CSRF(Cross-Site Request Forgery) 공격과 방어

쇼핑 사이트에 로그인한 상태로 다른 탭에서 별 생각 없이 열어본 기사 링크 하나가, 내 계정에서 100만 원짜리 상품 주문을 자동으로 날려버릴 수 있다면 믿어지시나요? 바로 이게 CSRF(Cross-Site Request Forgery) 공격의 본질입니다. “사이트 간 요청 위조”라는 이름 그대로, 공격자가 내 브라우저를 통해 내 이름으로 요청을 보내는 공격이에요.

이 글에서는 CSRF가 왜 가능한지, 공격이 실제로 어떻게 성립하는지, 그리고 이를 막는 네 가지 방어 기법(Synchronizer Token, Double Submit Cookie, SameSite 쿠키, Origin 헤더 검증)을 차례로 알아봅니다. 실무에서 어떤 방어를 조합하는 게 좋은지, 그리고 OAuth의 state 파라미터가 사실상 같은 원리로 동작한다는 점까지 이어서 다루겠습니다.

CSRF 공격이 가능한 이유

브라우저에는 흔히 간과되는 중요한 특성이 하나 있습니다. 어떤 사이트로든 요청이 나갈 때, 해당 사이트 도메인에 저장된 쿠키가 자동으로 첨부된다는 점인데요.

쿠키에 로그인 세션이 들어 있다면, 브라우저가 대신 로그인 상태를 유지해주는 편리한 구조입니다. 하지만 이 편리함의 이면에 구멍이 숨어 있어요. 어느 출처의 페이지가 요청을 일으켰든 상관없이 쿠키가 자동으로 붙는다는 점입니다.

예를 들어 내가 bank.com에 로그인한 상태에서 다른 탭에 열린 evil.combank.com으로 요청을 보내도, 브라우저는 “이 요청은 evil.com에서 시작됐지만 목적지가 bank.com이니 bank.com의 쿠키를 붙여 보낸다”라고 판단합니다. 결과적으로 서버는 정상 로그인된 사용자의 요청과 공격자가 유도한 요청을 구분할 수 없어요.

여기에 HTML의 특성이 한 가지 더 얹힙니다. <img>, <form>, <script> 같은 태그는 별도의 사용자 클릭 없이 다른 출처로 요청을 일으킬 수 있거든요. 즉, 공격자는 사용자가 단지 페이지를 열어보기만 해도 내 세션을 타고 원치 않는 동작을 수행시킬 수 있는 길이 열립니다.

공격 시나리오 한 번 따라가 보기

말로만 들으면 추상적이니 구체적인 흐름을 그려보겠습니다. 가장 고전적인 CSRF 사례는 “폼 자동 제출”입니다.

사용자 입장에서 벌어지는 일은 이렇습니다.

  1. 피해자가 bank.com에 로그인한다 (세션 쿠키가 브라우저에 저장됨)
  2. 같은 브라우저에서 공격자가 보낸 이메일의 링크 evil.com을 연다
  3. 페이지에 별다른 내용이 없어 보여 닫아버린다

그동안 evil.com에서는 이런 HTML이 로드됐습니다.

evil.com에 숨어 있는 폼
<form id="attack" action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="1000000" />
</form>
<script>
  document.getElementById("attack").submit();
</script>

페이지가 로드되는 순간 <script>가 자동으로 폼을 제출합니다. 이 POST 요청이 bank.com으로 나가면서, 브라우저는 bank.com의 세션 쿠키를 자동으로 함께 붙여요. bank.com 서버 입장에서는 “정상 로그인된 사용자가 이체 요청을 보냈다”로 읽히므로, 검증 없이 이체를 처리하게 됩니다.

사용자는 자기도 모르는 사이에 공격자 계좌로 돈을 보낸 상태가 되는 거죠. GET 요청으로도 같은 공격이 가능하고, <img src="...">src만으로도 서버 상태를 바꾸는 요청을 촉발할 수 있습니다.

Synchronizer Token Pattern

가장 고전적이면서 지금도 강력한 방어가 Synchronizer Token Pattern입니다. 핵심 아이디어는 이렇습니다.

서버가 요청마다(혹은 세션마다) 예측 불가능한 CSRF 토큰을 발급하고, 이 토큰을 폼 안에 숨겨진 필드로 박아둡니다. 요청이 올 때 이 토큰이 함께 오지 않거나 서버가 발급한 값과 일치하지 않으면 요청을 거부해요.

서버가 렌더링한 폼
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="aB3dE5fG7hI9jK..." />
  <input type="text" name="to" />
  <input type="number" name="amount" />
  <button>이체</button>
</form>

왜 이게 CSRF를 막을까요? 공격자의 evil.com은 피해자가 bank.com에서 받은 토큰 값을 알 수 없기 때문입니다. 브라우저의 동일 출처 정책(Same-Origin Policy) 때문에 evil.com의 스크립트는 bank.com의 응답 본문을 읽을 수 없거든요. 쿠키는 자동으로 붙일 수 있지만, HTML 본문 안에 박힌 토큰은 읽어올 방법이 없습니다.

서버 구현 측면에서 주의할 두 가지가 있습니다.

  • 토큰은 세션별로 예측 불가능하게 생성 — 공격자가 추측할 수 없도록 암호학적으로 안전한 난수 생성기(crypto.randomBytes 등)를 써야 합니다
  • 토큰 비교는 상수 시간 — 문자열 직접 비교는 타이밍 공격에 취약할 수 있으니, 상수 시간 비교 함수를 씁니다

Express의 csurf 미들웨어, Django의 {% csrf_token %} 템플릿 태그, Rails의 protect_from_forgery 같은 프레임워크 기본 기능이 모두 이 패턴의 구현체입니다.

Synchronizer Token은 서버가 토큰 상태를 관리해야 해서 무상태(stateless) API에서는 불편합니다. 이 문제를 풀기 위한 패턴이 Double Submit Cookie예요.

원리는 간단합니다. 서버가 CSRF 토큰을 쿠키로 설정하고, 클라이언트는 요청 시 같은 토큰을 헤더(또는 폼 필드)로도 함께 보냅니다. 서버는 쿠키 값과 헤더 값이 일치하면 통과시키고요.

클라이언트가 보내는 요청
POST /transfer HTTP/1.1
Host: bank.com
Cookie: session=...; csrf_token=aB3dE5fG7hI9jK...
X-CSRF-Token: aB3dE5fG7hI9jK...

이게 왜 방어가 되느냐면, 공격자가 evil.com에서 bank.com으로 요청을 보낼 때 쿠키는 자동으로 붙지만 헤더는 직접 채워 넣어야 하기 때문입니다. 헤더에 넣을 값을 공격자가 알 수 없으니(쿠키 값을 읽어올 수 없으니) 둘이 일치하는 요청을 만들 수 없어요.

서버에 상태를 두지 않아도 된다는 장점이 있는 대신, 구현 함정도 있습니다.

  • 쿠키와 헤더를 단순 비교만 하면 부족 — 공격자가 서브도메인(evil.bank.com)을 장악해 쿠키를 심으면 우회가 가능합니다. 최신 권고는 토큰에 세션 식별자를 HMAC으로 묶는 signed double submit이에요
  • JavaScript가 쿠키를 읽어 헤더로 복사해야 하므로 이 쿠키만큼은 HttpOnly로 둘 수 없습니다. XSS 방어를 다른 층에서 보완해야 해요

SameSite 쿠키

위의 두 패턴이 애플리케이션 레이어의 방어라면, SameSite 쿠키는 브라우저 자체가 제공하는 방어입니다. 쿠키 설정 시 SameSite 속성을 넣으면, 브라우저가 “교차 사이트 요청에는 이 쿠키를 붙이지 않는다”는 규칙을 적용해줘요.

쿠키 설정 예시
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax

세 가지 값이 있습니다.

  • Strict — 교차 사이트 요청에는 일절 쿠키를 붙이지 않음. 가장 안전하지만 외부 링크로 사이트에 들어올 때 이미 로그인 상태였어도 다시 로그인해야 하는 UX 문제가 생김
  • Lax — 교차 사이트 요청 중 최상위 내비게이션(링크 클릭 등)에만 쿠키를 붙이고, POST·스크립트 요청에는 붙이지 않음. 보안과 UX의 현실적 절충
  • None — 제한 없이 붙임. 단, 이 값을 쓰려면 반드시 Secure도 함께 지정해야 함(HTTPS에서만 전송)

주요 브라우저가 기본값을 Lax로 옮기면서, 아무 설정도 안 해도 CSRF의 대다수 시나리오가 자동으로 차단되는 흐름이 자리 잡았습니다. 다만 이게 만병통치약은 아닙니다.

  • 최상위 내비게이션으로 포장된 GET 공격(<a href="bank.com/transfer?...">클릭</a>)은 여전히 쿠키가 붙음
  • 일부 오래된 브라우저는 SameSite 지원이 부정확함
  • 서브도메인은 “same site”로 간주되기 때문에 서브도메인 XSS로의 우회가 남아 있음

그래서 SameSite는 기본 방어막 위에 추가로 토큰 기반 방어를 얹는 defense-in-depth가 권장됩니다.

Origin 헤더 검증

브라우저는 교차 출처 요청과 POST 요청에 Origin 헤더를 자동으로 붙입니다. 서버가 이 헤더를 검사해서 허용된 출처가 아니면 거부하는 것도 CSRF 방어가 돼요.

정상 요청
POST /transfer HTTP/1.1
Host: bank.com
Origin: https://bank.com

evil.com이 보낸 요청이라면 Origin: https://evil.com이 붙어 올 테니, 서버는 “허용 목록에 없다”며 거부할 수 있어요.

이 방법의 단점은 Origin 헤더가 모든 요청에 붙진 않는다는 점입니다. 같은 출처의 일부 GET 요청에는 생략될 수 있고, 구형 브라우저는 아예 지원하지 않는 경우도 있어요. 그래서 Origin이 있을 때만 엄격히 검사하고, 없으면 Referer 헤더로 폴백한 뒤, 두 헤더 모두 없는 요청은 애초에 상태 변경을 거부하는 식으로 보수적으로 다뤄야 합니다.

Origin·Referer 헤더의 자세한 차이는 HTTP 요청 헤더: Origin, Host, Referer에서 다뤘으니 함께 읽어보시면 좋습니다.

실무에서 조합하는 법

위의 네 가지 방어는 배타적이지 않고 겹쳐 쓰는 게 원칙입니다. 단일 방어가 깨지더라도 다른 층에서 막는 방어 심층화(defense in depth)가 CSRF의 표준 전략이에요.

일반적인 웹 애플리케이션에 권장되는 조합은 이렇습니다.

  1. 쿠키를 SameSite=Lax(혹은 민감 세션은 Strict)로 설정 — 대부분의 CSRF 시나리오를 브라우저 단에서 자동 차단
  2. 상태를 변경하는 요청에 CSRF 토큰 요구 — Synchronizer Token 또는 signed Double Submit
  3. 서버는 Origin/Referer 헤더를 보조적으로 검증 — 토큰 검증 앞단의 저렴한 필터로 동작
  4. 안전하지 않은 메서드(POST·PUT·DELETE)에만 보호 적용GET은 원칙적으로 상태를 바꾸지 않도록 설계. 이 규칙이 깨져 있다면 CSRF 방어 이전에 API 설계부터 점검해야 합니다

SPA와 JSON API 조합이라면 많은 경우 커스텀 헤더 요구(예: X-Requested-With: XMLHttpRequest)만으로도 상당한 방어가 됩니다. 브라우저는 교차 사이트 요청에 커스텀 헤더를 자동으로 붙일 수 없기 때문에 preflight가 발생하고, 서버는 Access-Control-Allow-Origin으로 거를 수 있거든요. 다만 이것만으로는 부족하고, 토큰 방어를 병행하는 게 안전합니다.

OAuth의 state 파라미터가 하는 일

지금까지의 설명이 익숙하게 들린다면 당연합니다. OAuth 2.0 인가 요청에 들어가는 state 파라미터가 사실상 CSRF 토큰의 OAuth 버전이거든요.

OAuth의 Authorization Code Flow에서는 사용자가 AS로 리다이렉트됐다가 클라이언트의 redirect_uri로 돌아올 때 인가 코드를 받습니다. 이 시점에 공격자가 자기 계정의 인가 코드를 피해자의 브라우저에 주입하면, 피해자가 공격자 계정에 묶이는 CSRF 변형 공격이 성립할 수 있어요.

이를 막기 위해 클라이언트는 인가 요청을 보낼 때 예측 불가능한 state 값을 생성해 함께 전송하고, 콜백으로 돌아오는 state가 같은 값인지 검증합니다.

인가 요청
GET /authorize?client_id=...&state=aB3dE5fG7...
콜백
GET /callback?code=xyz&state=aB3dE5fG7...   ← 원본과 같아야 함

원리는 완전히 같습니다. “클라이언트가 만들어낸 예측 불가능한 토큰이 요청과 콜백을 왕복하며 정합성을 보증한다”는 규칙이죠. OAuth를 쓰든 일반 폼을 쓰든, 브라우저 기반 상태 변경에는 CSRF 방어가 빠질 수 없다는 원칙은 동일합니다.

마치며

CSRF는 “브라우저가 쿠키를 자동으로 붙인다”는 편의 기능과 “<form>·<img> 태그가 교차 출처 요청을 쉽게 일으킨다”는 웹의 개방성이 만났을 때 생기는 구조적인 취약점입니다. 그래서 방어도 어느 한 층에만 의존할 수 없고, 브라우저(SameSite)·애플리케이션(CSRF 토큰)·HTTP 헤더(Origin·Referer)를 겹쳐 쓰는 다층 방어가 기본 전략이 됩니다.

현대적인 웹 프레임워크 대부분은 이 중 몇 층을 기본으로 활성화해둡니다. 내가 쓰는 프레임워크가 어느 층까지 책임져주고 어느 층은 내가 직접 설정해야 하는지 한 번쯤 확인해보시길 권해요. 그리고 POST 요청에만 신경 쓰고 GET으로 상태를 바꾸는 관행이 남아 있다면, 그 관행부터 정리하는 게 어떤 CSRF 방어보다도 선행되어야 합니다.

더 자세한 내용은 OWASP Cross-Site Request Forgery Prevention Cheat Sheet를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord