
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.
Spring Security verwirft still und heimlich HTTP-Response-Sicherheitsheader. Nur für Demo-/Bildungszwecke; führe es gegen nichts außer dieser lokalen App aus.
| 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: