컴퓨터공학 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>&lt;img src=x onerror=&quot;alert(document.cookie)&quot;&gt;</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 신뢰 설정을 확인한다.

확인 문제

  1. XSS 가 동일 출처 정책을 “우회” 하는 방식을 설명하라.
  2. <a href="{사용자 입력}"> 에서 HTML 인코딩만으로 부족한 이유는?
  3. CSRF 공격자가 응답 내용을 읽을 수 없는데도 공격이 성립하는 이유는?
  4. XSS 취약점이 있으면 CSRF 토큰이 무력해지는 이유는?
  5. HttpOnly 쿠키가 XSS 의 근본 대책이 아닌 이유는?

풀이

  1. 공격 스크립트가 우리 페이지 안에서 실행되므로 우리 출처의 권한을 그대로 가진다. 정책을 깨는 게 아니라 정책 안쪽으로 들어온다.
  2. javascript: 같은 스킴은 HTML 특수문자 없이도 스크립트를 실행한다. URL 스킴 허용 목록 검증이 필요하다.
  3. 송금·설정 변경처럼 요청이 서버에 도달해 처리되는 것 자체가 피해이기 때문이다.
  4. 같은 출처에서 실행되는 스크립트는 페이지의 토큰을 읽어 정상 요청에 붙일 수 있다.
  5. 쿠키를 훔치지 못할 뿐, 스크립트가 사용자 브라우저에서 사용자 권한으로 요청을 보내고 화면을 조작하는 것은 막지 못한다.

더 읽을거리 (References)