[Web Security] CSRF 공격과 방어 기법 완벽 가이드

🎯 CSRF(Cross-Site Request Forgery)란?

CSRF는 사용자가 의도하지 않은 요청을 공격자가 만든 웹사이트를 통해 실행시키는 공격입니다.
사용자가 이미 로그인되어 있는 사이트에서, 공격자가 조작한 요청을 사용자 몰래 전송하게 만듭니다.

공격 시나리오 예시

  1. 사용자가 은행 사이트(bank.com)에 로그인되어 있음
  2. 공격자가 만든 악성 사이트(evil.com)를 방문
  3. 악성 사이트에 숨겨진 코드가 bank.com으로 송금 요청을 전송
  4. 브라우저는 자동으로 쿠키를 포함해 요청을 전송
  5. 은행 서버는 정상적인 요청으로 인식하고 처리

🔍 CSRF 공격의 핵심 원리

브라우저의 쿠키 자동 전송

브라우저는 같은 도메인으로의 모든 요청에 자동으로 쿠키를 첨부합니다.

GET /transfer?to=attacker&amount=10000 HTTP/1.1
Host: bank.com
Cookie: session_id=abc123 ← 자동으로 포함됨!

공격 코드 예시

1. 이미지 태그를 이용한 공격

<!-- 사용자가 evil.com을 방문했을 때 -->
<img src="https://bank.com/transfer?to=attacker&amount=10000"
style="display:none">

2. 자동 제출 폼을 이용한 공격

<form action="https://bank.com/transfer" method="POST" id="hack">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>

<script>
document.getElementById('hack').submit();
</script>

3. AJAX를 이용한 공격

fetch('https://bank.com/transfer', {
method: 'POST',
credentials: 'include', // 쿠키 포함
body: JSON.stringify({
to: 'attacker',
amount: 10000
})
});

🛡️ CSRF 방어 기법

1. CSRF Token (가장 기본적이고 강력한 방법)

서버가 생성한 랜덤한 토큰을 폼에 포함시키고, 요청 시 검증합니다.

서버 측 구현 (Spring Security 예시)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
}

클라이언트 측 구현

<!-- HTML 폼 -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="${csrfToken}">
<input type="text" name="to">
<input type="number" name="amount">
<button type="submit">송금</button>
</form>
// JavaScript (AJAX)
const csrfToken = document.querySelector('meta[name="_csrf"]').content;

fetch('/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
to: 'recipient',
amount: 1000
})
});

브라우저가 외부 사이트에서의 요청에는 쿠키를 전송하지 않도록 설정합니다.

Set-Cookie: session_id=abc123; SameSite=Strict; HttpOnly; Secure

SameSite 옵션

설명
Strict 외부 사이트에서 온 요청에는 절대 쿠키를 전송하지 않음
Lax GET 요청 등 일부 안전한 요청에만 쿠키 전송 (기본값)
None 모든 요청에 쿠키 전송 (HTTPS + Secure 속성 필수)

Spring Boot 설정

# application.yml
server:
servlet:
session:
cookie:
same-site: strict
http-only: true
secure: true
// Java 코드로 설정
@Bean
public CookieSameSiteSupplier cookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofStrict();
}

3. Referer/Origin 헤더 검증

요청이 신뢰할 수 있는 도메인에서 왔는지 확인합니다.

@Component
public class CsrfRefererFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {

String referer = request.getHeader("Referer");
String origin = request.getHeader("Origin");

if (!"POST".equals(request.getMethod())) {
chain.doFilter(request, response);
return;
}

if (referer == null || !referer.startsWith("https://bank.com")) {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Invalid referer");
return;
}

chain.doFilter(request, response);
}
}

CSRF 토큰을 쿠키와 요청 파라미터 양쪽에 포함시키고, 서버에서 두 값이 일치하는지 확인합니다.

// 쿠키에서 토큰 읽기
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}

// 요청 시 헤더에 토큰 포함
const csrfToken = getCookie('XSRF-TOKEN');

fetch('/api/transfer', {
method: 'POST',
headers: {
'X-XSRF-TOKEN': csrfToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});

5. Custom Header 사용

AJAX 요청에 커스텀 헤더를 추가하는 방법입니다.
Same-Origin Policy에 의해 외부 사이트는 커스텀 헤더를 추가할 수 없습니다.

fetch('/api/transfer', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
// 서버 측 검증
if (!"XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}

🔐 실전 방어 전략

권장 조합

# 최고 수준의 보호
1. CSRF Token (Stateful)
2. SameSite=Strict Cookie
3. Referer/Origin 헤더 검증
4. HTTPS 강제

# API 서버용 (Stateless)
1. Double Submit Cookie
2. SameSite=Lax Cookie
3. Custom Header 검증
4. CORS 정책 엄격히 설정

Spring Security + React 예시

Backend (Spring Boot)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()));

return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://frontend.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
config.setAllowedHeaders(List.of("*"));

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

// CSRF 토큰 엔드포인트
@RestController
public class CsrfController {

@GetMapping("/api/csrf-token")
public CsrfToken getCsrfToken(CsrfToken token) {
return token;
}
}

Frontend (React)

// API 클라이언트 설정
import axios from 'axios';

const api = axios.create({
baseURL: 'https://api.bank.com',
withCredentials: true // 쿠키 포함
});

// CSRF 토큰 가져오기
async function getCsrfToken() {
const response = await api.get('/api/csrf-token');
return response.data.token;
}

// 요청 인터셉터에 CSRF 토큰 추가
api.interceptors.request.use(async (config) => {
if (['post', 'put', 'delete'].includes(config.method)) {
const token = await getCsrfToken();
config.headers['X-CSRF-TOKEN'] = token;
}
return config;
});

// 사용 예시
export async function transfer(to, amount) {
const response = await api.post('/api/transfer', { to, amount });
return response.data;
}

⚠️ 주의사항

CSRF 방어가 필요 없는 경우

  1. 순수 읽기 전용 API (GET)

    • 단, GET 요청으로 상태를 변경하면 안 됨!
  2. 상태를 변경하지 않는 요청

    • 조회, 검색 등

GET 요청의 위험성

// ❌ 잘못된 예시 - GET으로 상태 변경
@GetMapping("/delete-account")
public String deleteAccount() {
// 계정 삭제 로직
}

// ✅ 올바른 예시
@PostMapping("/delete-account")
@CsrfProtected // CSRF 보호 필요
public String deleteAccount() {
// 계정 삭제 로직
}

API 인증 방식별 CSRF 취약점

인증 방식 CSRF 취약점 이유
Session Cookie ✅ 취약 브라우저가 자동으로 쿠키 전송
JWT (Cookie) ✅ 취약 쿠키 저장 시 자동 전송
JWT (LocalStorage) ❌ 안전 JavaScript로만 접근 가능 (XSS 주의)
Bearer Token ❌ 안전 Authorization 헤더는 자동 전송 안 됨

🧪 CSRF 공격 테스트

취약점 확인 방법

<!-- test.html - 공격 시뮬레이션 페이지 -->
<!DOCTYPE html>
<html>
<head>
<title>CSRF Test</title>
</head>
<body>
<h1>CSRF Attack Simulation</h1>
<form action="https://target.com/api/transfer" method="POST" id="attackForm">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>

<script>
// 페이지 로드 시 자동 제출
document.getElementById('attackForm').submit();
</script>
</body>
</html>

방어 확인 방법

# 1. CSRF 토큰 없이 요청
curl -X POST https://bank.com/api/transfer \
-H "Cookie: session_id=abc123" \
-d "to=attacker&amount=10000"
# 기대 결과: 403 Forbidden

# 2. 잘못된 CSRF 토큰으로 요청
curl -X POST https://bank.com/api/transfer \
-H "Cookie: session_id=abc123" \
-H "X-CSRF-TOKEN: invalid_token" \
-d "to=attacker&amount=10000"
# 기대 결과: 403 Forbidden

# 3. 올바른 CSRF 토큰으로 요청
curl -X POST https://bank.com/api/transfer \
-H "Cookie: session_id=abc123" \
-H "X-CSRF-TOKEN: valid_token_xyz" \
-d "to=recipient&amount=1000"
# 기대 결과: 200 OK

✅ 체크리스트

  • 모든 상태 변경 요청(POST/PUT/DELETE)에 CSRF 보호 적용
  • SameSite 쿠키 속성 설정 (최소 Lax)
  • HTTPS 사용 강제
  • Referer/Origin 헤더 검증
  • CORS 정책 엄격히 설정
  • GET 요청으로 상태 변경하지 않음
  • 민감한 작업은 재인증 요구 (비밀번호 변경 등)

🎓 결론

CSRF는 브라우저의 쿠키 자동 전송 특성을 악용한 공격입니다.

핵심 방어 전략:

  1. CSRF Token 또는 Double Submit Cookie 사용
  2. SameSite 쿠키 속성 설정
  3. CORS 정책 엄격히 관리
  4. GET 요청으로 상태 변경 금지

단일 방어 기법보다는 여러 기법을 조합하여 다층 방어(Defense in Depth)를 구축하는 것이 가장 안전합니다.

Share