Spring Security 기반 CSRF 공격 실습 및 방어 방법 정리
유튜브 영상도 촬영하였습니다. 아래 영상을 참고하면서 블로그글을 참고해주세요.
CSRF (Cross-Site Request Forgery)란?
CSRF (Cross-Site Request Forgery) 사이트 간 요청 위조는 웹 애플리케이션의 취약점을 악용하는 공격 방식 중 하나로, 사용자가 의도하지 않은 요청을 수행하게 만드는 공격입니다.
CSRF 공격의 목적은 사용자가 웹 애플리케이션에서 인증된 세션을 가지고 있는 상태에서, 공격자가 의도한 행동을 사용자로 하여금 실행하게 하는 것입니다.
이는 사용자가 현재 로그인한 세션을 이용하여 악의적인 요청이 서버에 전달되도록 하여, 사용자의 의도와 무관하게 데이터 변경, 거래 발생 등의 작업이 수행되게 만듭니다.
CSRF(Cross-Site Request Forgery)의 동작 원리
CSRF 공격이 성공하기 위해서는 아래의 조건을 충족해야 합니다.
CSRF 공격이 성공하기 위한 조건
사용자 인증
- 사용자가 공격 대상 웹사이트에 로그인하여 유효한 세션 쿠키를 가지고 있어야 합니다.
- 이 세션 쿠키는 공격자가 생성한 악성 요청에 포함되어 서버에서 인증된 요청으로 인식됩니다.
쿠키
- 쿠키 기반으로 서버 세션 정보를 획득할 수 있어야 합니다.
요청의 구조 이해
- 공격자는 서버가 어떤 URL 패턴과 요청 파라미터를 사용하는지 알고 있어야 합니다.
- 예를 들어, 사용자의 계좌에서 돈을 이체하는 요청이 POST /transfer 엔드포인트를 사용하고, amount와 recipient이라는 파라미터를 요구하는 경우, 공격자는 이 정보를 알고 있어야 합니다.
CSRF 공격이 일어나기 위한 조건
- 사용자가 이미 인증된 상태여야 함 (세션, 쿠키 기반 로그인)
- 브라우저가 자동으로 쿠키를 함께 전송
- 공격자가 form, img, script 태그 등을 통해 요청을 유도
실습: CSRF 공격을 재현해보기
SecurityConfig.java
package com.luca.csrf.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 보호 기능을 비활성화 (실습을 위해 OFF) //Spring Security는 기본적으로 CSRF 공격 방지를 위해 폼 요청에 토큰을 요구함.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login","/css/**").permitAll() // /login,/css로 시작하는 경로는 누구나 접근 가능
.anyRequest().authenticated() //그 외에 모든 요청은 인증된 사용자만 접근 가능
)
.formLogin(form -> form
.loginPage("/login") //로그인 폼 URL을 커스텀
.defaultSuccessUrl("/post",true) //로그인 성공 후 이동할 페이지 설정
.permitAll() //로그인 폼 자체는 인증 없이 누구나 접근 가능
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
//메모리에 사용자 정보 하나 생성
//비밀번호를 단순 인코딩 (실습용). 실제 서비스에서는 BCrypt 같은 강력한 해시 사용 권장
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("1234")
.roles("USER")
.build();
// DB 없이 메모리에서 사용자 인증 정보를 관리함 (간단한 테스트 용도에 적합)
return new InMemoryUserDetailsManager(user); // 메모리 기반 사용자 저장소 반환
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
PostController.java
package com.luca.csrf.post.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class PostController {
@GetMapping("/post")
public String postForm() {
return "post";
}
@PostMapping("/post")
@ResponseBody
public String submitPost(@RequestParam("content") String content) {
System.out.println("작성된 글: " + content);
return "글 작성 완료!";
}
@GetMapping("/login")
public String loginPage() {
return "login";
}
}
login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1>로그인</h1>
<form method="post" th:action="@{/login}">
<input type="text" name="username" placeholder="user" />
<input type="password" name="password" placeholder="1234" />
<button type="submit">로그인</button>
</form>
</body>
</html>
post.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1>게시글 작성</h1>
<form action="/post" method="post">
<input type="text" name="content" value="정상 글입니다." />
<button type="submit">작성</button>
</form>
</body>
</html>
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.luca'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
compileOnly 'org.projectlombok:lombok'
// runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
1. 정상적인 게시글 작성 테스트
먼저, 아래와 같은 간단한 로그인 및 게시글 작성 기능이 있는 웹 애플리케이션을 준비했습니다.
- 로그인 페이지: /login
- 게시글 작성 페이지: /post
사용자 정보는 메모리 기반 사용자 저장소를 사용하고 있으며, user / 1234 계정으로 로그인할 수 있습니다.
로그인 후 /post 페이지에서 글을 작성하면 서버에서는 정상적으로 글 내용을 로그에 출력합니다.
2. 공격자 사이트 접근 (evil.html)
이제 공격자가 만든 사이트(evil.html)를 준비해봅니다. 예를 들어, “쿠폰 받기”라는 문구로 유인하는 단순한 HTML 페이지입니다.
<!-- evil.html -->
<h2>쿠폰 1000원 받기!</h2>
<form action="http://localhost:8080/post" method="POST">
<input type="hidden" name="content" value="😈 해커의 글 😈" />
<button type="submit">쿠폰 받기</button>
</form>
사용자가 이 페이지를 방문해 버튼을 클릭하면 브라우저는 현재 로그인된 세션 쿠키를 자동으로 첨부하여 /post로 POST 요청을 보내게 됩니다.
결과적으로 사용자의 의도와 상관없이 “😈 해커의 글 😈” 이라는 내용이 A 사이트에 등록됩니다.
3. CSRF 공격이 실제로 성공한 이유
- 사용자는 이미 A 사이트에 로그인한 상태였고,
- 브라우저는 세션 쿠키를 자동으로 전송했기 때문에,
- 서버는 이를 정상적인 요청으로 인식하게 됩니다.
CSRF 방어 방법 정리
CSRF Token (기본) | 서버에서 토큰 발급, 클라이언트에서 폼/헤더에 담아 전송 | 가장 강력한 방어 | AJAX 요청 시 수동 삽입 필요 |
Referer 체크 | 요청의 출처(도메인) 확인 | 구현 간단 | 일부 브라우저에서 헤더 없음 |
CAPTCHA | 사용자가 직접 입력 or 클릭 | 자동 요청 차단 | UX 저하 가능 |
첫번째 방어 방법인 CSRF Token을 사용해 보겠습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// .csrf(csrf -> csrf.disable()) // CSRF 보호 기능을 비활성화 (실습을 위해 OFF) //Spring Security는 기본적으로 CSRF 공격 방지를 위해 폼 요청에 토큰을 요구함.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login","/css/**").permitAll() // /login,/css로 시작하는 경로는 누구나 접근 가능
.anyRequest().authenticated() //그 외에 모든 요청은 인증된 사용자만 접근 가능
)
.formLogin(form -> form
.loginPage("/login") //로그인 폼 URL을 커스텀
.defaultSuccessUrl("/post",true) //로그인 성공 후 이동할 페이지 설정
.permitAll() //로그인 폼 자체는 인증 없이 누구나 접근 가능
);
return http.build();
}
...그외 소스들 냅둘것
csrf 부분을 주석을 넣어줍니다. 스프링 시큐리티는 기본적으로 csrf보호 기본값이 적용됩니다.
CSRF 토큰을 폼에 삽입 (Thymeleaf)
Spring Security는 HTML 폼에서 CSRF 토큰을 자동으로 처리할 수 있도록 지원합니다. Thymeleaf를 사용한다면 아래와 같이 <input type="hidden">으로 쉽게 삽입할 수 있습니다:
<form action="/post" method="post">
<input type="text" name="content" value="정상 글입니다." />
<input type="hidden" th:name=\"${_csrf.parameterName}\" th:value=\"${_csrf.token}\" />
<button type="submit">작성</button>
</form>
4. evil.html 공격 다시 시도
앞서 사용했던 evil.html 파일을 다시 실행한 뒤 쿠폰 받기 버튼을 클릭해봅니다.
하지만 이번에는 결과가 다릅니다. 서버는 다음과 같은 응답을 반환합니다:
HTTP 403 Forbidden
왜 차단됐을까?
- Spring Security는 이제 모든 POST 요청에 대해 CSRF 토큰이 포함되었는지를 검사합니다.
- evil.html에서는 CSRF 토큰이 포함되어 있지 않기 때문에, 서버는 이 요청을 악의적인 것으로 간주하고 차단합니다.
1.Spring Security에서 CSRF 토큰은 이렇게 동작합니다 1.토큰 생성 시점 사용자가 최초로 GET 요청(예: /post, /login)으로 접근하면 Spring Security는 내부적으로 CsrfTokenRepository를 통해 고유한 토큰을 생성합니다.
기본 구현체는 HttpSessionCsrfTokenRepository이며, 이 토큰은 서버 세션에 저장됩니다.
2.토큰 전달 방식 Thymeleaf를 사용한다면 ${_csrf.parameterName}과 ${_csrf.token} 값을 통해 Spring Security가 세션에서 가져온 토큰을 <form>에 자동으로 삽입해줍니다.
3.토큰 검증 사용자가 POST, PUT, DELETE 요청을 보내면, CsrfFilter가 요청에 포함된 토큰과 세션에 저장된 토큰을 비교합니다. 둘이 일치하면 요청 허용, 불일치하거나 누락되면 403 Forbidden.
참고 :
https://pixx.tistory.com/344
https://jaykaybaek.tistory.com/29