CSRF 보안 공격

sangcheol·2024년 11월 1일
0

CSRF 이해

CORS와 달리 CSRF는 해커들이 애플리케이션 내부의 데이터를 훔치거나, 애플리케이션 내부에서 일부 권한 있는 작업을 수행하기 위해 사용하는 실제 보안 공격입니다.

CSRF 정의

CSRF(Cross-Site Request Forgery)는 사이트 간 요청 위조 공격으로, 사용자의 동의 없이 공격자가 사용자 대신 웹 애플리케이션에서 작업을 수행하려는 시도입니다. 이는 공격자가 피해자의 인증된 세션을 이용해 의도하지 않은 행동을 하게 만드는 방식으로, 사용자는 공격이 이루어지는 것을 인지하지 못합니다.

CSRF 공격의 예시

예를 들어, 사용자가 넷플릭스(netflix.com)에 로그인한 후 다른 탭에서 악성 웹사이트(evil.com)에 접속하게 되는 경우를 가정해 봅시다. 이 악성 사이트에는 '아이폰 90% 할인'과 같은 유혹적인 배너가 있어, 사용자가 클릭하게 됩니다.

공격 과정 요약
1. 넷플릭스 로그인: 사용자가 netflix.com에 로그인하면, 넷플릭스 서버는 사용자의 브라우저에 쿠키(예: abc123)를 저장합니다. 이 쿠키는 넷플릭스 도메인에서만 유효합니다.
2. 악성 사이트 접속: 사용자가 악성 사이트(evil.com)에 접속하여 유도된 배너를 클릭합니다.
3. 악성 링크 클릭: 이 배너에는 악성 폼이 숨겨져 있으며, 클릭 시 netflix.com/changeEmailPOST 요청이 전송됩니다. 이 요청은 이메일 주소를 user@evil.com으로 변경하려는 내용입니다.
4. 요청 전송: 사용자가 알지 못하는 사이에 요청이 전송되며, 쿠키 abc123이 첨부되어 사용자가 보낸 요청처럼 위장됩니다.
5. 서버 응답: 넷플릭스 서버는 이 요청이 동일한 도메인(netflix.com)에서 발생한 것으로 인식하고 이메일 주소를 변경합니다.

CORS의 한계

이 시나리오에서는 CORS가 적용되더라도 CSRF 공격이 방지되지 않습니다. 임베디드 폼을 통한 요청은 동일한 도메인에서 발생하는 요청으로 처리되기 때문에, 브라우저는 요청의 출처가 악성 사이트임을 인식하지 못합니다.

CSRF 공격 방지 및 해결책

CSRF 공격의 문제점

CSRF 공격에서는 악성 웹사이트가 사용자를 속여 인증된 상태로 요청을 보내게 함으로써, 백엔드 서버가 해당 요청이 원래 웹사이트에서 온 것인지 아닌지 구분할 수 없게 만듭니다. 이 문제를 해결하기 위해, CSRF 토큰을 사용하여 백엔드가 요청 출처를 검증하도록 하는 방법이 필요합니다.

CSRF 토큰의 역할

CSRF 토큰은 사용자의 세션마다 고유하며 예측하기 어려운 안전한 랜덤 값으로 생성됩니다. 이를 통해 서버는 요청이 원래 웹사이트에서 온 것인지 악의적인 사이트에서 온 것인지 식별할 수 있게 됩니다. 토큰의 특징은 다음과 같습니다:

  • 세션마다 고유하며, 무작위 값으로 생성됩니다.
  • 충분히 긴 값을 사용하여 추측하기 어렵게 만듭니다.

CSRF 공격 방지 시나리오

1단계: CSRF 토큰 생성 및 전송
넷플릭스 사용자가 로그인하면, 서버는 인증 쿠키와 함께 CSRF 토큰도 생성하여 UI 애플리케이션에 전달합니다. 이 CSRF 토큰은 쿠키 형태로 전송되며, 이후 요청에서 함께 사용될 것입니다.

2단계: 악성 사이트 접속 및 공격 시도
사용자가 다른 탭에서 악성 사이트(evil.com)에 접속해 악성 링크를 클릭할 경우, 이 링크는 넷플릭스의 netflix.com/changeEmail로 요청을 보냅니다. 이때 브라우저는 쿠키에 저장된 CSRF 토큰을 함께 첨부하여 서버로 전송합니다.

3단계: 서버의 CSRF 토큰 검증
백엔드 서버는 요청을 받을 때 두 가지 위치에서 CSRF 토큰을 확인합니다:

  • 쿠키에 있는 CSRF 토큰
  • RequestHeader 또는 RequestBody에 있는 CSRF 토큰

넷플릭스 UI에서 정상 요청이 발생하는 경우, 서버는 두 위치에 있는 토큰 값이 동일함을 확인할 수 있습니다. 반면, evil.com에서는 CSRF 토큰을 포함할 수 없어 헤더/본문에는 토큰 값이 없습니다. 이로 인해 서버는 요청을 차단(403 에러)하고 CSRF 공격을 방지할 수 있습니다.

JavaScript의 제한 사항
이 과정에서 악성 사이트는 넷플릭스의 쿠키를 읽을 수 없습니다. 이는 브라우저가 Same-Origin-Policy에 따라 evil.com의 JavaScript 코드가 netflix.com의 쿠키에 접근하지 못하도록 막기 때문입니다.

요약: CSRF 방지 방법

  1. 고유한 CSRF 토큰 생성: 사용자 세션마다 고유하고 예측할 수 없는 값을 생성합니다.
  2. CSRF 토큰 포함 요청 검증: 요청이 발생할 때마다 헤더/본문과 쿠키에 포함된 토큰을 비교하여 유효성을 확인합니다.
  3. JavaScript를 통한 읽기 제한: 브라우저의 동일 출처 정책을 이용하여 악성 사이트가 타 사이트의 쿠키에 접근하지 못하게 합니다.

CSRF 공격을 방지하는 이러한 접근 방식은 업계에서 널리 사용되고 있으며, 안정적인 보안을 제공합니다.

Spring Security CSRF 기본 설정

SecurityConfig

http.csrf(AbstractHttpConfigurer::disable)

지금까지는 csrf 설정을 비활성화 해둔 채로 실습을 진행하였습니다.

http.csrf(AbstractHttpConfigurer::disable) 설정을 제거하여, csrf 설정을 활성화 해보겠습니다.
기본적으로 Spring Security는 데이터를 변경하거나 생성하거나 삭제하는 모든 http 메소드를 차단합니다.

POST 요청

{
    "status": 403,
    "error": "Forbidden",
    "message": "Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.",
    "path": "/register"
}

'잘못된 CSRF 토큰 'null'이 요청 매개변수에서 발견되었습니다'라는 메시지와 함께 403 에러가 발생하였습니다.
이 에러가 발생하는 이유는 post 메소드를 사용하여 새 데이터를 생성하려고 하지만 요청의 일부로 CSRF 토큰을 전달하지 않기 때문입니다. CSRF 토큰이 누락되면 Spring Security는 누군가가 CSRF 공격을 통해 애플리케이션을 공격하려고 한다고 가정합니다. 따라서 403 에러를 발생시켜 이 요청 처리를 중단한 것입니다.

Spring Security의 CSRF

CsrfToken 인터페이스
구현체: 기본적으로 DefaultCsrfToken 사용

CsrfTokenRepository 인터페이스
구현체: 기본적으로 CookieCsrfTokenRepository 사용
Csrf는 Session보다 Cookie에 저장하는 것을 권장

CsrfFilter

  • 요청에서 CsrfToken 로드
  • 토큰이 어떤 방식으로든 유효하지 않다면 AccessDeniedException 발생

SecurityConfig

http.csrf(csrfConfig ->
             csrfConfig.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));

CookieCsrfTokenRepository

public static CookieCsrfTokenRepository withHttpOnlyFalse() {
	CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
	result.cookieHttpOnly = false;
	return result;
}
  • Cookie에서 CSRF 토큰을 읽어야 하기 때문에 result.cookieHttpOnly = false로 설정한다.
  • React나 Vue 등의 클라이언트 애플리케이션을 사용하는 경우 반드시 withHttpOnlyFalse() 사용

"XSRF-TOKEN"이라는 쿠키에 CSRF 토큰을 유지하고 AngularJS의 규칙에 따라 "X-XSRF-TOKEN" 헤더에서 읽는 CsrfTokenRepository 입니다. AngularJS와 함께 사용할 때는 반드시 withHttpOnlyFalse() 사용하세요.

이 구조를 통해 Spring Security는 JavaScript를 통해 읽을 수 있는 CSRF 토큰을 생성하고, 이를 HTTP 요청마다 검증하여 CSRF 공격을 방지합니다.

커스텀 Csrf 필터 생성

CsrfCookieFilter

package com.study.springsecsection1.filter;

import java.io.IOException;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;

public class CsrfCookieFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        // 지연된 토큰을 로드하여 쿠키에 토큰 값을 렌더링
        csrfToken.getToken();
        filterChain.doFilter(request, response);
    }
}

SecurityConfig

http.csrf(csrfConfig ->
              csrfConfig.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
    .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)

제가 만든 이 커스텀 필터를 BasicAuthenticationFilter 실행이 완료된 후에 실행되도록 하고 싶습니다.
왜 여기서 BasicAuthenticationFilter를 명시하려고 하는 걸까요?

이유는 매우 간단합니다
이제부터는 httpBasic 형식을 사용하여 자격 증명을 전달함으로써 로그인 작업을 수행할 것입니다
따라서 httpBasic 형식을 사용하여 자격 증명을 보낼 때마다 이 필터는 자격 증명을 추출하여 실제 인증을 수행하는 역할을 할 것입니다. 인증이 완료되면 Spring Security 프레임워크는 CookieCsrfTokenRepository 클래스의 도움으로 CSRF 토큰을 생성할 것입니다. 모든 작업이 완료되면 저는 이 필터가 실행되어 지연된 토큰을 읽고 실제 값을 로드하도록 하고 싶습니다.

CsrfFilter에서 UI 애플리케이션의 요청에서 CSRF 토큰 값을 어디서 읽어야 하는지 알려줘야 합니다.

SecurityConfig

CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();

http.csrf(csrfConfig -> csrfConfig.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))

SecurityConfig

http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
  • SessionCreationPolicy.ALWAYS: 항상 세션을 생성. 이렇게 하면 동일한 세션을 반복해서 재사용하여 보안 API에 접근
  • 따라서 세션 설정에 따라 생성되는 jsessionId는 SecurityContextHolder에 자동으로 저장되지 않을 것입니다.
  • 우리는 jsessionId를 수동으로 SecurityContextHolder 안에 저장하거나 Spring 프레임워크가 이를 처리할 수 있도록 설정해야합니다.

SecurityConfig

http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false))

Spring 프레임워크에 jsessionId 세부 정보나 로그인된 인증 세부 정보를 SecurityContextHolder에 저장하지 않겠다고 알리는 것입니다. 대신 Spring Security 프레임워크가 이를 처리하도록 하고 싶습니다.

Postman 요청

jsessionidXSRF-TOKEN이 응답 쿠키에 담긴다.

{
    "status": 403,
    "error": "Forbidden",
    "message": "Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-XSRF-TOKEN'.",
    "path": "/register"
}

이 쿠키를 이용하여 POST 요청을 보내면 여전히 403 에러가 발생한다.
X-XSRF-TOKEN 헤더에 csrf 토큰이 담겨있지 않기 때문이다.

XSRF-TOKEN 쿠키의 토큰 값을 X-XSRF-TOKEN 헤더에 담은 후 요청을 보내면 POST 요청이 성공하게 된다.

UI 프레임워크 로그인 로직 - CSRF 설정 추가

LoginComponent

...
import { getCookie } from 'typescript-cookie';

export class LoginComponent implements OnInit {
  ...
  validateUser(loginForm: NgForm) {
    this.loginService.validateLoginDetails(this.model).subscribe(
      responseData => {
        this.model = <any> responseData.body;
        this.model.authStatus = 'AUTH';
        window.sessionStorage.setItem("userdetails",JSON.stringify(this.model));
        let xsrf = getCookie("XSRF-TOKEN")!; // XSRF-TOKEN 쿠키에서 꺼내기
        window.sessionStorage.setItem("XSRF-TOKEN",xsrf); // 세션 스토리지에 저장
        this.router.navigate(['dashboard']);
      });
  }
  ...
}
  1. XSRF-TOKEN 쿠키에서 꺼내기
  2. 세션 스토리지에 저장

XhrInterceptor

import { Injectable } from '@angular/core';
import { HttpInterceptor,HttpRequest,HttpHandler,HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import {Router} from '@angular/router';
import {tap} from 'rxjs/operators';
import { User } from 'src/app/model/user.model';

@Injectable()
export class XhrInterceptor implements HttpInterceptor {

  user = new User();
  constructor(private router: Router) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    let httpHeaders = new HttpHeaders();
    if(sessionStorage.getItem('userdetails')){
      this.user = JSON.parse(sessionStorage.getItem('userdetails')!);
    }
    if(this.user && this.user.password && this.user.email){
      httpHeaders = httpHeaders.append('Authorization', 'Basic ' + window.btoa(this.user.email + ':' + this.user.password));
    }
    
    // csrf 검증 추가
    let xsrf = sessionStorage.getItem('XSRF-TOKEN');
    if(xsrf){
      httpHeaders = httpHeaders.append('X-XSRF-TOKEN', xsrf);
    }

    httpHeaders = httpHeaders.append('X-Requested-With', 'XMLHttpRequest');
    const xhr = req.clone({
      headers: httpHeaders
    });
  return next.handle(xhr).pipe(tap(
      (err: any) => {
        if (err instanceof HttpErrorResponse) {
          if (err.status !== 401) {
            return;
          }
          this.router.navigate(['dashboard']);
        }
      }));
  }
}

인터셉터에 CSRF 설정

let xsrf = sessionStorage.getItem('XSRF-TOKEN');
if(xsrf){
  httpHeaders = httpHeaders.append('X-XSRF-TOKEN', xsrf);
}

인터셉터는 API 서버에 요청할 때마다 요청을 가로챈다. 로그인 할 때 세션 스토리지에 담아두었던 XSRF-TOKEN를 가져와 X-XSRF-TOKEN 헤더에 담아보낸다.

logout 로직

ngOnInit(): void {
  window.sessionStorage.setItem("userdetails","");
  window.sessionStorage.setItem("XSRF-TOKEN","");
  this.router.navigate(['/login']);
}

취약점
로그아웃을 하면 세션 스토리지에서의 userdetailsXSRF-TOKEN은 사라지지만, Cookie에는 userdetailsXSRF-TOKEN이 여전히 존재하게 된다. 따라서 공용 PC에서 로그인을 한 후 로그아웃을 했다고 안심할 수 없다. 왜냐하면 쿠키에서 값을 가져와 세션 스토리지에 넣어버리고 요청하면 되기 때문이다.

CSRF 무시

공개 API인 경우 POST 요청에서도 CSRF를 허용해야 할 경우가 있다. CSRF를 허용할 경우 해야할 설정에 대해 알아보자.

SpringConfig

.csrf(csrfConfig -> csrfConfig.ignoringRequestMatchers("/contact", "/register"))

위와 같이 ignoringRequestMatchers() 메서드를 이용하면 된다.

참고
다른 백엔드 API를 통해서만 호출할 수 있는 애플리케이션을 구축하려는 경우, UI 애플리케이션을 통해서가 아니라면 이러한 시나리오에서는 CSRF를 비활성화할 수 있습니다. 왜냐하면 해커 시나리오는 UI 애플리케이션과 브라우저를 사용할 때만 가능하기 때문입니다.

profile
백엔드 개발자

0개의 댓글