
Proof-of-Concept für CVE-2026-22732, eine Spring-Security-Schwachstelle, bei der setIntHeader("Content-Length") alle Sicherheits-Header entfernt, mit verwundbaren und gepatchten Builds.
| CVE | CVE-2026-22732 (CWE-425), veröffentlicht am 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 |
| Direkte Abhängigkeit | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Verwundbare Komponente | spring-security-web / -config / -core 5.7.11 — nur transitiv, nie in pom.xml genannt |
| Behobene Komponente | spring-security-web 5.7.14-0.cgr.2, erreicht durch eine einzige <version>-Änderung — siehe den Übergang |
| Verifiziert auf | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Betroffene Bereiche: 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 pinnt Spring Security 5.7.11, genau innerhalb des ersten Bereichs:
$ 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 # Terminal 1 — baut und startet auf :8080 (pinnt JDK 17)
./exploit.sh # Terminal 2 — fährt jeden Endpunkt an, vergleicht die Header
run.sh pinnt JAVA_HOME, weil Spring Boot 2.7.x nicht auf dem JDK 25 laufen kann, das mvn auf
dieser Maschine standardmäßig auflöst. Überschreibe mit JAVA_HOME_17=/path/to/jdk17.
Es baut außerdem standardmäßig mit -s settings-chainguard.xml, weil das gepatchte Parent nicht auf
Maven Central liegt. Setze MAVEN_SETTINGS=/path/to/your/settings.xml, um woanders hinzuzeigen, oder
MAVEN_SETTINGS=, um rein von Central zu bauen — was nur für das unveränderte 2.7.18 funktioniert.
exploit.sh liest die tatsächlichen spring-security-web- und spring-boot-Versionen aus
target/*.jar, sodass sein Banner immer meldet, was wirklich läuft, statt eines fest kodierten Strings.
SecurityConfig wendet überhaupt keine Header-Anpassung an — Spring Securitys Standardeinstellungen
sind in Kraft, genau worauf sich eine sicherheitsbewusste App verlässt. Jeder Endpunkt liefert denselben
sensiblen Body zurück:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
Das Einzige, was variiert, ist, wie der Controller die Response schreibt.
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
Nur /vuln/content-length/account ändert seinen Zustand, wenn die CVE gepatcht wird, daher ist es der
einzige Endpunkt, aus dem exploit.sh sein Urteil ableitet. Der Rest sind Kontrollen.
setIntHeader("Content-Length", n) → vollständiger BypassDrei Zeilen gewöhnlich aussehender Controller-Code entfernen jeden Header, den Spring Security versprochen hat:
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
Kein X-Frame-Options, kein X-Content-Type-Options, kein Cache-Control, kein Pragma, kein Expires, kein
X-XSS-Protection. Vergleiche /safe/account, das alle sechs trägt. Die Response ist von jedem
Origin framebar, MIME-sniffbar und cachebar — während sie eine Kartennummer ausliefert.
Korrigiert: Eine frühere Version dieses README nannte dies „Exploit 2" und behauptete, es sei die
Bedingung, die der Advisory dokumentiert. Es ist nicht Teil von CVE-2026-22732 und kein Upgrade behebt es.
CacheControlHeadersWriter ist byte-identisch in 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (letzte verwundbare) und
6.5.9 (erste behobene) — verifiziert durch Diffen der Sources-Jars. Sein Javadoc nennt das Verhalten
unverblümt: „Inserts headers to prevent caching if no cache control headers have been
specified."
Es ist dennoch wert, demonstriert zu werden, weil das Leck real ist und das Restrisiko das Patchen überlebt.
CacheControlHeadersWriter bricht ab, wenn Cache-Control, Expires oder Pragma bereits
vorhanden ist, sodass das Setzen von irgendeinem einen der drei alle No-Store-Direktiven von Spring
Security unterdrückt. Eine gut gemeinte Zeile genügt:
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 zählt in der obigen Tabelle als „vorhanden", aber mit einem angreiferfreundlichen Wert, den die App
gewählt hat — Spring Securitys Expires: 0 wurde ersetzt, nicht bloß verworfen. Kartendaten sind nun von
jedem Browser und geteilten Proxy auf dem Pfad bis 2099 speicherbar.
Auf dem gepatchten Build wechselt /vuln/content-length/account zu NOT cacheable, während
/vuln/cache/account genau wie oben bleibt. Nur Anwendungscode oder ein Reverse Proxy behebt es —
was das Nützliche ist, das man in einer Demo laut sagen sollte: Das Upgrade der Bibliothek schließt die
CVE und lässt dies unberührt.
Mehrere weit verbreitete Write-ups — einschließlich eines öffentlichen Reproduktions-Repos — listen
response.getOutputStream() und response.flushBuffer() als Auslöser auf und erklären, dass „die Response
committed wird, bevor Spring Security seine Header injizieren kann". Auf Spring Security 5.7.11 ist das
falsch. Beide Endpunkte liefern alle sechs Header.
/diag/committed zeigt, warum die Erklärung nicht hält. Nach einem 12-KB-Schreibvorgang ist die Response wirklich
innerhalb des Controllers committed, dennoch kommen die Header an:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper überschreibt flushBuffer() und die Output-Stream-Schreibvorgänge, sodass er seine
Header vor diesen Commits herausbekommt. Commit-Reihenfolge allein ist nicht der Bug; der Pfad mit deklariertem
Content-Length ist es. Der Spring-Advisory selbst befürwortet nie die Commit-Reihenfolge-Erzählung.
Diese beiden Endpunkte beizubehalten macht das PoC falsifizierbar: Es zeigt, was nicht reproduziert, genauso klar wie das, was es tut, und beide bleiben über den Patch hinweg grün, was den einen Endpunkt, der tatsächlich umschlägt, bedeutsam macht.
Eine Zeile in pom.xml, nichts weiter. Keine Quellcode-Änderung, keine Property-Änderung, kein Spring-Boot-Major-
Bump:
<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>
Das pinnt spring-security.version von 5.7.11 auf 5.7.14-0.cgr.2 neu (und
spring-framework.version von 5.3.31 auf 5.3.39-0.cgr.4) über die
spring-boot-dependencies des Parents. Gemessen:
| 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 (by design) |
exploit.sh verdict | VULNERABLE | PATCHED |
Beim Diffen der spring-security-web-Sources-Jars ist nur eine Datei relevant. 5.7.11 →
5.7.14-0.cgr.2 fügt setHeader / setIntHeader / addIntHeader-Overrides zu
OnCommittedResponseWrapper hinzu, die jeweils Content-Length durch setContentLength() leiten:
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Vor dem Fix tat dies nur addHeader, sodass setIntHeader("Content-Length", n) die verfolgte Länge des
Wrappers bei 0 beließ, onResponseCommitted() nie ausgelöst wurde und HeaderWriterFilter nie seine
Header schrieb, bevor Tomcat die Response committed.
Derselbe Hunk erscheint wortgleich beim Diffen von Upstream 6.5.8 (letzte verwundbare) gegen 6.5.9
(erste behobene), sodass dies der offizielle Fix ist, backportiert, keine Neuimplementierung. Der Chainguard-Build
fügt zwei Null-Guards hinzu, die Upstream 6.5.9 nicht hat (value != null bei der String-Überladung und
(csq != null) ? csq.length() : 4 in append).
Spring Security 5.7.x ist Upstream End-of-Life; OSS-Fixes landen nur in 6.4.15 / 6.5.9 / 7.0.4+. Wenn ein neu gebautes 5.7.x keine Option ist:
HeaderWriterFilter.shouldWriteHeadersEagerly = true über einen
ObjectPostProcessor. Laut Advisory ändert dies das Verhalten: anwendungsgeschriebene Header
überschreiben dann nur bestimmte Header, statt Spring Securitys zu unterdrücken. Dieses behebt auch
/vuln/cache/account, was der Versions-Bump nicht tut.Nichts davon ist in dieses Projekt eingebaut, sodass das verwundbare Verhalten der Standard ist und der
gepatchte Zustand durch die obige einzelne <version>-Änderung erreichbar ist.
Boot 2.7.18 pinnt Tomcat 9.0.83, das grype . mit 34 CVEs (4 Critical) markiert. Alle davon sind
bei 9.0.118 oder darunter behoben, und 9.0.118 ist die neueste 9.0.x-Version — also räumt eine Property die
Menge:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies deklariert jedes tomcat-embed-*-Artefakt über diese einzige Property, sodass
das Überschreiben core, el und websocket zusammen neu pinnt. Verifiziert:
$ 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
Einschränkung: Bleibe auf der 9.0.x-Linie. Tomcat 10+ verschob die Servlet-API nach jakarta.*, während Spring
Framework 5.3 gegen javax.servlet kompiliert, sodass ein 10.x/11.x-Bump zur Laufzeit mit
NoClassDefFoundError bei den Servlet-Typen fehlschlägt.
Derselbe Mechanismus, angewendet auf den Rest von Boot 2.7.18s verwalteten Abhängigkeiten. Keine Major-Version-Bumps und kein Spring Boot-Upgrade:
| Property | Boot 2.7.18 default | Pinned here | Ceiling reason |
|---|---|---|---|
tomcat.version | 9.0.83 | 9.0.118 | latest 9.0.x; 10+ is jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | last OSS 5.3.x on Central |
jackson-bom.version | 2.13.5 | 2.22.2 | latest 2.x |
log4j2.version | 2.17.2 | 2.26.1 | latest 2.x |
snakeyaml.version | 1.30 | 1.33 | last 1.x; fix for the remaining CVE is 2.0 |
logback.version | 1.2.12 | 1.2.13 | last 1.2.x — see below |
spring-security.version | 5.7.11 | left alone | it is the subject of the demo |
Diese Overrides interagieren mit dem gepatchten Parent, also wisse, was sie tun, bevor du demonstrierst. Auf
2.7.18-0.cgr.3 liefert der Parent bereits tomcat.version 9.0.118, logback.version 1.2.13 und
snakeyaml.version 1.33 — diese drei Zeilen werden exakte Duplikate und können gelöscht werden, ohne
etwas zu ändern. Die Zeilen jackson-bom.version und log4j2.version tun weiterhin echte Arbeit: Der
gepatchte Parent behält Boots Standard 2.13.5 / 2.17.2, sodass die Overrides gewinnen und diese beiden Abhängigkeiten
auf reine Upstream-Builds statt auf Chainguard-gebaute auflösen. spring-framework.version ist
absichtlich auskommentiert, was das 5.3.39-0.cgr.4 des Parents durchlässt.
grype .-Verlauf| State | Findings | Breakdown |
|---|---|---|
| Stock Boot 2.7.18 | 99 | 7C / 39H / 38M / 15L |
| + Tomcat bump | 65 | 3C / 23H / 29M / 10L |
| + all in-major bumps | 43 | 3C / 12H / 18M / 10L |
Vollständig bereinigt: Tomcat (34), Jackson (7), log4j (1). Insgesamt 99 → 43, Highs 39 → 12.
Nach jedem Bump verifiziert: Die App bootet auf Tomcat/9.0.118, alle sechs Endpunkte liefern 200, und die
CVE reproduziert byte-genau. Spring Boot ist weiterhin 2.7.18 und Spring Security weiterhin 5.7.11, sodass
CVE-2026-22732 unberührt ist — was der Punkt dieses Abschnitts ist und auch seine ehrliche Grenze:
Alles drumherum zu patchen tut nichts für die Application-Framework-CVE. Diese zu beheben erfordert
den Parent-Bump, nicht eine Property.
Das neueste 1.x ist 1.6.3 — gleicher Major, also nominell im Rahmen. Es funktioniert nicht. Logback 1.3+ ersetzte
den SLF4J 1.7 StaticLoggerBinder durch den SLF4J 2.x ServiceLoader-Provider, und Boot 2.7s
LogbackLoggingSystem ruft StaticLoggerBinder direkt auf. Gemessen mit 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)
Dem zu entkommen erfordert SLF4J 2.x (ein Major-Bump) und ein Boot 3.x-Logging-System. 1.2.13 ist die echte Obergrenze und lässt 6 Logback-Findings (2 Medium, 4 Low) auf dieser Linie unbehebbar.
| Component | Why it can't be fixed in-major |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 is the last OSS 5.3.x; 14 of 15 webmvc findings have no fix at all, and the 5.3.42 grype cites is commercial-only |
| logback-core (6) | needs SLF4J 2.x, see above |
| spring-security-* (8) | EOL line; CVE-2026-22732 is deliberate |
| spring-boot / -autoconfigure (3) | no fix published for 2.7.x |
| snakeyaml (1) | CVE-2022-1471 is fixed only in 2.0 |
Zwei der drei verbleibenden Criticals sind es wert, richtig gelesen zu werden statt nach Score:
HttpInvokerServiceExporter. Diese App verwendet keinen HTTP Invoker, also ist es hier nicht erreichbar.5.7.14-0.cgr.2 hinter der Fix-Version liegt.Der Rest ist strukturell: Spring Framework 5.3.x und Spring Security 5.7.x sind beide EOL. Das, nicht Tomcat oder Jackson, ist das echte Argument für eine Boot 3.x-Migration.
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
Auth ist permitAll und CSRF ist aus, damit curl unauthentifiziert funktioniert — beides ist nicht Teil dieser CVE.