본문으로 건너뛰기
yutils

XSS 는 어떻게 동작할까?

reflected vs stored vs DOM-based XSS, innerHTML 이 용인 이유, context 잘못된 escape 의 실패 (HTML attribute vs URL vs JS), DOMPurify, Trusted Types, 가장 많은 공격 막는 실제 CSP 룰.

약 10분 읽기

Cross-Site Scripting — OWASP Top 10 단골. user input 이 페이지에 그대로 박혀서 JavaScript 실행. 단순해 보이지만 context 마다 다른 encoding 이 필요하고, modern web 의 SPA / template / framework 가 다 다른 함정. 이 가이드는 XSS 3 type, escape 의 context-sensitivity, CSP / Trusted Types 의 실제 동작을 정리한다.

XSS 3 type

1. Reflected XSS

URL 의 input 이 응답에 그대로 박힘.

https://example.com/search?q=<script>alert(1)</script>

서버 응답 HTML:
  <h1>Results for <script>alert(1)</script></h1>
                  ↑
                  실행됨

공격 시나리오:
  공격자 → 위 URL 을 피해자에게 (이메일·메시지)
  피해자 클릭 → 자기 쿠키 노출, 자기 권한으로 행동

2. Stored XSS

악성 input 이 DB 에 저장 → 모든 사용자에게 전달.

공격자 → comment.body = "<script>steal()</script>" POST
DB 저장
다른 사용자 → 그 페이지 방문 → 모두에게 실행

→ Reflected 보다 훨씬 위험 (한 번 공격으로 전체 사용자 영향)

3. DOM-based XSS

서버 거치지 않고 client JavaScript 가 직접 실행.

// 페이지의 JS
const name = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = "Hello, " + name;
//                                  ↑↑↑↑↑↑↑↑↑
//                                  여기가 폭탄

URL: /?name=<img src=x onerror=alert(1)>
→ innerHTML 이 <img> 를 DOM 으로 → onerror 실행

서버 log 에 안 보임 (URL 의 fragment / hash 면 server 전송 X).

Context-sensitive Escape — 가장 흔한 함정

같은 user input 이 어디에 들어가느냐에 따라 escape 방식 다름.

1. HTML 본문 — & < > " ' escape

<div>{user_input}</div>

input: <script>x</script>
escape: &lt;script&gt;x&lt;/script&gt;
→ 화면에 글자 그대로 표시, 실행 X

2. HTML attribute — 같은 + 따옴표 escape

<input value="{user_input}">

input: " onmouseover="alert(1)
부족한 escape: <input value="" onmouseover="alert(1)">
                              ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
                              새 attribute 로 parsed

올바른 escape: 따옴표 + 모든 attribute-breaking 문자 escape.

3. JavaScript context — JSON encoding

<script>
  const data = "{user_input}";
</script>

input: ";alert(1);//
부족한 escape: const data = "";alert(1);//";
                                ↑↑↑↑↑↑↑↑↑↑↑↑
                                실행됨

올바른 방법: JSON.stringify 후 박기 (또는 따로 attribute 로 전달).
  const data = JSON.parse(document.getElementById("data").textContent);

4. URL context

<a href="{user_input}">click</a>

input: javascript:alert(1)
→ click 시 JavaScript 실행 (javascript: protocol)

올바른 방법: protocol whitelist (http:, https:, mailto: 만).
encodeURIComponent 만으로는 javascript: 도 통과.

→ 모든 context 의 일관 escape 는 불가능. framework 의 context-aware 렌더링 의지 (React JSX, Vue templates, Angular bindings 모두 default 로 escape).

innerHTML 의 위험

element.innerHTML = userInput;  // ❌ XSS 위험

대안:
  element.textContent = userInput;  // ✓ HTML parsing 안 함
  element.setAttribute("class", userInput);  // attribute escape 자동

용 비교:
  // React
  <div>{userInput}</div>            ✓ 자동 escape
  <div dangerouslySetInnerHTML={{__html: userInput}}>  ❌ raw HTML

  // Vue
  <div>{{ userInput }}</div>        ✓ 자동 escape
  <div v-html="userInput">          ❌ raw HTML

  // 모든 *HTML / *raw* / dangerous* 이름은 위험 신호.

DOMPurify — 안전한 HTML 허용

사용자가 HTML 입력 허용 (마크다운 → HTML 후, 리치 에디터 등):

const clean = DOMPurify.sanitize(userInput);
element.innerHTML = clean;

DOMPurify 가 하는 일:
- <script>, <iframe>, <object> 등 위험 태그 제거
- onclick, onerror 등 event handler 제거
- javascript: URL 제거
- whitelist 기반 (안전 알려진 것만 통과)

→ 직접 regex 로 <script> 거르기 = 항상 우회 패턴 존재. 라이브러리 의지.

Content Security Policy (CSP)

HTTP header 로 "어디서 온 script / style / img 만 OK" 선언.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://apis.google.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  frame-ancestors 'none';

의미:
- script 는 자기 도메인 + Google API 만
- inline <script> 차단 (= XSS payload 99% 차단)
- iframe 안에 박는 거 차단 (clickjacking 방어)

XSS 의 1차 방어:
- script-src 에서 'unsafe-inline' 빼기 (가장 효과 큼)
- nonce 또는 hash 기반 inline 허용
- strict-dynamic 모드 (modern, framework 호환)

Trusted Types — modern browser API

Chrome 83+, Firefox/Safari 미지원.

CSP 헤더에 require-trusted-types-for 'script'

→ innerHTML / eval / setTimeout(string) 등에 plain string 거부
→ TrustedHTML / TrustedScript 객체만 허용
→ 객체 만들 때 policy 필수 (sanitize 명시)

policy = trustedTypes.createPolicy("default", {
  createHTML: (input) => DOMPurify.sanitize(input),
});

→ "DOMPurify 거치지 않은 HTML 은 절대 박을 수 없게" runtime 강제.

관련 도구

흔한 함정

  • "우리는 React 쓰니 XSS 안전" — dangerouslySetInnerHTML / refs.innerHTML / 외부 라이브러리는 우회. CSP 함께.
  • regex 로 escape 시도 — context 마다 다른데 한 regex 로 못 막음. framework 의 context-aware 렌더링 의지.
  • frame 안 XSS — same-origin iframe 의 XSS 가 parent 영향. frame-ancestors / sandbox 설정 필요.
  • SVG / Markdown 의 XSS — SVG 안 <script>, Markdown 의 raw HTML 허용 → DOMPurify 같이 통과.
  • URL parameter trust — 자기 페이지가 자기 URL 쓰는 거 = trust 아님. server-side validate + client-side encode 둘 다.

마무리

XSS 의 본질 = "user input 이 코드처럼 실행". 방어는 context-aware escape + CSP + Trusted Types 의 다층. 한 layer 만 의존 X.

실용 — modern framework (React/Vue/Angular) 의 default escape + DOMPurify (HTML 허용 영역) + 엄격한 CSP (no unsafe-inline) + 보안 헤더. dangerouslySetInnerHTML 같은 escape hatch 사용 시 review 강제.

가이드 목록으로