[CVE-2025-41232] 스프링 시큐리티(Spring Security) 인가 우회(Authorization Bypass) 취약점

6 분 소요


RECOMMEND POSTS BEFORE THIS

1. CVE-2025-41232

CVE-2025-41232는 스프링 시큐리티(Spring Security)에서 발견된 인가 우회(authorization bypass) 취약점이다. 스프링 시큐리티의 AspectJ 모드에서 private 메서드에 적용된 메서드 기반 인가 처리가 정상적으로 동작하지 않을 때 발생한다.

private 메서드에 적용된 보안 애너테이션을 올바르게 찾지 못해 보안상 허점이 생겼다. 근본적인 원인은 UniqueSecurityAnnotationScanner 클래스에서 메서드 일치 여부를 비교할 때 equals 메서드 대신 == 연산자를 사용했기 때문이다. 이로 인해 AspectJ 라이브러리처럼 컴파일 타임에 메서드 동작을 엮어 넣는 위빙(weaving) 기술이 적용되었을 때 해당 스캐너가 동일한 메서드로 인식하지 못한다. 결과적으로 적절한 권한 검사 없이 해당 private 메서드가 호출될 수 있다.

  • CVSS 위험도 점수
    • 9.1(CRITICAL)로 평가(CVSS 4.0 기준)
  • 영향을 받는 버전
    • 스프링 시큐리티 6.4.0 ~ 6.4.5 버전
    • @EnableMethodSecurity(mode=ASPECTJ)를 사용하지 않거나, 보안 애너테이션이 적용된 private 메서드가 없다면 이 취약점의 영향을 받지 않는다.
  • 패치된 버전
    • 스프링 시큐리티 6.4.6

CVSS 4.0 기준 기본 지표(base metrics)인 익스플로잇(exploit) 가능성과 시스템 영향은 다음과 같다.

  • AV:N (Attack Vector: Network)
    • 공격자가 취약점이 존재하는 시스템에 물리적으로 접근하거나 로컬 환경에 있을 필요 없이, 인터넷이나 내부망 등 네트워크를 통해 원격으로 공격을 수행할 수 있다.
    • 이 취약점의 경우, 공격자는 특정 접근 경로를 통해 취약한 스프링 시큐리티 애플리케이션에 네트워크로 접근하여 내부에서 보호되어야 할 private 메서드가 실행되도록 유도할 수 있다.
  • AC:L (Attack Complexity: Low)
    • 공격을 성공시키기 위해 복잡한 조건이나 특별한 보안 회피 기법이 필요하지 않고, 일관되게 공격할 수 있음을 의미한다.
    • 이 취약점의 경우, 애플리케이션이 취약한 조건(@EnableMethodSecurity(mode=ASPECTJ) 사용 및 private 메서드에 보안 애너테이션 적용)을 충족하기만 하면, 특수한 회피 기법 없이도 애플리케이션 내부의 메서드 비교 오류(== 연산자 사용)로 인해 권한 우회가 발생한다.
  • AT:N (Attack Requirements: None)
    • 취약점을 악용하기 위해 대상 시스템에 특별한 사전 배포나 실행 조건이 요구되지 않는다.
    • 이 취약점의 경우, 영향을 받는 버전과 설정 조건을 충족하는 애플리케이션이라면 추가 사전 조건 없이 취약한 private 메서드 호출 경로를 악용할 수 있다.
  • PR:N (Privileges Required: None)
    • 공격자가 대상 시스템이나 애플리케이션에 로그인하거나 특정 권한을 획득할 필요가 전혀 없음을 의미한다. 인증되지 않은 사용자도 취약점을 악용할 수 있다.
    • 이 취약점의 본질은 보호 메커니즘 실패에 따른 ‘인가 우회’이므로, 사전에 특정 권한을 부여받지 않은 외부 공격자라도 보호되어야 할 private 메서드를 인증 없이 호출할 수 있다.
  • UI:N (User Interaction: None)
    • 공격을 수행할 때 관리자나 일반 사용자의 상호작용이 전혀 필요하지 않다. 공격자가 단독으로 취약점을 트리거할 수 있다.
    • 이 취약점의 경우, 공격자가 취약한 애플리케이션을 향해 직접 요청을 보내 우회를 유발하며, 관리자나 사용자가 특정 링크를 클릭하는 등의 개입도 요구되지 않는다.
  • S:U (Scope: Unchanged)
    • 취약점을 통해 공격받은 컴포넌트가 다른 시스템이나 컴포넌트의 보안 범위에 영향을 미치지 않는다.
    • 이 취약점의 경우, 권한 우회의 영향은 스프링 시큐리티가 적용된 해당 애플리케이션 내부의 메서드 실행에만 국한되며, 기반 운영체제 등 다른 시스템 영역으로 권한이 확장되지는 않는다.
  • C:H (Confidentiality Impact: High)
    • 기밀성에 높은 영향을 미친다.
    • 이 취약점의 경우, 인가 없이 호출된 private 메서드가 민감한 시스템 정보나 사용자의 개인 데이터를 조회하고 반환하는 로직을 포함한다면, 해당 데이터가 공격자에게 그대로 유출되어 심각한 침해가 발생할 수 있다.
  • I:H (Integrity Impact: High)
    • 무결성에 높은 영향을 미친다.
    • 이 취약점의 경우, 공격자가 접근에 성공한 private 메서드가 데이터베이스 값을 수정하거나 삭제하는 등 상태 변경(write) 기능을 가진다면, 애플리케이션 내부의 데이터가 임의로 조작되거나 심각하게 훼손될 수 있다.
  • A:N (Availability Impact: None)
    • 공격으로 인한 시스템의 가용성 상실 영향이 없음을 의미한다.
    • 이 취약점은 특정 메서드에 대한 접근 권한을 우회하여 비정상적으로 실행하는 문제이며, CPU나 메모리 같은 시스템 자원을 과도하게 소모하거나 고갈시켜 서비스를 강제로 중단시키는 메커니즘은 아니므로 시스템의 가용성에는 영향을 주지 않는다.

2. CWE-693

해당 취약점과 연관된 취약점 유형(CWE, Common Weakness Enumeration)은 CWE-693: 보호 메커니즘 실패(Protection Mechanism Failure)이다. CWE-693(Protection Mechanism Failure) 유형의 취약점은 다음과 같은 특징을 갖는다.

  • 제품이나 애플리케이션이 자신을 대상으로 한 직접적인 공격으로부터 시스템을 충분히 방어할 수 있는 보호 장치(메커니즘)를 아예 사용하지 않거나, 잘못 사용하는 경우에 발생하는 취약점이다.

이 취약점 유형은 크게 세 가지 세부 실패 상황을 포괄한다.

  • 특정 공격 유형에 대해 애플리케이션이 어떤 방어 메커니즘도 정의하지 않은 경우
  • 방어 메커니즘이 존재하여 일반적인 공격 등은 막아낼 수 있지만, 원래 의도했던 모든 위협을 완벽하게 방어하지는 못하는 경우
  • 보호 메커니즘이 제품에 존재하고 실제로 사용되고 있는데도, 특정 코드 경로에는 해당 메커니즘이 정상적으로 적용되지 못한 경우

이 취약점은 앞서 언급한 세 가지 상황 중 ‘무시된/적용되지 않은(Ignored) 메커니즘’의 전형적인 사례에 해당한다. 스프링 시큐리티라는 보호 메커니즘이 시스템에 존재하고 사용 중이었으나, 내부 코드에서 메서드를 비교할 때 equals 메서드 대신 == 연산자를 사용하는 단순한 로직 오류가 있었다.

이로 인해 AspectJ 라이브러리처럼 컴파일 시점에 코드를 위빙하는 기술을 사용할 때, 시스템이 특정 private 메서드에 적용된 보안 애너테이션을 올바르게 인식하지 못하고 지나쳤다. 결과적으로 해당 private 메서드의 코드 경로에서는 인가 보호 메커니즘이 작동하지 않아, 적절한 권한 검사 없이 메서드가 호출되는 심각한 인가 우회 상태가 발생했다.

3. Cause of vulnerability

이제 보안 취약점이 동작하는 원리를 살펴보자. 전체 코드는 아래 테스트 코드 저장소에서 확인할 수 있다. 보안 문제가 발생한 의존성 구성을 살펴보자.

  • org.springframework.boot 3.4.5 버전
  • spring-security.version 6.4.5 버전
  • AspectJ 모드를 사용하기 위한 spring-security-aspects 의존성
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

...

ext {
    set('spring-security.version', '6.4.5') 
}

...

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-aspects'
    implementation 'org.aspectj:aspectjrt:1.9.22'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    ...
}

...

AspectJ 라이브러리의 위빙 기능을 사용하기 때문에 IDE의 디버거(debugger) 모드가 정상적으로 동작하지 않는다. 디버거 모드를 통해 콜 스택 등 실행 흐름을 확인하려면 아래 추가 설정이 필요하다.

...

configurations {
    ajc
    aspectpath
}

dependencies {
    ...
    ajc 'org.aspectj:aspectjtools:1.9.22'
    aspectpath 'org.springframework.security:spring-security-aspects'
}

tasks.named('compileJava').configure {
    doLast {
        ant.taskdef(
                resource: 'org/aspectj/tools/ant/taskdefs/aspectjTaskdefs.properties',
                classpath: configurations.ajc.asPath
        )
        ant.iajc(
                source: '17',
                target: '17',
                destDir: destinationDirectory.get().asFile.absolutePath,
                maxmem: '1024m',
                fork: 'true',
                aspectPath: configurations.aspectpath.asPath,
                inpath: destinationDirectory.get().asFile.absolutePath,
                classpath: (configurations.compileClasspath + files(destinationDirectory)).asPath,
                showWeaveInfo: 'true'
        )
    }
}

메인 클래스에 아래와 같이 메서드 기반 인가 처리를 활성화하고 모드를 AdviceMode.ASPECTJ로 지정한다.

package action.in.blog;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.AdviceMode;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@EnableMethodSecurity(mode = AdviceMode.ASPECTJ)
@SpringBootApplication
public class ActionInBlogApplication {

    public static void main(String[] args) {
        SpringApplication.run(ActionInBlogApplication.class, args);
    }

}

테스트를 위해 아래와 같은 SystemUnderTest 클래스를 만든다. 이 보안 취약점은 AspectJ 모드를 사용할 때 private 메서드에 지정한 인가 애너테이션이 정상적으로 동작하지 않는 문제다. public 메서드와 private 메서드를 만든 뒤, private 메서드에 @PreAuthorize 애너테이션을 지정한다.

  • ADMIN 역할(role)을 가진 사용자만 접근할 수 있다.
package action.in.blog.component;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;

@Component
public class SystemUnderTest {

    public String publicMethod() {
        return adminMethod();
    }

    @PreAuthorize("hasRole('ADMIN')")
    private String adminMethod() {
        return "Admin method executed";
    }
}

해당 권한 처리가 정상적으로 동작하는지 확인하는 테스트 코드를 살펴보자.

  • USER 역할을 가진 사용자가 접근할 경우 AccessDeniedException 예외가 발생할 것으로 예상한다.
package action.in.blog;

import action.in.blog.component.SystemUnderTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;

import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
class ActionInBlogApplicationTests {

    @Autowired
    SystemUnderTest sut;

    @Test
    @WithMockUser(roles = "USER")
    void givenUserRole_whenCallAdminMethod_thenAccessDenied() {
        assertThrows(AccessDeniedException.class, () -> {
            sut.publicMethod();
        });
    }
}

SystemUnderTest 객체의 publicMethod 메서드 내부에서 인가 처리가 적용된 private 메서드를 호출하기 때문에 테스트가 정상적으로 통과(GREEN)할 것 같지만, 실제로는 AccessDeniedException 예외가 발생하지 않아 테스트가 실패(RED)한다. 인가 처리가 제대로 동작하지 않아 아무런 예외도 발생하지 않는다. 테스트를 실행하면 AccessDeniedException 예외를 던질 것으로 예상했지만, 실제로는 예외가 발생하지 않았음을 보여주는 테스트 실패 로그를 확인할 수 있다.

Expected org.springframework.security.access.AccessDeniedException to be thrown, but nothing was thrown.
org.opentest4j.AssertionFailedError: Expected org.springframework.security.access.AccessDeniedException to be thrown, but nothing was thrown.
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:152)
	at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:73)
	at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
	at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3128)
	at action.in.blog.ActionInBlogApplicationTests.givenUserRole_whenCallAdminMethod_thenAccessDenied(ActionInBlogApplicationTests.java:21)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
  ...

이제 원인을 살펴보자. 취약점을 유발한 코드에 브레이크포인트를 설정한 뒤 테스트를 실행하면 다음과 같은 콜 스택(call stack)을 볼 수 있다.


위 콜 스택의 실행 흐름을 추상화해 시퀀스 다이어그램으로 표현하면 다음과 같다.

  1. @PreAuthorize 애너테이션이 적용된 메서드가 실행되면 AuthorizationManagerBeforeMethodInterceptor 객체를 통해 인가 처리가 이루어진다.
  2. AuthorizationManagerBeforeMethodInterceptor 객체는 PreAuthorizeAuthorizationManager 객체에 인가 작업을 위임한다.
  3. PreAuthorizeAuthorizationManager 객체는 SpEL(Spring Expression Language)을 통해 애너테이션에 문자열로 작성된 인가 표현식을 처리한다. 이 예제의 경우 hasRole('ADMIN')을 의미한다.
  4. PreAuthorizeAuthorizationManager 객체는 PreAuthorizeExpressionAttributeRegistry 객체를 통해 인가 작업이 필요한 대상 메서드의 SpEL 표현식 속성(attribute) 객체를 만든다.
  5. PreAuthorizeExpressionAttributeRegistry 객체는 UniqueSecurityAnnotationScanner 객체를 통해 대상 메서드를 찾고, 해당 메서드에 어떤 인가 애너테이션들이 적용되어 있는지 확인한다.


위 과정에서 UniqueSecurityAnnotationScanner 객체가 대상 메서드를 정상적으로 찾지 못하면 애너테이션도 찾을 수 없고, 그 결과 SpEL 표현식 속성 객체를 만들 수 없어 인가 작업이 생략된다. 문제가 발생한 UniqueSecurityAnnotationScanner 클래스의 findMethod 메서드 코드를 살펴보자.

  • 후보 메서드와 대상 메서드를 비교할 때 == 연산자를 사용한다.
private static Method findMethod(Method method, Class<?> targetClass) {
    for (Method candidate : targetClass.getDeclaredMethods()) {
        if (candidate == method) { // this point
            return candidate;
        }
        if (isOverride(method, candidate)) {
            return candidate;
        }
    }
    return null;
}

== 연산자는 레퍼런스를 비교하기 때문에 같은 메서드인데도 제대로 비교되지 않아 null 값을 반환하는 경우가 생긴다. 대상 메서드를 찾지 못하면 @PreAuthorize 애너테이션도 찾지 못하므로 인가 작업을 수행하지 않는다. 실제로 브레이크포인트를 설정해 런타임 데이터를 확인해보자. 아래 이미지를 보면 같은 메서드를 나타내는 객체이지만, 객체 주소가 달라 같은 메서드로 판단하지 못한다.


해당 문제는 스프링 시큐리티 6.4.6 버전에서 수정되었으므로 라이브러리 버전을 올리면 위 테스트가 정상적으로 통과한다. build.gradle 파일에서 스프링 시큐리티 버전을 올린다.

ext {
    set('spring-security.version', '6.4.6')
}

테스트를 실행하면 정상적으로 통과한다.

TEST CODE REPOSITORY

REFERENCE

댓글남기기