CSRF( Cross Site Request Forgery )

뾰족머리삼돌이·2024년 8월 24일
0

Spring Security

목록 보기
8/16

CSRF 공격이란 무엇인가?

CSRF는 인증권한을 취득한 브라우저를 대상으로 의도치않은 요청을 발생시켜 보호된 리소스를 얻어내는 공격으로 사이트 간 요청 위조라고도 부른다. 브라우저가 요청에 쿠키를 자동으로 포함한다는 점을 악용하는 공격이다.

예를들어, 사용자가 은행 사이트에 로그인했다고 가정해보자.

사용자는 은행 사이트에서 작업을 모두 처리하고 로그아웃을 하지 않고 다른 사이트에 접속한다.
이렇게되면 브라우저에 Session ID 혹은 토큰 정보가 남아있게 된다. 다른 사이트에서 은행 사이트를 대상으로한 요청을 발생시키면 브라우저에 저장된 인증 정보가 은행 사이트에서 만료되지 않은 경우, 작업처리가 가능해진다.

심지어 XSS를 적용시켜 JavaScript를 사용한다면 사이트 방문만으로 요청을 발생시킬 수도 있다.

CSRF 공격 보호방법

CSRF 공격이 발생하는 원인은 특정 사이트에서 발생한 요청을 구분하는 방법을 생각하지 않았기 때문이다.
즉, 정상 사이트에서의 요청과 비정상 사이트에서의 요청을 구분하지 못하기 때문에 서버에서는 모두 정상으로 받아들이는 것이다.

따라서 CSRF 공격을 방어하기 위해서는 정상 사이트를 구분할 수 있는 장치가 필요하다.
이와 관련하여 Spring에서는 두 가지의 서로다른 매커니즘을 제공한다.

이러한 매커니즘이 정상적으로 동작하기 위해서는 GET, HEAD, OPTIONS, TRACE Method 는 서버의 상태를 변화시키지 않는 Read-only로 동작해야한다.

Synchronizer Token Pattern

세션 쿠키 외에 CSRF 토큰이라는 안전한 무작위 생성값을 각 요청에 포함시키는 방법이다.
서버에서 생성하여 세션에 관리중인 CSRF 토큰과 요청에 포함된 CSRF 토큰을 비교하여 정상적인 요청인지 확인한다.

주의할 사항으로 쿠키처럼 브라우저에 의해 자동으로 요청에 CSRF 토큰이 포함되어서는 안된다.
즉, 파라미터나 헤더를 통해 CSRF 토큰을 전송해야 한다는 말이다.

CSRF 토큰은 세션마다 고유한 값을 가져야하며, 외부에 노출되어서는 안되고, 예측할 수 없어야한다.

위 이미지는 프로그래밍 언어별 토큰 생성에 사용되는 메서드 예시이며, 이 곳에 소개되어있다.

SameSite Attribute

쿠키에 SameSite 속성을 지정하는 방법이다.
서버에서 쿠키에 해당 속성을 설정하면 외부사이트에서 오는 요청에 대해 쿠키가 전송되지 않게 할 수 있다.

여기서 동일사이트는 URL을 기준으로 판단된다.
따라서, FE와 BE의 주소가 다른경우 FE에서 BE로의 요청은 외부사이트에서의 요청이다.

스프링 시큐리티에선 지원하지 않는 방어방법이다.
Web-Flux 환경의 애플리케이션이라면 스프링 프레임워크의 CookieWebSessionIdResolver를,
서블릿 환경의 애플리케이션이라면 Spring Session을 참고하자

서버에서 아래와 같이 SameSite를 포함한 응답을 돌려주는 것으로 CSRF를 방어할 수 있다.

Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax

SameSite에 올 수 있는 값은 StrictLax가 있다.

  • Strict : 동일 사이트에서 오는 모든 요청에 쿠키 포함
  • Lax : 동일 사이트 혹은 웹 페이지 이동과 read-only Method( GET, HEAD, OPTION, TRACE )인 경우 쿠키 포함

Strict는 더 안전하지만 외부사이트에서의 접근 자체를 금지시킨다는 점에 유의하자.
또한, 이 방어방법은 브라우저에서 SameSite 속성을 지원해야한다.

CSRF 보호를 사용해야하는 경우

CSRF 공격은 브라우저에 저장된 인증정보를 이용하므로, 브라우저를 사용하지 않는 클라이언트 요청에는 CSRF 보호가 적용되지 않아도 된다.
즉, 서버간 요청이나 토큰 인증을 사용하는 REST API 처럼 세션을 사용하지 않는 경우에는 CSRF 보호를 하지않아도 된다.

Stateless 브라우저 애플리케이션에서의 CSRF
Stateless 애플리케이션은 서버에서 사용자 상태를 저장하지 않는다
하지만, JSESSIONID을 대신하여 사용자 인증 관련 정보를 저장하는 쿠키를 관리한다면 여전히 CSRF 공격에 취약하다

JSON 요청의 경우에도 아래와 같이 Content-Type을 검증하지 않는 서버를 대상으로 공격이 가능하다

<form action="https://bank.example.com/transfer" method="post" enctype="text/plain">
	<input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
	<input type="submit"
		value="Win Money!"/>
</form>

Spring MVC 애플리케이션은 Content-Type검증을 하지만, 아래와 같이 .json을 URL 접미사에 추가한 공격이 가능하다

<form action="https://bank.example.com/transfer.json" method="post" enctype="text/plain">
	<input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
	<input type="submit"
		value="Win Money!"/>
</form>

CSRF 고려사항

로그인

로그인 요청 위조 공격을 방어하기 위해서 로그인 요청에 CSRF 보호를 설정해야한다.

로그인 요청 위조 공격은 아래 순서로 이뤄진다.

  1. 공격자의 사이트에 피해자가 접속하면 공격자의 자격증명으로 특정 사이트로의 로그인이 실행된다
  2. 서버에서 로그인이 이뤄지면 공격자의 인증쿠키가 피해자의 브라우저에 저장된다
  3. 이후, 피해자의 브라우저에서 특정 사이트로의 요청은 공격자의 계정으로 이뤄진 것으로 취급된다

즉, CSRF를 적용시키면 사용자의 세션이 생성되는 순간 CSRF 토큰이 발급되며, 로그인 요청시에 이 토큰이 포함되어야 한다.
1. 상황에서 CSRF 토큰을 같이 전송할 수 없으므로 로그인 요청 위조 공격을 방어할 수 있다.

세션 타임아웃

서버에서는 검증에 사용되는 CSRF 토큰을 주로 세션에 저장한다. 이는 세션이 만료될 때, 해당 토큰 정보가 사라진다는 것을 의미한다.
따라서, 세션이 만료되면 CSRF 토큰 검증이 불가능하므로 HTTP 요청이 거부된다.

이와 관련하여 세션 만료를 해결하기 위한 방법으로는 아래와 같은 것들이 있다.

  1. form 제출 이전에 CSRF 토큰 요청을 통해 갱신하기
  2. 세션 만료 알림을 통해 세션 갱신하기
  3. 서버 세션 대신 쿠키에서 CSRF 토큰 관리하기

Multipart( File Upload )

Content-Type이 multipart/form-data 인 요청은 body에 파일정보가 저장된다.
이는 요청이 서버에 도착하게 되면 body에 저장된 파일이 임시 업로드 된다는 의미이다.

다르게 말하면 외부에서 서버에 임의의 파일을 업로드 할 수 있다는 말이기도 하다.

이 때문에 CSRF 토큰이 전송되는 위치를 고민해야한다.

Body의 경우, 파일이 임시 업로드되긴 하지만 인증이 되지 않은 사용자의 경우 업로드가 취소된다.
URL의 경우, 외부에 토큰값이 노출되는 문제가 있다.

따라서 적절하게 선택하여 전송위치를 결정해야한다

Spring Security 에서의 CSRF

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			// ...
			.csrf(Customizer.withDefaults());
		return http.build();
	}
}

스프링 시큐리티에서는 HttpSecurity.csrf()를 통해 손쉽게 CSRF 방어를 설정할 수 있다.
기본적으로 unsafe 한 POST 같은 요청에 대해 CSRF 방어가 적용된다.


CSRF 검증은 CsrfFilter에 의해 시작된다.
CsrfTokenRequestHandler에서 헤더와 파라미터를 통해 요청에 CsrfToken이 있는지를 파악하며,
요청에 CSRF 보호가 필요한지 확인하고, 토큰을 검증하고 상황에 따라 AccessDeniedException을 처리한다.

전체적인 동작흐름을 살펴보자.

  1. CsrfTokenRepository에서 DeferredCsrfToken를 읽어온다
  2. XorCsrfTokenRequestAttributeHandler에 의해 DeferredCsrfTokenSupplier<CsrfToken>를 생성한다.
    a. _csrf 이름으로 Supplier<CsrfToken>가 포함된 SupplierCsrfToken 타입의 CsrfToken가 request 속성으로 저장된다.
  3. 메인 CSRF 보호 프로세스가 실행되며, 현재 요청에 CSRF 보호가 필요한지 확인한다.
  4. CSRF 보호가 필요하다면 DeferredCsrfToken으로 세션에 저장되어 있던 CSRF 토큰 값을 얻어온다.
  5. CsrfTokenRequestHandler를 통해 클라이언트가 제공한 CSRF 토큰을 얻어온다
  6. 두 CSRF 토큰을 비교/검증한다.
  7. 제공받은 CSRF 토큰이 없거나 검증에 실패하면 AccessDeniedException이 동작한다.

바로 CSRF 토큰을 생성하지 않고, DeferredCsrfToken를 생성하는 이유는 지연생성을 통해 CSRF 보호가 필요없는 요청에서의 자원을 아끼기 위해서다.

처음 세션이 생성되어 토큰을 프론트엔드나 클라이언트 애플리케이션으로 내려주는 방법은 CSRF 보호와 통합 에서 확인하자

서버에서의 CSRF 토큰 관리

서버에서는 사용자에게서 입력받은 CSRF토큰과 비교하기 위한 토큰을 기본적으로 세션에 저장하여 관리한다.
이때 토큰을 직접적으로 저장하고 관리하는 인터페이스가 CsrfTokenRepository다.

해당 인터페이스의 구현체로는 세션을 사용하는 HttpSessionCsrfTokenRepository쿠키를 이용하는 CookieCsrfTokenRepository 가 있다.
이 외에 CsrfTokenRepository를 구현한 커스텀한 구현체를 등록시켜 사용할 수도 있다.

HttpSessionCsrfTokenRepository

기본적으로 아무런 설정을 하지않으면 스프링 시큐리티는 HttpSessionCsrfTokenRepository를 사용한다.
HttpSessionCsrfTokenRepositoryHttpSession에 CSRF토큰을 저장한다.

CookieCsrfTokenRepository

서버에서 CSRF 토큰을 쿠키에 저장하여 브라우저로 내려주는 구현체다.
자바스크립트 기반의 애플리케이션을 위한 방법이며, XSRF-TOKEN라는 이름의 쿠키로 보관하고
HTTP 요청 헤더의 X-XSRF-TOKEN 또는 _csrf 파라미터를 통해 읽어들인다.

쿠키에 저장된 CSRF는 사용자가 입력한 CSRF 토큰과 대조하는 용도로 사용된다.
따라서, 사용자가 CSRF 토큰을 요청에 포함해야 하므로 CSRF 공격을 막을 수 있다

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			// ...
			.csrf((csrf) -> csrf
				.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 
                // 자바스크립트에서 쿠키를 직접 읽을 필요가 없다면 활성화 하여 보안을 향상시킬 수 있음
			);
		return http.build();
	}
}

CSRF 토큰 처리

스프링 시큐리티에서 CSRF 토큰의 관리는 CsrfTokenRequestHandler 인터페이스의 구현체에 의해 이뤄진다.
기본적으로 사용되는 구현체는 XorCsrfTokenRequestAttributeHandler이며, BREACH 공격에 대한 보호를 제공한다.
BREACH 보호가 필요없는 경우에 대비하여 CsrfTokenRequestAttributeHandler 구현체도 제공하며, 커스텀한 구현체를 등록할 수도 있다.

이 인터페이스의 구현체들은 Repository에서 CSRF 토큰을 읽어와서 HttpServletRequest 속성에 등록하고,
요청 헤더나 파라미터에서 클라이언트가 입력한 CSRF 토큰을 읽어오는 작업을 한다

XorCsrfTokenRequestAttributeHandler

이 구현체는 CSRF토큰을 HttpServletRequest_csrf라는 이름의 속성으로 저장하고 BREACH 공격에 대한 보호를 제공한다.
CsrfTokenRequestHandler 인터페이스의 resolveCsrfTokenValue()로 요청 헤더나 파라미터에서 CSRF 토큰값을 읽어오기도 한다.

BREACH 공격에 대한 보호수단으로 CSRF 토큰을 SecureRandom으로 암호화하여 클라이언트에게 내려준다

스프링 시큐리티는 기본적으로 이 구현체를 사용하므로 BREACH 공격에 대비한 추가적인 코드를 작성하지 않아도 된다.

CsrfTokenRequestAttributeHandler

실질적으로 CSRF토큰을 HttpServletRequest_csrf라는 이름의 속성으로 저장하는 구현체다.
앞선 XorCsrfTokenRequestAttributeHandler도 이 클래스를 상속하고 있다.

CsrfTokenRequestHandler 인터페이스의 resolveCsrfTokenValue()로 요청 헤더나 파라미터에서 CSRF 토큰값을 읽어오기도 한다.

만약, BREACH 공격에 대한 보호가 필요없다면 이 클래스만 사용하도록 설정할 수 있다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			// ...
			.csrf((csrf) -> csrf
				.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
			);
		return http.build();
	}
}

CSRF 토큰 지연 로딩

스프링 시큐리티는 성능향상의 목적으로 지연로딩을 이용하여 실제 세션에서 CSRF 토큰을 읽어오는 것을 미룬다.
만약 모든 요청에서 지연 로딩을 비활성화하고 싶다면 아래의 설정을 따라하면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		XorCsrfTokenRequestAttributeHandler requestHandler = new XorCsrfTokenRequestAttributeHandler();
		// null 설정을 하면 CsrfTokenRequestAttributeHandler의 handle() 에서 Request에 저장될 속성 키를 얻기위해 실제 토큰을 가져온다
		requestHandler.setCsrfRequestAttributeName(null);
		http
			// ...
			.csrf((csrf) -> csrf
				.csrfTokenRequestHandler(requestHandler)
			);
		return http.build();
	}
}
public class CsrfTokenRequestAttributeHandler implements CsrfTokenRequestHandler {

	private String csrfRequestAttributeName = "_csrf";
    
    // ...
    
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response,
			Supplier<CsrfToken> deferredCsrfToken) {
		Assert.notNull(request, "request cannot be null");
		Assert.notNull(response, "response cannot be null");
		Assert.notNull(deferredCsrfToken, "deferredCsrfToken cannot be null");

		request.setAttribute(HttpServletResponse.class.getName(), response);
		CsrfToken csrfToken = new SupplierCsrfToken(deferredCsrfToken);
		request.setAttribute(CsrfToken.class.getName(), csrfToken);
		String csrfAttrName = (this.csrfRequestAttributeName != null) ? this.csrfRequestAttributeName
				: csrfToken.getParameterName();
		request.setAttribute(csrfAttrName, csrfToken);
	}
    
    // ...

}

handle() 을 살펴보면 csrfAttrName를 얻는 과정에서 csrfRequestAttributeName이 null이면 CSRF 토큰의 파라미터 이름을 가져온다.
이 과정에서 init()이 실행되어 실제 토큰을 세션에서 가져온다.

CSRF 보호와 통합

CSRF 공격을 방어하기 위해 CSRF 토큰을 이용하는 방법은 HTTP 요청에 파라미터나 헤더를 통하여 CSRF 토큰을 함께 보내야한다.
이는 자동으로 이뤄지지 않기때문에 토큰을 어떻게 읽어낼 수 있는지를 알아야 한다.

HTML Forms

<input type="hidden"
	name="_csrf"
	value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>

HTML Forms에서는 처음 세션이 생성될 때, CSRF 토큰이 생성되어 hidden input으로 반환된다.

public final class CsrfRequestDataValueProcessor implements RequestDataValueProcessor {
	
    // ...

	@Override
	public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
		if (Boolean.TRUE.equals(request.getAttribute(this.DISABLE_CSRF_TOKEN_ATTR))) {
			request.removeAttribute(this.DISABLE_CSRF_TOKEN_ATTR);
			return Collections.emptyMap();
		}
		CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
		if (token == null) {
			return Collections.emptyMap();
		}
		Map<String, String> hiddenFields = new HashMap<>(1);
		hiddenFields.put(token.getParameterName(), token.getToken());
		return hiddenFields;
	}

	// ...

}

CsrfRequestDataValueProcessor를 살펴보면 hiddenFields.put으로 토큰 값을 작성하는 것을 확인할 수 있다.
또는 HttpServletRequest의 속성으로 CSRF 토큰이 저장된다는 사실을 이용할 수도 있다.

<c:url var="logoutUrl" value="/logout"/>
<form action="${logoutUrl}" method="post">
  <input type="submit" value="Log out" />
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>

JavaScript 애플리케이션

자바스크립트 애플리케이션은 HTML form대신 JSON을 사용하여 서버와 요청을 주고받는다.
JSON을 사용하는 경우, 요청 파라미터 대신 요청 헤더를 통해 CSRF 토큰을 전달할 수 있다.

CSRF 토큰을 획득하기 위해서 세션이 아닌, 쿠키에 저장하여 토큰을 요청 헤더에 자동으로 포함시킬 수 있다.
이 외에 응답 헤더를 통해 CSRF 토큰을 포함시키거나, HttpServletRequest의 속성으로 CSRF 토큰이 저장된다는 사실을 이용할 수도 있다.

SPA나 MPA 및 자바스크립트 애플리케이션에서 CSRF 보호를 적용하는 예시는 공식 문서를 참고하자

CSRF 보호 비활성화

특정 URL만 CSRF 보호를 비활성화 하는 경우

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // ...
            .csrf((csrf) -> csrf
                .ignoringRequestMatchers("/api/*")
            );
        return http.build();
    }
}

애플리케이션 전체 CSRF 보호를 비활성화 하는 경우

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			// ...
			.csrf((csrf) -> csrf.disable());
		return http.build();
	}
}

0개의 댓글