SSTI(Server Side Template Injection)
SSTI 란?
템플릿 엔진의 고유 문법을 이용해 악성 페이로드를 삽입하고, 이를 서버 측에서 실행하는 공격.
CSTI는 브라우저에서 실행되는 반면 SSTI는 서버에서 실행되는 공격. 둘 다 템플릿 엔진의 고유 문법을 이용해서 악의적인 입력을 템플릿에 이용하는데, 피해자가 각각 클라이언트 / 서버로 다름. 따라서 CSTI는 최대 XSS 공격으로 연계될 수 있는 것에 반해 SSTI는 최대 서버 장악까지 갈 수 있으므로 더 위험하다.
SSTI 원리
사용자 입력이 템플릿에 데이터로 전달되는 게 아니라 문자열로 이어붙여질 때 발생한다. 약간… 넓게 보면 SQLi랑 원리가 같음.
Twig 템플릿의 예시로 설명하면,
- 입력값이 템플릿에 데이터로 전달되는 경우
1
$output = $twig->render("Dear {first_name},", array("first_name" => $user.first_name));
{first_name}은 고정 placeholder이고 값은 두 번째 인자로 데이터로만 전달되므로, 만약 그 값에 {{7*7}} 같은 문자열이 들어오더라도 새 표현식으로 해석되지 않고 그냥 출력용 문자열로 취급된다.
- 입력값이 템플릿에 들어가서 파싱 될 때 표현식으로 실행되는 경우
1
$output = $twig->render("Dear " . $_GET['name']);
“Dear “이라는 고정 문자열과 $_GET[‘name’]이 이어붙여지는 구조로, name 파라미터가 템플릿 소스의 일부가 되어버린다.
따라서 입력값으로 ?name={{77}}를 넣으면 렌더링될 때 템플릿이 “Dear {{77}}”가 되어 Twig가 이걸 파싱하면서 {{7*7}}를 표현식으로 실행시킨다.
SSTI 공격 흐름
크게 세 단계로 이루어진다.
Detect(SSTI취약점이 존재하는지) → Identify(무슨 엔진을 쓰는지) → Exploit(어떻게 공격할지)
1. Detect — 취약점 탐지
먼저 퍼징하기. 템플릿 표현식에 자주 쓰이는 특수문자를 한 번에 던진다.
1
${{<%[%'"}}%\
이 문자열을 입력했을 때 예외(exception)가 발생하면, 서버가 이 문법을 해석하려다 깨졌다는 신호니까 SSTI 가능성을 의심할 수 있다. ${, {{, <%, %} 등 주요 엔진의 문법 조각이 섞여 있어 어떤 엔진이든 하나는 걸리도록 만든 페이로드.
단, 퍼징 결과와 무관하게 아래 두 가지 context를 반드시 따로 테스트해야 한다. SSTI는 입력이 들어가는 위치에 따라 탐지 방법이 갈린다.
(A) Plaintext context
사용자 입력이 텍스트 자리에 그대로 렌더링되는 경우다.
1
render('Hello ' + username)
?username=${7*7} 을 넣어 결과가 Hello 49 로 나오면, 서버에서 수식이 계산된 것이므로 SSTI PoC가 된다.
이 context는 XSS와 혼동하기 쉬운데, <script> 같은 태그를 넣으면 XSS로도 동작하기 때문이다. 그러나 ${7*7} → 49 는 XSS로 설명 되지 않는다. 수식 평가는 오직 템플릿 엔진만 수행하기 때문.. 그래서 태그가 아니라 수식으로 테스트하는 거…
(B) Code context
사용자 입력이 이미 존재하는 표현식 안에 들어가는 경우다.
1
2
greeting = getQueryParameter('greeting')
engine.render("Hello {{"+greeting+"}}", data)
내 입력이 {{ ... }} 내부에 들어간다. ?greeting=data.username 이면 {{data.username}} 이 되는 구조로, 개발자는 “변수 이름만 사용자가 고르도록” 의도한 것이다.
이 경우는 결과가 단순 hashmap 조회처럼 보여서 탐지가 까다롭다. 다음 순서로 확인한다.
?greeting=data.username<tag>— HTML을 넣어본다. XSS라면 태그가 살아야 하지만, 보통 빈칸/인코딩/에러가 나온다 → 직접적인 XSS는 아님을 확인.?greeting=data.username}}<tag>—}}로 표현식을 탈출(break out)시키고 그 뒤에 HTML을 붙인다.- 결과가
Hello Carlos<tag>처럼 태그가 살아나오면 표현식 밖으로 빠져나온 것이므로 SSTI가 존재한다.
정리하면, plaintext context는 ${7*7} 를 바로 던지면 되지만, code context는 먼저 }} 로 기존 표현식을 닫고 나와야 한다는 차이가 있다.
2. Identify — 엔진 판별
문법이 비슷해도 엔진마다 미묘하게 다르므로, exploit 페이로드를 고르려면 엔진을 특정해야 한다.
1순위: 에러 메시지 유도. 깨지는 문법을 넣으면 에러가 엔진 이름(때로는 버전)까지 노출한다. 예를 들어 <%=foobar%> 를 넣으면 Ruby ERB가 스택트레이스에 erb.rb 를 그대로 드러낸다. 가장 빠른 방법이다.
2순위: 결정 트리로 소거법. 수식 PoC를 엔진별 문법으로 바꿔 던지며 범위를 좁혀나간다.
1
2
3
4
5
6
7
8
9
10
11
12
${7*7} 평가됨?
├── YES → a{*comment*}b 넣어봄
│ ├── "ab" 출력 → Smarty
│ └── 아니면 ${"z".join("ab")} 넣어봄
│ ├── "zab" 출력 → Mako
│ └── else → Unknown
│
└── NO → {{7*7}} 평가됨?
├── YES → {{7*'7'}} 넣어봄
│ ├── 7777777 → Jinja2
│ └── 49 → Twig
└── NO → 템플릿 인젝션 아님(또는 다른 엔진)
{{7*'7'}} 로 Twig와 Jinja2가 갈리는 부분이 특히 중요하다.
- Jinja2 는 Python 시맨틱을 따르므로
'7' * 7= 문자열 반복 =7777777 - Twig 는 PHP 시맨틱을 따르므로
'7'이 숫자 7로 형변환되어7*7=49
같은 페이로드지만 언어의 타입 처리 방식 차이로 엔진이 갈린다. {{7*7}} = 49 는 Twig와 Jinja2 양쪽에서 모두 나오기 때문에, {{7*'7'}} 까지 테스트해야 확정할 수 있다. 하나의 성공 응답만으로 엔진을 단정해서는 안 된다.
3. 공격 수행
엔진을 특정한 뒤에는 해당 엔진 전용 페이로드로 실제 공격을 수행한다. 대부분 객체 체인을 따라가 OS 명령 실행 함수(os.system, Runtime.exec 등)에 도달해 RCE를 노리는데.. 엔진별로 방식이 크게 다르다고 함.
나머지 정리는 나중에………