
Proof-of-concept che dimostra CVE-2026-22732, una vulnerabilità di Spring Security in cui setIntHeader("Content-Length") elimina tutti gli header di sicurezza, con build vulnerabili e corrette.
Spring Security elimina silenziosamente gli header di sicurezza delle risposte HTTP. Solo per uso dimostrativo/educativo; eseguilo solo contro questa app locale.
| CVE | CVE-2026-22732 (CWE-425), pubblicata il 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 |
| Dipendenza diretta | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Componente vulnerabile | spring-security-web / -config / -core 5.7.11 — solo transitiva, mai dichiarata nel pom.xml |
| Componente corretta | spring-security-web 5.7.14-0.cgr.2, raggiunta con un solo cambio di <version> — vedi la transizione |
| Verificato su | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Intervalli interessati: 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 fissa Spring Security 5.7.11, pienamente all'interno del primo intervallo:
$ 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 # terminale 1 — compila e avvia su :8080 (fissa JDK 17)
./exploit.sh # terminale 2 — interroga ogni endpoint, confronta gli header
run.sh fissa JAVA_HOME perché Spring Boot 2.7.x non può girare sul JDK 25 che mvn risolve per
default su questa macchina. Sovrascrivi con JAVA_HOME_17=/path/to/jdk17.
Compila anche con -s settings-chainguard.xml per default, perché il parent corretto non è su
Maven Central. Imposta MAVEN_SETTINGS=/path/to/your/settings.xml per puntare altrove, oppure
MAVEN_SETTINGS= per compilare esclusivamente da Central — cosa che funziona solo con il 2.7.18 originale.
exploit.sh legge le versioni effettive di spring-security-web e spring-boot da
target/*.jar, quindi il suo banner riporta sempre ciò che è realmente in esecuzione anziché una stringa hardcoded.
SecurityConfig non applica nessuna personalizzazione degli header — sono in vigore i default di
Spring Security, che è esattamente ciò su cui si basa un'app attenta alla sicurezza. Ogni endpoint restituisce lo stesso
body sensibile:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
L'unica cosa che varia è come il controller scrive la risposta.
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
Solo /vuln/content-length/account cambia stato quando la CVE viene corretta, quindi è l'unico
endpoint da cui exploit.sh deriva il suo verdetto. Gli altri sono controlli.
setIntHeader("Content-Length", n) → bypass totaleTre righe di codice del controller dall'aspetto ordinario eliminano ogni header che Spring Security aveva promesso:
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
Nessun X-Frame-Options, nessun X-Content-Type-Options, nessun Cache-Control, nessun Pragma, nessun Expires, nessun
X-XSS-Protection. Confronta con /safe/account, che li porta tutti e sei. La risposta è incorniciabile da qualsiasi
origine, MIME-sniffable e cacheable — mentre serve un numero di carta.
Corretto: una versione precedente di questo README lo chiamava "exploit 2" e sosteneva fosse la
condizione documentata nell'advisory. Non fa parte di CVE-2026-22732 e nessun aggiornamento lo corregge.
CacheControlHeadersWriter è identico byte per byte in 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (ultima vulnerabile) e
6.5.9 (prima corretta) — verificato confrontando i sources jar. Il suo Javadoc afferma esplicitamente il comportamento:
"Inserts headers to prevent caching if no cache control headers have been
specified."
Vale comunque la pena dimostrarlo, perché la fuga è reale e il rischio residuo sopravvive alla patch.
CacheControlHeadersWriter si ferma se Cache-Control, Expires o Pragma è già
presente, quindi impostare uno qualsiasi dei tre sopprime tutte le direttive no-store di Spring Security.
Una sola riga ben intenzionata lo fa:
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 conta come "presente" nella tabella sopra, ma con un valore favorevole all'attaccante scelto dall'app —
l'Expires: 0 di Spring Security è stato sostituito, non semplicemente eliminato. I dati del titolare della carta sono ora memorizzabili da
ogni browser e proxy condiviso sul percorso fino al 2099.
Sulla build corretta /vuln/content-length/account passa a NOT cacheable, mentre
/vuln/cache/account resta esattamente come sopra. Solo il codice dell'applicazione o un reverse proxy lo corregge —
che è la cosa utile da dire ad alta voce in una demo: aggiornare la libreria chiude la CVE e lascia
questo intatto.
Diversi write-up ampiamente diffusi — incluso un repo pubblico di riproduzione — elencano
response.getOutputStream() e response.flushBuffer() come trigger, spiegando che "la risposta
è committed prima che Spring Security possa iniettare i suoi header". Su Spring Security 5.7.11 questo è
sbagliato. Entrambi gli endpoint consegnano tutti e sei gli header.
/diag/committed mostra perché la spiegazione non regge. Dopo una scrittura di 12 KB la risposta è davvero
committed all'interno del controller, eppure gli header arrivano comunque:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper sovrascrive flushBuffer() e le scritture dell'output-stream, quindi emette i suoi
header prima di quei commit. Il solo ordinamento dei commit non è il bug; il percorso con Content-Length
dichiarato lo è. L'advisory di Spring stesso non avalla mai la storia dell'ordinamento dei commit.
Mantenere questi due endpoint rende il PoC falsificabile: mostra ciò che non si riproduce con la stessa chiarezza di ciò che si riproduce, ed entrambi restano verdi attraverso la patch, che è ciò che rende significativo l'unico endpoint che effettivamente cambia.
Una riga nel pom.xml, nient'altro. Nessuna modifica al sorgente, nessuna modifica alle proprietà, nessun bump di major
di Spring Boot: