
CVE-2026-22732를 시연하는 개념 증명으로, `setIntHeader("Content-Length")`가 모든 보안 헤더를 제거하는 Spring Security 취약점이며, 취약 빌드와 패치된 빌드를 포함합니다.
Spring Security가 HTTP 응답 보안 헤더를 조용히 누락시킨다. 데모 / 교육용으로만 사용하며, 이 로컬 앱 외에는 어떤 대상에도 실행하지 말 것.
| CVE | CVE-2026-22732 (CWE-425), 2026-03-19 공개 |
| CVSS 3.1 | 9.1 CRITICAL — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 직접 의존성 | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| 취약 컴포넌트 | spring-security-web / -config / -core 5.7.11 — 전이 의존성일 뿐, pom.xml에 명시된 적 없음 |
| 수정 컴포넌트 | spring-security-web 5.7.14-0.cgr.2, <version> 한 번 변경으로 도달 — 전환 과정 참조 |
| 검증 환경 | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
영향 범위: 5.7.0–5.7.21, 5.8.0–5.8.23, 6.3.0–6.3.14, 6.4.0–6.4.14, 6.5.0–6.5.8, 7.0.0–7.0.3. Spring Boot 2.7.18은 Spring Security 5.7.11을 고정하며, 이는 첫 번째 범위에 정확히 들어간다:
$ mvn dependency:tree -Dincludes=org.springframework.security
+- org.springframework.security:spring-security-config:jar:5.7.11:compile
| \- org.springframework.security:spring-security-core:jar:5.7.11:compile
\- org.springframework.security:spring-security-web:jar:5.7.11:compile
./run.sh # 터미널 1 — 빌드 후 :8080에서 시작 (JDK 17 고정)
./exploit.sh # 터미널 2 — 모든 엔드포인트를 구동하고 헤더를 비교
run.sh는 JAVA_HOME을 고정하는데, Spring Boot 2.7.x는 이 머신에서 mvn이 기본으로 선택하는
JDK 25에서 실행될 수 없기 때문이다. JAVA_HOME_17=/path/to/jdk17로 재정의할 수 있다.
또한 기본적으로 -s settings-chainguard.xml로 빌드하는데, 패치된 parent가 Maven Central에 없기
때문이다. 다른 위치를 가리키려면 MAVEN_SETTINGS=/path/to/your/settings.xml을 설정하거나,
MAVEN_SETTINGS=로 순수하게 Central에서만 빌드할 수 있다 — 이는 순정 2.7.18에만 통한다.
exploit.sh는 target/*.jar에서 실제 spring-security-web과 spring-boot 버전을 읽어들이므로,
배너는 항상 하드코딩된 문자열이 아니라 실제로 실행 중인 것을 보고한다.
SecurityConfig는 헤더 커스터마이징을 전혀 적용하지 않는다 — Spring Security의 기본값이
그대로 적용되며, 이는 보안을 고려한 앱이 바로 의존하는 것이다. 모든 엔드포인트는 동일한 민감한
본문을 반환한다:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
유일하게 달라지는 것은 컨트롤러가 응답을 작성하는 방식이다.
BASELINE standard Spring MVC return value
/safe/account OK all 6 headers delivered
CONTROL getOutputStream(), body > 8 KB buffer
/vuln/stream/account OK all 6 headers delivered
CONTROL explicit response.flushBuffer()
/vuln/flush/account OK all 6 headers delivered
EXPLOIT setIntHeader("Content-Length", n) <-- CVE-2026-22732
/vuln/content-length/account BYPASSED ALL 6 security headers dropped
BY DESIGN application sets its own Expires header (NOT this CVE)
/vuln/cache/account PARTIAL Cache-Control + Pragma dropped
CVE가 패치될 때 상태가 바뀌는 것은 /vuln/content-length/account뿐이므로, exploit.sh가 판정을
도출하는 엔드포인트도 이것뿐이다. 나머지는 대조군이다.
setIntHeader("Content-Length", n) → 완전 우회평범해 보이는 컨트롤러 코드 세 줄이 Spring Security가 약속한 모든 헤더를 제거한다:
response.setContentType("application/json");
response.setIntHeader("Content-Length", body.length);
response.getOutputStream().write(body);
$ curl -sD - -o /dev/null http://localhost:8080/vuln/content-length/account
HTTP/1.1 200
Content-Type: application/json
Content-Length: 77
Date: Tue, 01 Sep 2026 00:53:18 GMT
X-Frame-Options도, X-Content-Type-Options도, Cache-Control도, Pragma도, Expires도,
X-XSS-Protection도 없다. 여섯 개를 모두 갖춘 /safe/account와 비교해 보라. 응답은 어떤
오리진에서든 프레이밍 가능하고, MIME 스니핑 가능하며, 캐시 가능하다 — 그러면서 카드 번호를
제공한다.
정정: 이 README의 이전 버전은 이를 "exploit 2"라고 부르고 어드바이저리가 문서화한 조건이라고
주장했다. 그것은 CVE-2026-22732의 일부가 아니며 어떤 업그레이드로도 고쳐지지 않는다.
CacheControlHeadersWriter는 5.7.11, 5.7.14-0.cgr.2, 6.5.8(마지막 취약 버전), 6.5.9(첫 수정
버전)에서 바이트 단위로 동일하다 — 소스 jar를 diff하여 검증했다. 그 Javadoc은 이 동작을 명시적으로
서술한다: "캐시 제어 헤더가 지정되지 않은 경우 캐싱을 방지하기 위해 헤더를 삽입한다."
그래도 시연할 가치가 있는데, 유출이 실제이며 잔여 위험이 패치 후에도 살아남기 때문이다.
CacheControlHeadersWriter는 Cache-Control, Expires 또는 Pragma가 이미 존재하면
중단하므로, 세 가지 중 하나만 설정해도 Spring Security의 모든 no-store 지시문이 억제된다.
선의의 한 줄이면 충분하다:
response.setHeader("Expires", "Thu, 01 Jan 2099 00:00:00 GMT");
/safe/account NOT cacheable (Cache-Control: no-cache, no-store, max-age=0, must-revalidate)
/vuln/cache/account CACHEABLE (Cache-Control: absent / Expires: Thu, 01 Jan 2099 00:00:00 GMT)
/vuln/content-length/account CACHEABLE (Cache-Control: absent / Expires absent)
위 표에서 Expires는 "존재함"으로 간주되지만, 앱이 선택한 공격자 친화적 값으로 인해 Spring
Security의 Expires: 0은 단순히 누락된 것이 아니라 대체되었다. 카드 소지자 데이터는 이제 2099년까지
경로상의 모든 브라우저와 공유 프록시에 저장될 수 있다.
패치된 빌드에서는 /vuln/content-length/account가 NOT cacheable로 바뀌는 반면,
/vuln/cache/account는 위와 정확히 동일하게 유지된다. 애플리케이션 코드나 리버스 프록시만이 이를
고칠 수 있다 — 이것이 데모에서 소리 내어 말할 유용한 점이다: 라이브러리를 업그레이드하면 CVE는
닫히고 이것은 그대로 남는다.
널리 퍼진 여러 글 — 공개 재현 저장소를 포함하여 — 은 response.getOutputStream()과
response.flushBuffer()를 트리거로 나열하며, "Spring Security가 헤더를 주입하기 전에 응답이
커밋된다"고 설명한다. Spring Security 5.7.11에서는 그것이 틀렸다. 두 엔드포인트 모두 여섯 개
헤더를 전부 전달한다.
/diag/committed는 그 설명이 성립하지 않는 이유를 보여준다. 12 KB 쓰기 후 응답은 실제로
컨트롤러 내부에서 커밋되지만, 헤더는 여전히 도착한다:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper는 flushBuffer()와 출력 스트림 쓰기를 재정의하므로, 그 커밋보다
먼저 헤더를 내보낸다. 커밋 순서만으로는 버그가 아니며, 선언된 Content-Length 경로가 버그다.
Spring 어드바이저리 자체도 커밋 순서 이야기를 지지하지 않는다.
이 두 엔드포인트를 남겨두면 PoC가 반증 가능해진다: 재현되지 않는 것을 재현되는 것만큼 명확히 보여주며, 둘 다 패치 전반에 걸쳐 초록으로 유지되는데, 이것이 바로 바뀌는 하나의 엔드포인트를 의미 있게 만든다.
pom.xml의 한 줄, 그 외에는 아무것도 없다. 소스 변경도, 프로퍼티 변경도, Spring Boot 메이저
버전 업도 없다:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version> <!-- vulnerable -->
<version>2.7.18-0.cgr.3</version> <!-- patched -->
</parent>
이는 parent의 spring-boot-dependencies를 통해 spring-security.version을 5.7.11에서
5.7.14-0.cgr.2로 (그리고 spring-framework.version을 5.3.31에서 5.3.39-0.cgr.4로)
다시 고정한다. 측정 결과:
spring-security-web 소스 jar를 diff하면 파일 하나만 중요하다. 5.7.11 →
5.7.14-0.cgr.2는 OnCommittedResponseWrapper에 setHeader / setIntHeader / addIntHeader
재정의를 추가하며, 각각 Content-Length를 setContentLength()를 통해 라우팅한다:
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
수정 전에는 addHeader만 이렇게 했으므로, setIntHeader("Content-Length", n)은 래퍼의 추적
길이를 0으로 남겨두었고, onResponseCommitted()는 결코 발동하지 않았으며, HeaderWriterFilter는
Tomcat이 응답을 커밋하기 전에 헤더를 결코 쓰지 않았다.
업스트림 6.5.8(마지막 취약)과 6.5.9(첫 수정)를 diff할 때도 동일한 헝크가 그대로 나타나므로,
이는 재구현이 아니라 공식 수정을 백포트한 것이다. Chainguard 빌드는 업스트림 6.5.9에 없는
null 가드 두 개를 추가한다(String 오버로드의 value != null, 그리고 append의
(csq != null) ? csq.length() : 4).
Spring Security 5.7.x는 업스트림에서 EOL이다; OSS 수정은 6.4.15 / 6.5.9 / 7.0.4+에만 적용된다. 재빌드된 5.7.x가 선택지가 아니라면:
ObjectPostProcessor를 통해
HeaderWriterFilter.shouldWriteHeadersEagerly = true를 설정. 어드바이저리에 따르면 이는
동작을 바꾼다: 애플리케이션이 작성한 헤더가 Spring Security의 헤더를 억제하는 대신 특정
헤더만 재정의하게 된다. 이것은 버전 범프가 고치지 않는 /vuln/cache/account도 고친다.이 중 어느 것도 이 프로젝트에 연결되어 있지 않으므로, 취약한 동작이 기본값이며 패치된 상태는 위의
단일 <version> 변경으로 도달할 수 있다.
Boot 2.7.18은 Tomcat 9.0.83을 고정하며, grype .는 여기서 **34개 CVE (4 Critical)**를
표시한다. 모두 9.0.118 이하에서 수정되었고, 9.0.118이 최신 9.0.x 릴리스이므로 프로퍼티 하나로
전체를 정리할 수 있다:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies는 그 단일 프로퍼티를 통해 모든 tomcat-embed-* 아티팩트를 선언하므로,
이를 재정의하면 core, el, websocket이 함께 다시 고정된다. 검증됨:
$ mvn dependency:tree | grep tomcat-embed
tomcat-embed-core:jar:9.0.118:compile
tomcat-embed-el:jar:9.0.118:compile
tomcat-embed-websocket:jar:9.0.118:compile
제약: 9.0.x 라인에 머물러야 한다. Tomcat 10+는 Servlet API를 jakarta.*로 옮긴 반면 Spring
Framework 5.3은 javax.servlet에 대해 컴파일하므로, 10.x/11.x 범프는 런타임에 서블릿 타입에서
NoClassDefFoundError로 실패한다.
Boot 2.7.18의 나머지 관리 의존성에도 동일한 메커니즘을 적용한다. 메이저 버전 범프도, Spring Boot 업그레이드도 없다:
이 재정의들은 패치된 parent와 상호작용하므로, 데모 전에 무엇을 하는지 알아야 한다.
2.7.18-0.cgr.3에서 parent는 이미 tomcat.version 9.0.118, logback.version 1.2.13,
snakeyaml.version 1.33을 제공한다 — 이 세 행은 정확한 중복이 되어 아무것도 바꾸지 않고 삭제할
수 있다. jackson-bom.version과 log4j2.version 행은 여전히 실제로 작동한다: 패치된 parent는
Boot의 순정 2.13.5 / 2.17.2를 유지하므로, 재정의가 이기고 그 두 의존성은 Chainguard 빌드가 아닌
순수 업스트림 빌드로 해석된다. spring-framework.version은 의도적으로 주석 처리되어 있으며,
이것이 parent의 5.3.39-0.cgr.4를 통과시키는 이유다.
grype . 진행 상황| 상태 | 발견 사항 | 내역 |
|---|---|---|
| 순정 Boot 2.7.18 | 99 | 7C / 39H / 38M / 15L |
| + Tomcat 범프 | 65 | 3C / 23H / 29M / 10L |
| + 모든 메이저 내 범프 |
완전히 정리됨: Tomcat (34), Jackson (7), log4j (1). 전체 99 → 43, High 39 → 12.
모든 범프 후 검증됨: 앱이 Tomcat/9.0.118에서 부팅되고, 여섯 엔드포인트 모두 200을 반환하며,
CVE가 바이트 단위로 재현된다. Spring Boot는 여전히 2.7.18이고 Spring Security는 여전히 5.7.11이므로,
CVE-2026-22732는 손대지 않은 상태다 — 이것이 이 섹션의 요점이며, 동시에 그 정직한 한계다:
주변을 전부 패치해도 애플리케이션 프레임워크 CVE에는 아무 효과가 없다. 그것을 고치려면 프로퍼티가
아니라 parent 범프가 필요하다.
최신 1.x는 1.6.3이다 — 같은 메이저이므로 명목상 범위 내다. 작동하지 않는다. Logback 1.3+는
SLF4J 1.7의 StaticLoggerBinder를 SLF4J 2.x의 ServiceLoader 프로바이더로 대체했고, Boot 2.7의
LogbackLoggingSystem은 StaticLoggerBinder를 직접 호출한다. 1.5.38로 측정:
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder"
Caused by: java.lang.NoClassDefFoundError: org/slf4j/impl/StaticLoggerBinder
at org.springframework.boot.logging.logback.LogbackLoggingSystem.getLoggerContext(...:304)
이를 벗어나려면 SLF4J 2.x(메이저 범프) 그리고 Boot 3.x 로깅 시스템이 필요하다. 1.2.13이 실제 상한이며, 이 라인에서 고칠 수 없는 6개의 Logback 발견 사항(2 Medium, 4 Low)이 남는다.
| 컴포넌트 |
|---|
남은 세 개의 Critical 중 두 개는 점수가 아니라 제대로 읽을 가치가 있다:
HttpInvokerServiceExporter를 통한
역직렬화. 이 앱은 HTTP Invoker를 사용하지 않으므로 여기서는 도달할 수 없다.5.7.14-0.cgr.2가 수정 버전을 넘어서므로 부수적으로 이를 정리한다.잔여물은 구조적이다: Spring Framework 5.3.x와 Spring Security 5.7.x는 둘 다 EOL이다. Tomcat이나 Jackson이 아니라 이것이 Boot 3.x 마이그레이션의 진짜 근거다.
pom.xml parent + 2 starters, nothing else. Flip the <version> to switch state.
run.sh build + run on JDK 17, via settings-chainguard.xml
exploit.sh header diff, cache impact, clickjacking check, CVE-scoped verdict
settings-chainguard.xml Chainguard Libraries repo -- required for the patched parent
src/main/java/com/example/poc/
PocApplication.java @SpringBootApplication
SecurityConfig.java permitAll, zero header customisation
AccountController.java baseline, 2 exploits, 2 controls, 1 diagnostic
인증은 permitAll이고 CSRF는 꺼져 있어 curl이 인증 없이 작동한다 — 둘 다 이 CVE의 일부가 아니다.
| 2.7.18 | 2.7.18-0.cgr.3 |
|---|
/safe/account | OK 6/6 | OK 6/6 |
/vuln/stream/account | OK 6/6 | OK 6/6 |
/vuln/flush/account | OK 6/6 | OK 6/6 |
/vuln/content-length/account | BYPASSED 0/6 | OK 6/6 |
/vuln/cache/account | PARTIAL 4/6 | PARTIAL 4/6 (설계상) |
exploit.sh 판정 | VULNERABLE | PATCHED |
| 프로퍼티 | Boot 2.7.18 기본값 | 여기서 고정 | 상한 이유 |
|---|
tomcat.version | 9.0.83 | 9.0.118 | 최신 9.0.x; 10+는 jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | Central의 마지막 OSS 5.3.x |
jackson-bom.version | 2.13.5 | 2.22.2 | 최신 2.x |
log4j2.version | 2.17.2 | 2.26.1 | 최신 2.x |
snakeyaml.version | 1.30 | 1.33 | 마지막 1.x; 남은 CVE의 수정은 2.0 |
logback.version | 1.2.12 | 1.2.13 | 마지막 1.2.x — 아래 참조 |
spring-security.version | 5.7.11 | 그대로 둠 | 데모의 주제이기 때문 |
| 3C / 12H / 18M / 10L |
| 메이저 내에서 고칠 수 없는 이유 |
|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39는 마지막 OSS 5.3.x; 15개 webmvc 발견 사항 중 14개는 수정이 전혀 없으며, grype가 언급하는 5.3.42는 상용 전용 |
| logback-core (6) | SLF4J 2.x 필요, 위 참조 |
| spring-security-* (8) | EOL 라인; CVE-2026-22732는 의도적 |
| spring-boot / -autoconfigure (3) | 2.7.x에 대한 수정이 게시되지 않음 |
| snakeyaml (1) | CVE-2022-1471은 2.0에서만 수정됨 |