[CS300 #252] XSS 와 CSRF — 브라우저의 신뢰를 악용하는 두 공격
컴퓨터공학 300 주제 시리즈의 252번째 글이다. 전체 지도는 여기.
한 줄 요약
XSS 는 공격자의 스크립트가 우리 사이트의 출처로 실행되게 만드는 공격이고, CSRF 는 사용자의 브라우저가 자동으로 붙이는 쿠키를 이용해 다른 사이트에서 우리 사이트로 원치 않는 요청을 보내게 하는 공격이다. XSS 는 출력 인코딩과 CSP 로, CSRF 는 토큰과 SameSite 쿠키로 막는다.
왜 필요한가
브라우저 보안의 기본 원칙은 동일 출처 정책(Same-Origin Policy) 이다. a.com 의 스크립트는 b.com 의 데이터를 읽을 수 없다. 두 공격은 이 원칙을 정면으로 깨지 않고 옆으로 돌아간다.
- XSS 는 공격 코드를 우리 출처 안으로 들여보낸다. 그러면 동일 출처 정책이 오히려 공격 코드를 보호한다. 세션 탈취, 화면 위조, 사용자 대신 모든 행동이 가능해진다.
- CSRF 는 다른 출처에서 요청을 보내기만 한다. 응답은 못 읽지만, 송금·비밀번호 변경처럼 요청 자체가 피해인 경우엔 충분하다.
OWASP Top 10:2025 는 XSS 를 인젝션 범주에, CSRF 를 접근 통제 실패 범주에 넣는다. 둘 다 프론트엔드 프레임워크가 기본 방어를 많이 제공하지만, 그 방어를 우회하는 탈출구(innerHTML, dangerouslySetInnerHTML, v-html, CSRF 예외 설정)에서 계속 나온다.
핵심 개념
XSS 의 세 유형
| 유형 | 페이로드 위치 | 예 |
|---|---|---|
| 반사형(Reflected) | 요청 파라미터 → 즉시 응답에 반영 | 검색어를 “…에 대한 결과” 로 그대로 출력 |
| 저장형(Stored) | DB 에 저장 → 다른 사용자가 볼 때 실행 | 게시판 댓글, 프로필 이름 |
| DOM 기반 | 서버를 거치지 않고 클라이언트 JS 가 위험한 싱크에 넣음 | location.hash 를 innerHTML 에 대입 |
XSS 방어: 문맥에 맞는 출력 인코딩
핵심은 “입력을 거른다” 가 아니라 “출력하는 위치의 문법에 맞게 인코딩한다” 다. 같은 데이터라도 들어가는 자리마다 위험한 문자가 다르다.
| 출력 문맥 | 예 | 방어 |
|---|---|---|
| HTML 본문 | <p>값</p> |
& < > " ' HTML 엔티티 인코딩 |
| HTML 속성 | <input value="값"> |
속성 인코딩 + 반드시 따옴표로 감싸기 |
| JavaScript 데이터 | <script>var x = 값</script> |
JSON 직렬화 후 < 등 이스케이프. 가능하면 data 속성으로 넘긴다 |
| URL | <a href="값"> |
URL 인코딩 + javascript: 스킴 차단(허용 목록) |
| 사용자 HTML 허용 | 리치 텍스트 에디터 | 검증된 새니타이저(DOMPurify 등) |
템플릿 엔진의 자동 이스케이프를 켜고, 끄는 탈출구를 최소화하는 것이 1차 방어다. DOM 에서는 innerHTML 대신 textContent 를 쓴다.
XSS 심층 방어
- CSP(Content-Security-Policy): 실행 가능한 스크립트의 출처를 제한한다. 논스나 해시 기반의 엄격한 CSP 를 쓰면 인라인 주입 스크립트가 실행되지 않는다. 인코딩 실수가 있어도 피해를 줄인다.
- HttpOnly 쿠키: 스크립트에서
document.cookie로 세션 쿠키를 못 읽게 한다. 다만 XSS 가 성공하면 쿠키를 훔치지 않고도 사용자 권한으로 요청을 보낼 수 있으므로 근본 대책은 아니다.
CSRF 의 원리
1) 사용자가 bank.example 에 로그인 → 세션 쿠키 저장
2) 같은 브라우저로 evil.example 방문
3) evil 페이지의 숨은 폼이 bank.example/transfer 로 POST 자동 제출
4) 브라우저가 bank.example 쿠키를 자동으로 붙임 → 서버는 정상 요청으로 판단
조건: (1) 쿠키 같은 자동 첨부 자격 증명으로 인증하고 (2) 요청의 모든 파라미터를 공격자가 예측 가능하며 (3) 상태를 바꾸는 요청이다.
CSRF 방어
| 방법 | 원리 |
|---|---|
| CSRF 토큰(동기화 토큰, HMAC 토큰) | 세션에 묶인 예측 불가 값을 폼·헤더에 넣고 서버가 확인. 다른 출처는 이 값을 읽을 수 없다 |
| SameSite 쿠키 | Strict/Lax 면 교차 사이트 요청에 쿠키를 안 붙인다(Lax 는 최상위 GET 이동은 허용) |
| Origin / Fetch Metadata 확인 | Origin, Sec-Fetch-Site 헤더로 교차 사이트 요청을 거부 |
| 상태 변경은 GET 금지 | GET 은 링크·이미지로도 발생한다 |
MDN 에 따르면 일부 브라우저는 SameSite 를 지정하지 않으면 Lax 로 취급하지만, 모든 브라우저가 그렇지는 않다. 그래서 쿠키에 SameSite 를 명시하고 토큰을 함께 쓴다. 그리고 XSS 가 있으면 CSRF 방어는 무력하다. 같은 출처의 스크립트는 토큰을 읽을 수 있기 때문이다.
직접 해 보기
표준 라이브러리로 (1) HTML 문맥 인코딩 (2) script 문맥의 JSON 인코딩 (3) 세션에 묶인 HMAC CSRF 토큰을 확인한다. 페이로드는 실행되지 않는 문자열로만 다룬다.
import html, hmac, hashlib, secrets, json
comment = '<img src=x onerror="alert(document.cookie)">'
# --- XSS ---
def render_vulnerable(c):
return f"<li>{c}</li>" # 그대로 끼워 넣음
def render_safe(c):
return f"<li>{html.escape(c, quote=True)}</li>" # HTML 문맥 인코딩
print("취약:", render_vulnerable(comment))
print("개선:", render_safe(comment))
# <script> 안에 데이터를 넣을 때는 다른 인코딩이 필요하다
data = {"name": "</script><script>alert(1)</script>"}
naive = json.dumps(data)
safer = json.dumps(data).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
print("JSON 그대로 :", naive)
print("JSON 인코딩 :", safer)
# --- CSRF: 세션에 묶인 HMAC 토큰 ---
SERVER_KEY = secrets.token_bytes(32)
def csrf_token(session_id: str) -> str:
nonce = secrets.token_hex(8)
mac = hmac.new(SERVER_KEY, f"{session_id}:{nonce}".encode(), hashlib.sha256).hexdigest()
return f"{nonce}.{mac}"
def csrf_ok(session_id: str, token: str) -> bool:
try:
nonce, mac = token.split(".")
except ValueError:
return False
exp = hmac.new(SERVER_KEY, f"{session_id}:{nonce}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(exp, mac)
victim_sid = "sess-victim"
tok = csrf_token(victim_sid) # 폼에 숨겨 넣은 값
print("정상 폼 제출 :", csrf_ok(victim_sid, tok))
print("외부 사이트 위조 요청:", csrf_ok(victim_sid, ""))
print("다른 세션 토큰 재사용:", csrf_ok(victim_sid, csrf_token("sess-attacker")))
실행 결과(Python 3.12):
취약: <li><img src=x onerror="alert(document.cookie)"></li>
개선: <li><img src=x onerror="alert(document.cookie)"></li>
JSON 그대로 : {"name": "</script><script>alert(1)</script>"}
JSON 인코딩 : {"name": "</script><script>alert(1)</script>"}
정상 폼 제출 : True
외부 사이트 위조 요청: False
다른 세션 토큰 재사용: False
두 번째 예가 중요하다. json.dumps 결과는 올바른 JSON 이지만, HTML 파서는 JSON 을 모른다. 문자열 안의 </script> 를 보고 스크립트 블록을 닫아 버린다. “JSON 으로 직렬화했으니 안전하다” 는 문맥을 무시한 판단이다. 세 번째 예에서 공격자는 피해자 세션의 쿠키를 브라우저가 대신 보내 주더라도, 그 세션에 묶인 토큰을 알 수 없어 실패한다. 자기 세션 토큰을 가져와도 세션 ID 가 MAC 에 들어 있어 통하지 않는다.
현업에서는
- 프레임워크 탈출구 검색: React 의
dangerouslySetInnerHTML, Vue 의v-html, Angular 의bypassSecurityTrust*, 서버 템플릿의|safe·autoescape off를 코드 검색으로 전수 확인한다. 대부분의 XSS 가 여기서 나온다. - CSP 도입은 Report-Only 부터:
Content-Security-Policy-Report-Only로 위반 보고만 받아 기존 인라인 스크립트를 정리한 뒤 강제로 전환한다. 한 번에 켜면 화면이 깨진다. - API 와 CSRF:
Authorization: Bearer헤더로 인증하는 API 는 브라우저가 토큰을 자동 첨부하지 않으므로 CSRF 영향이 작다. 반면 SPA 가 쿠키 세션을 쓰면 CSRF 방어가 다시 필요하다. 인증 방식에 따라 판단이 바뀐다. - 쿠키 속성 점검: 인그레스나 리버스 프록시 뒤의 앱은 TLS 를 프록시가 처리하므로 앱이 “HTTPS 가 아니다” 로 판단해
Secure속성을 빠뜨리는 경우가 있다.X-Forwarded-Proto신뢰 설정을 확인한다.
확인 문제
- XSS 가 동일 출처 정책을 “우회” 하는 방식을 설명하라.
<a href="{사용자 입력}">에서 HTML 인코딩만으로 부족한 이유는?- CSRF 공격자가 응답 내용을 읽을 수 없는데도 공격이 성립하는 이유는?
- XSS 취약점이 있으면 CSRF 토큰이 무력해지는 이유는?
- HttpOnly 쿠키가 XSS 의 근본 대책이 아닌 이유는?
풀이
- 공격 스크립트가 우리 페이지 안에서 실행되므로 우리 출처의 권한을 그대로 가진다. 정책을 깨는 게 아니라 정책 안쪽으로 들어온다.
javascript:같은 스킴은 HTML 특수문자 없이도 스크립트를 실행한다. URL 스킴 허용 목록 검증이 필요하다.- 송금·설정 변경처럼 요청이 서버에 도달해 처리되는 것 자체가 피해이기 때문이다.
- 같은 출처에서 실행되는 스크립트는 페이지의 토큰을 읽어 정상 요청에 붙일 수 있다.
- 쿠키를 훔치지 못할 뿐, 스크립트가 사용자 브라우저에서 사용자 권한으로 요청을 보내고 화면을 조작하는 것은 막지 못한다.