
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.
| 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:
<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>
Questo ri-fissa spring-security.version da 5.7.11 a 5.7.14-0.cgr.2 (e
spring-framework.version da 5.3.31 a 5.3.39-0.cgr.4) tramite le
spring-boot-dependencies del parent. Misurato:
| 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) |
verdetto di exploit.sh | VULNERABLE | PATCHED |
Confrontando i sources jar di spring-security-web, conta un solo file. 5.7.11 →
5.7.14-0.cgr.2 aggiunge gli override setHeader / setIntHeader / addIntHeader a
OnCommittedResponseWrapper, ciascuno instrada Content-Length attraverso setContentLength():
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Prima della correzione solo addHeader faceva questo, quindi setIntHeader("Content-Length", n) lasciava la lunghezza tracciata dal wrapper
a 0, onResponseCommitted() non scattava mai, e HeaderWriterFilter non scriveva mai i suoi
header prima che Tomcat facesse il commit della risposta.
Lo stesso hunk appare identico confrontando l'upstream 6.5.8 (ultima vulnerabile) con 6.5.9
(prima corretta), quindi questa è la correzione ufficiale backportata, non una reimplementazione. La build Chainguard
aggiunge due null-guard che l'upstream 6.5.9 non ha (value != null sull'overload String, e
(csq != null) ? csq.length() : 4 in append).
Spring Security 5.7.x è end-of-life upstream; le correzioni OSS arrivano solo in 6.4.15 / 6.5.9 / 7.0.4+. Se una 5.7.x ricompilata non è un'opzione:
HeaderWriterFilter.shouldWriteHeadersEagerly = true tramite un
ObjectPostProcessor. Secondo l'advisory questo cambia il comportamento: gli header scritti dall'applicazione
sovrascrivono poi solo header specifici anziché sopprimere quelli di Spring Security. Questo corregge anche
/vuln/cache/account, cosa che il bump di versione non fa.Nessuna di queste è cablata in questo progetto, quindi il comportamento vulnerabile è il default e lo
stato corretto è raggiungibile con il singolo cambio di <version> sopra.
Boot 2.7.18 fissa Tomcat 9.0.83, che grype . segnala con 34 CVE (4 Critical). Tutte sono
corrette alla 9.0.118 o inferiore, e la 9.0.118 è la release 9.0.x più recente — quindi una sola proprietà le elimina tutte:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies dichiara ogni artefatto tomcat-embed-* attraverso quella singola proprietà, quindi
sovrascriverla ri-fissa core, el e websocket insieme. Verificato:
$ 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
Vincolo: resta sulla linea 9.0.x. Tomcat 10+ ha spostato la Servlet API su jakarta.* mentre Spring
Framework 5.3 compila contro javax.servlet, quindi un bump a 10.x/11.x fallisce a runtime con
NoClassDefFoundError sui tipi servlet.
Stesso meccanismo applicato al resto delle dipendenze gestite di Boot 2.7.18. Nessun bump di major-version, e nessun aggiornamento di Spring Boot:
| Proprietà | Default Boot 2.7.18 | Fissata qui | Motivo del tetto |
|---|---|---|---|
tomcat.version | 9.0.83 | 9.0.118 | ultima 9.0.x; 10+ è jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | ultima 5.3.x OSS su Central |
jackson-bom.version | 2.13.5 | 2.22.2 | ultima 2.x |
log4j2.version | 2.17.2 | 2.26.1 | ultima 2.x |
snakeyaml.version | 1.30 | 1.33 | ultima 1.x; la correzione per la CVE rimanente è 2.0 |
logback.version | 1.2.12 | 1.2.13 | ultima 1.2.x — vedi sotto |
spring-security.version | 5.7.11 | lasciata invariata | è l'oggetto della demo |
Questi override interagiscono con il parent corretto, quindi sappi cosa fanno prima di fare la demo. Su
2.7.18-0.cgr.3 il parent fornisce già tomcat.version 9.0.118, logback.version 1.2.13 e
snakeyaml.version 1.33 — quelle tre righe diventano duplicati esatti e possono essere eliminate senza
cambiare nulla. Le righe jackson-bom.version e log4j2.version fanno ancora lavoro reale: il
parent corretto mantiene le 2.13.5 / 2.17.2 stock di Boot, quindi gli override vincono e quelle due dipendenze
si risolvono su build upstream normali anziché su quelle costruite da Chainguard. spring-framework.version è
commentata di proposito, ed è ciò che lascia passare la 5.3.39-0.cgr.4 del parent.
grype .| Stato | Risultati | Ripartizione |
|---|---|---|
| Boot 2.7.18 stock | 99 | 7C / 39H / 38M / 15L |
| + bump Tomcat | 65 | 3C / 23H / 29M / 10L |
| + tutti i bump in-major | 43 | 3C / 12H / 18M / 10L |
Completamente risolte: Tomcat (34), Jackson (7), log4j (1). Complessivamente 99 → 43, High 39 → 12.
Verificato dopo ogni bump: l'app si avvia su Tomcat/9.0.118, tutti e sei gli endpoint restituiscono 200, e la
CVE si riproduce byte per byte. Spring Boot è ancora 2.7.18 e Spring Security ancora 5.7.11, quindi
CVE-2026-22732 è intatta — che è il punto di questa sezione, e anche il suo onesto limite:
correggere tutto ciò che la circonda non fa nulla per la CVE dell'application-framework. Correggere quella richiede
il bump del parent, non una proprietà.
L'ultima 1.x è la 1.6.3 — stessa major, quindi nominalmente in ambito. Non funziona. Logback 1.3+ ha sostituito
lo StaticLoggerBinder di SLF4J 1.7 con il provider ServiceLoader di SLF4J 2.x, e il
LogbackLoggingSystem di Boot 2.7 chiama StaticLoggerBinder direttamente. Misurato con la 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)
Uscire da questo richiede SLF4J 2.x (un bump di major) e un logging system di Boot 3.x. La 1.2.13 è il vero tetto, lasciando 6 risultati Logback (2 Medium, 4 Low) non correggibili su questa linea.
| Componente | Perché non può essere corretto in-major |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 è l'ultima 5.3.x OSS; 14 dei 15 risultati webmvc non hanno alcuna correzione, e la 5.3.42 citata da grype è solo commerciale |
| logback-core (6) | richiede SLF4J 2.x, vedi sopra |
| spring-security-* (8) | linea EOL; CVE-2026-22732 è deliberata |
| spring-boot / -autoconfigure (3) | nessuna correzione pubblicata per 2.7.x |
| snakeyaml (1) | CVE-2022-1471 è corretta solo nella 2.0 |
Due delle tre Critical rimanenti meritano una lettura attenta anziché basata sul punteggio:
HttpInvokerServiceExporter. Questa app non usa HTTP Invoker, quindi non è raggiungibile qui.5.7.14-0.cgr.2 è oltre la versione di correzione.Il residuo è strutturale: Spring Framework 5.3.x e Spring Security 5.7.x sono entrambe EOL. Questo, non Tomcat o Jackson, è il vero argomento per una migrazione a 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
L'autenticazione è permitAll e CSRF è disattivato così curl funziona senza autenticazione — nessuno dei due fa parte di questa CVE.