Spring/Spring Security

[Spring Security] 주요 보안 공격 (CSRF, XSS, 세션 고정, JWT 탈취)과 대응 전략

leejunkim 2025. 9. 29. 10:53

WeeklyPaper: Spring 기반 웹 애플리케이션에서 발생할 수 있는 4가지 주요 보안 공격 (CSRF, XSS, 세션 고정, JWT 탈취)에 대해 설명하고, 각각에 대한 Spring Security 또는 일반적인 대응 전략을 설명하세요.


CSRF (Cross-Site Request Forgery)

CSRF(Cross-Site Request Forgery)는 사용자가 자신도 모르게 공격자가 의도한 요청을 특정 웹 애플리케이션에 보내도록 속이는 공격이다.
  • 피해자가 로그인하여 인증 쿠키를 보유 중일 때, 공격자가 조작된 링크나 폼을 열게 하면 브라우저가 자동으로 쿠키를 첨부하여 서버에 요청을 보낸다. (쿠키는 브라우저에 저장이 된다)
  • 예시 시나리오:
    • 사용자가 은행 사이트에 로그인해서 세션 쿠키를 들고 있다.
    • 공격자가 이메일로 악성 링크(<img src="https://bank.com/transfer?to=attacker&amount=1000">)를 삽입한다.
    • 사용자가 메일을 열면 브라우저가 은행 서버에 요청을 보내고, 자동으로 세션 쿠키가 첨부된다.
    • 서버는 정상적인 사용자 요청으로 인식하고 이체를 수행한다.
    • 결과는 사용자의 의도와 다르게 권한 있는 요청이 실행된다.

대응 전략 1 - 동기화 토큰 (Synchronizer Token)

  • 격을 방어하기 위한 가장 표준적이고 강력한 방법 중 하나다. 핵심 원리는 사용자가 보내는 요청이 정말로 사용자의 의도에 의해 시작된 것인지를 서버가 확인할 수 있는 비밀 값을 사용하는 것이다.  
  • 프로세스 예시
    • 사용자가 웹사이트에 로그인하여 세션이 생성되면, 서버는 해당 세션을 위해 아무나 예측할 수 없는 무작위의 고유한 토큰(CSRF 토큰)을 생성한다. 이 토큰은 서버 측 세션에 저장된다.
    • 서버는 사용자에게 비밀번호 변경, 글쓰기, 송금 등 민감한 작업을 수행하는 폼(Form) 페이지를 보낼 때, 이 CSRF 토큰을 <input type="hidden"> 태그를 이용해 폼 안에 몰래 숨겨서 전달한다.
      • <input type="hidden" name="csrf_token" value="a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6">
    • 사용자가 폼을 작성하고 '송금' 버튼을 누르면, 브라우저는 폼 데이터와 함께 숨겨져 있던 CSRF 토큰 값도 서버로 함께 전송한다.
    • 서버 측 검증: 요청을 받은 서버는 두 가지를 비교한다.
      • 사용자 세션에 저장해 둔 CSRF 토큰
      • 폼 데이터와 함께 전송된 CSRF 토큰
      • 만약 두 값이 일치하면, 서버는 이 요청이 정상적인 경로를 통해 온 것이라고 신뢰하고 처리한다. 만약 두 값이 일치하지 않거나 폼에서 토큰이 전송되지 않았다면, 서버는 이 요청을 CSRF 공격으로 간주하고 거부한다.
  • 이 방법이 효과적인 이유는 공격자는 서버가 사용자의 브라우저에만 몰래 심어놓은 CSRF 토큰의 값을 알 수 없다.

Spring Security의 CSRF 기본 보호 메커니즘

  • Spring Security는 기본적으로 상태 유지 세션 기반 애플리케이션에서 CSRF 보호를 활성화한다. 서버는 요청 시 CSRF 토큰을 검증하여, 클라이언트가 의도적으로 요청을 보냈는지 확인한다.
    • 앞서 설명된 동기화 토큰 패턴(Synchronizer Token Pattern)의 구현체다.
  • 구성 요소: 
    • CsrfFilter: 모든 요청에 대해 CSRF 토큰을 검증하는 필터다.
      • 사용자가 보낸 요청에 _csrf 파라미터나 헤더 값이 없는 경우 AccessDeniedException을 발생시킨다.
      • 요청에 _csrf 파라미터나 헤더 값이 있으면 그 값을 추출하고, 저장소 (HttpSession / Cookie)에 보관된 토큰과 비교한다. 불일치하면 AccessDeniedException을 발생시킨다.
    • CsrfToken: 서버가 세션별로 생성하여 클라이언트에 전달하는 토큰이다. 이 토큰은 매 요청마다 클라이언트가 전달해야 하며, 서버는 저장된 토큰과 비교하여 일치 여부를 검사한다
      • Spring Security는 사용자가 최초로 세션을 생성하거나 페이지를 요청할 때 고유한 난수 기반 토큰을 생성한다. 이 토큰은 HttpSessionCsrfTokenRepository(기본 구현체) 또는 CookieCsrfTokenRepository에 저장된다.
      • 난수는 SecureRandom 기반으로 생성이 되어서 예측이 불가능하고 위조가 어렵다.

토큰 생성 과정 예시

@Bean
public CsrfTokenRepository csrfTokenRepository() {
    HttpSessionCsrfTokenRepository repo = new HttpSessionCsrfTokenRepository();
    repo.setParameterName("_csrf");
    repo.setHeaderName("X-CSRF-TOKEN");
    return repo;
}

 

HTML 폼 예시 (Thymeleaf):

<form action="/transfer" method="post">
    <input type="hidden" name="_csrf" value="${_csrf.token}" />
    <button type="submit">송금</button>
</form>

 

Spring Security 6.x에는 CSRF가 기본적으로 활성화되어있다:

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.enable())
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
        return http.build();
    }
}

대응 전략 2 - 더블 서브밋 (Double Submit Cookie)

    • 서버가 토큰 값을 세션에 저장하지 않는다는 점에서 동기화 토큰 패턴과 결정적인 차이가 있다. 서버는 상태를 유지할 필요 없이(stateless) 검증만 수행한다.
    • 구현체는 CookieCsrfTokenRepository다. 기본적으로 사용되는 HttpSessionCsrfTokenRepository(동기화 토큰 패턴) 대신 이 구현체를 사용하도록 설정하면, Spring Security가 더블 서브밋 쿠키 방식으로 CSRF 보호를 처리한다.
    • 작동 방법
      • 서버는 사용자의 첫 요청 시, 예측 불가능한 토큰을 생성한다.
        • 이 토큰을 세션에 저장하는 대신, 두 곳으로 나누어 클라이언트에게 전송한다.
          • 응답 쿠키(Cookie)에 담아 보낸다.
          • HTML 본문의 hidden 필드AJAX 요청을 위한 헤더 값으로도 보낸다.
      • 클라이언트의 요청
        • 사용자가 데이터를 전송할 때, 브라우저는 쿠키에 담긴 토큰을 자동으로 요청에 포함시킨다.
        • 동시에, 폼 데이터나 AJAX 요청 헤더에 있던 두 번째 토큰도 함께 서버로 전송된다.
      • 서버의 검증
        • 서버는 요청에 담겨 온 두 개의 토큰(쿠키의 토큰, 헤더/본문의 토큰)을 받는다.
        • 서버는 세션을 확인할 필요 없이, 단순히 이 두 개의 값이 일치하는지만 비교한다.
        • 두 값이 일치하면 신뢰할 수 있는 요청으로 판단하고, 일치하지 않으면 CSRF 공격으로 간주하여 차단한다.
    • 구현:  서버는 값 비교만, 저장 불필요 (서명 권장)

대응 전략 3 - SameSite 쿠키 값 설정

  • SameSite는 CSRF 공격의 핵심 원리인 "다른 사이트에서 요청을 보낼 때 브라우저가 인증 쿠키를 자동으로 첨부하는 것" 자체를 막는 방식이다. 즉, 서버가 아닌 브라우저에게 직접 보안 규칙을 지시하는 것이다.
    • SameSite는 3가지 정책 Strict, Lax, None이 있다. 브라우저는 이 정책을 보고 쿠키를 언제 전송할지 결정한다.
      • SameSite=Lax (대부분의 브라우저 기본값): mybank.com과 전혀 관련 없는 악성 사이트(evil.com)에서 보내는 요청(POST, <img> 등)에는 세션 쿠키를 자동으로 첨부하지 않는다. 이 정책 하나만으로 대부분의 CSRF 공격이 원천 차단된다.
      • SameSite=Strict: 가장 강력한 정책으로, 외부 사이트에서 링크를 클릭해 들어오는 경우에도 쿠키를 보내지 않는다.
  • 공격자가 악성 사이트에서 사용자의 은행 사이트로 위조 요청을 보내도, 브라우저가 SameSite 정책에 따라 쿠키를 보내주지 않는다. 결국 서버는 인증되지 않은 요청으로 판단하고 이를 거부하게 된다.
  • Spring Security
    • Spring Security는 사용자의 세션을 관리하는 세션 쿠키 (일반적으로 JSESSIONID)를 생성한다. 
    • 개발자는 이 세션 쿠키가 생성될 때 SameSite=Lax/Strict attribute를 포함하도록 쿠키를 설정하고 브라우저로 보낸 수 있다. 이 설정이 적용되면, 해당 애플리케이션의 모든 인증 쿠키는 브라우저의 SameSite 정책에 따라 보호된다.

XSS (Cross-Site Scripting)

XSS는 공격자가 악의적인 스크립트를 웹 페이지에 삽입하여 사용자의 브라우저에서 실행되도록 하는 공격 기법이다.
  • 주로 쿠키 탈취, 세션 하이재킹, 악성 사이트 리다이렉션, 피싱 등에 활용된다.

CSP(Content-Security-Policy)X-Content-Type-Options: nosniff는 현재 Spring Security의 XSS 방어에서 가장 많이 사용하는 방식이다.

Spring의 대응 전략 1 - X-Content-Type-Options: nosniff

  • 문제 상황
    • 서버가 Content-Type: text/plain으로 응답을 보냈는데, 내용물이 <script>alert('hacked');</script> 와 같은 자바스크립트 코드일 수 있다.
    • 일부 브라우저는 "이건 그냥 텍스트가 아니라 스크립트 같은데?"라고 스스로 판단하여 멋대로 실행해 버릴 수 있다. 이를 MIME 스니핑(MIME Sniffing)이라고 부른다.
    • 공격자는 이 점을 악용해 텍스트 파일인 척 위장한 악성 스크립트를 실행시킬 수 있다.
  • 해결책
    • 컨트롤러에서 정확한 Content-Type 지정
    • 전역으로 X-Content-Type-Options: nosniff 헤더를 설정하면, 브라우저는 서버가 명시한 Content-Type을 절대적으로 신뢰하고 다른 해석을 하지 않는다.
      • 서버가 'text/plain'이라고 했다면, 내용이 스크립트처럼 생겼어도 절대 실행하지 않고 텍스트로만 취급한다.
// 컨트롤러에서 정확한 Content-Type 지정
@RestController
class ApiController {
  @GetMapping(value = "/api/data", produces = MediaType.APPLICATION_JSON_VALUE)
  public Map<String, Object> data() { return Map.of("msg", "hello"); }
}

// 전역 nosniff 적용 (스프링 시큐리티 기본 제공)
http.headers(h -> h.contentTypeOptions(withDefaults())); // X-Content-Type-Options: nosniff

Spring의 대응 전략 2 - CSP (Content-Security-Policy)

  • CSP는 XSS 방어의 핵심으로, 브라우저에 신뢰할 수 있는 콘텐츠의 출처(whitelist)를 알려주는 정책이다. 이 정책에 어긋나는 모든 리소스는 브라우저가 로드하거나 실행하는 것을 거부한다.
  • 스크립트 실행 원천을 제한하고, 인라인 스크립트 금지 또는 허용된 nonce/hash만 실행하게 한다.

Spring Security로 간단 설정 + 동적 nonce 바인딩 예시

http.headers(h -> h
  .contentSecurityPolicy(csp -> csp.policyDirectives(
    "default-src 'self'; script-src 'self' 'nonce-" + "{{nonce}}" + "'; object-src 'none'; base-uri 'self'"))
);
// {{nonce}}는 필터/인터셉터에서 생성한 값을 템플릿/응답에 치환

 

  • script-src 'self' 'nonce-{{nonce}}' 'strict-dynamic'
    • 의미: 스크립트(JavaScript) 파일에 대해서는 더 까다로운 규칙을 적용한다.
    • 'self': 우리 도메인에서 온 스크립트는 허용한다.
    • 'nonce-{{nonce}}': 서버가 매 요청마다 생성하는 **일회성 비밀번호(nonce)**를 가진 <script> 태그만 실행을 허용한다. 공격자가 페이지에 악성 스크립트를 삽입해도, 이 비밀번호를 모르기 때문에 브라우저는 해당 스크립트의 실행을 차단한다.

세션 고정

세션 고정(Session Fixation) 공격은 공격자가 미리 발급받은 세션 ID를 피해자에게 강제로 사용하게 한 뒤, 해당 세션을 탈취하는 공격 기법이다.
  • 즉, 피해자가 정상적으로 로그인하더라도 이미 공격자가 알고 있는 세션 ID가 그대로 유지된다면 공격자는 세션을 가로챌 수 있다.

Spring의 대응 전략 - session fixation 보호 전략

Spring Security는 로그인 시점에 세션 ID를 어떻게 다룰지 결정하는 session fixation 보호 전략을 제공한다.

http.sessionManagement(session -> session
    .sessionFixation(fixation -> fixation.migrateSession()));

전략 설명 특징 권장 사용처
none 기존 세션 그대로 유지 보호 없음 테스트 환경, 보안 불필요 서비스
newSession 새로운 세션을 생성하고 기존 속성은 복사하지 않음 세션 완전 초기화 극단적 보안이 필요한 경우
migrateSession (기본값) 새로운 세션을 생성하고 속성을 복사 보안 + 사용자 편의 균형 일반 웹 서비스 기본
changeSessionId 세션 ID만 새로 발급, 속성 그대로 유지 Servlet 3.1+ 환경 권장 최신 톰캣/서블릿 컨테이너
  • 기본값인 migrateSession은 대부분의 서비스에서 충분히 안전하다.
  • Servlet 3.1 이상 환경에서는 changeSessionId를 권장한다.
  • none은 절대 운영 환경에서 사용하지 않는다!
  • 세션 ID 변경 후에도 필요한 속성(예: 장바구니 정보)이 유지되는지 반드시 확인해야 한다.

JWT 탈취

JWT(JSON Web Token) 탈취는 공격자가 다른 사용자의 유효한 JWT를 훔쳐서, 그 사용자인 것처럼 위장하여 시스템에 접근하는 공격이다.

주요 탈취 경로는 다음과 같다:

  1. XSS(Cross-Site Scripting) 공격: 위에 언급했던 것처럼, 공격자가 웹사이트에 악성 스크립트를 삽입하여, 사용자의 브라우저에 저장된 JWT를 읽어 자신의 서버로 전송하는 방식이다.
  2. 중간자 공격(MITM): 암호화되지 않은 HTTP 통신을 사용하는 경우, 공격자가 네트워크 중간에서 오가는 데이터를 가로채 JWT를 탈취하는 방식이다.

Spring의 대응 전략 1 - HttpOnly 쿠키 사용

토큰을 안전하게 보관하는 것이 먼저다.

  • HttpOnly 속성을 가진 쿠키를 사용해야 한다 -> HttpOnly는 자바스크립트가 쿠키에 접근하는 것을 막는 브라우저 보안 기능인다, 이 설정을 적용하면 XSS 공격이 성공하더라도 공격자는 쿠키에 담긴 JWT를 읽어갈 수 없다. Spring에서는 ResponseCookie를 사용하여 쉽게 설정할 수 있다.
  • 또한, 절대 로컬 스토리지(Local Storage)를 사용하면 안된다 -> 로컬 스토리지는 자바스크립트로 쉽게 접근할 수 있어 XSS 공격에 매우 취약하다: 공격자가 삽입한 스크립트 한 줄이면 저장된 모든 토큰이 유출될 수 있다...!
// HttpOnly, Secure, SameSite 속성을 모두 적용한 안전한 쿠키 생성
ResponseCookie cookie = ResponseCookie.from("refreshToken", jwt)
    .httpOnly(true)
    .secure(true) // HTTPS 환경에서만 쿠키 전송
    .sameSite("Lax") // CSRF 공격 방어
    .path("/")
    .maxAge(쿠키만료시간)
    .build();

response.addHeader("Set-Cookie", cookie.toString());

Spring의 대응 전략 2 - 짧은 만료 기간의 액세스 토큰 + 리프레시 토큰 도입

  • 액세스 토큰(Access Token) vs 리프레시 토큰(Refresh Token)
    • 액세스 토큰(Access Token): 실제 API 요청에 사용되며, 만료 시간을 5분~30분 정도로 매우 짧게 설정한다.
      • 이 토큰이 탈취되더라도 공격자는 아주 짧은 시간 동안만 시스템을 악용할 수 있다.
    • 리프레시 토큰(Refresh Token): 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 재발급받는 용도로만 사용된다.
      • 만료 시간은 7일~30일 정도로 길게 설정하며, 반드시 HttpOnly 쿠키에 안전하게 저장하여 탈취 위험을 최소화해야 한다.

Spring의 대응 전략 3 - 모든 통신에 HTTPS 강제

가장 기본적이고 중요하다. HTTPS를 사용하면 클라이언트와 서버 간의 모든 통신이 암호화되므로, 공격자가 네트워크 중간에서 데이터를 가로채는 중간자 공격(MITM)을 원천적으로 차단할 수 있다.

 

SecurityConfig 예시:

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.requiresChannel(channel ->
            channel.anyRequest().requiresSecure()); // 모든 요청에 HTTPS 강제
        // ... 기타 설정
        return http.build();
    }
}