
Preuve de concept démontrant CVE-2026-22732, une faille de Spring Security où setIntHeader("Content-Length") supprime tous les en-têtes de sécurité, avec des builds vulnérables et corrigés.
Spring Security supprime silencieusement les en-têtes de sécurité des réponses HTTP. Démo / usage éducatif uniquement ; ne l'exécutez contre rien d'autre que cette application locale.
| CVE | CVE-2026-22732 (CWE-425), publiée le 2026-03-19 |
| CVSS 3.1 | 9.1 CRITIQUE — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Dépendance directe | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Composant vulnérable | spring-security-web / -config / -core 5.7.11 — transitif uniquement, jamais nommé dans pom.xml |
| Composant corrigé | spring-security-web 5.7.14-0.cgr.2, atteint par un seul changement de <version> — voir la transition |
| Vérifié sur | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Plages affectées : 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 épingle Spring Security 5.7.11, en plein dans la première plage :
$ 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 — builds and starts on :8080 (pins JDK 17)
./exploit.sh # terminal 2 — drives every endpoint, diffs the headers
run.sh épingle JAVA_HOME car Spring Boot 2.7.x ne peut pas s'exécuter sur le JDK 25 que mvn résout par
défaut sur cette machine. Remplacez avec JAVA_HOME_17=/path/to/jdk17.
Il compile aussi avec -s settings-chainguard.xml par défaut, car le parent corrigé n'est pas sur
Maven Central. Définissez MAVEN_SETTINGS=/path/to/your/settings.xml pour pointer ailleurs, ou
MAVEN_SETTINGS= pour compiler uniquement depuis Central — ce qui ne fonctionne que pour le 2.7.18 d'origine.
exploit.sh lit les versions réelles de spring-security-web et spring-boot depuis
target/*.jar, donc sa bannière rapporte toujours ce qui s'exécute réellement plutôt qu'une chaîne codée en dur.
SecurityConfig n'applique aucune personnalisation d'en-tête — les valeurs par défaut de Spring Security sont en
vigueur, ce qui est exactement ce sur quoi une application soucieuse de la sécurité s'appuie. Chaque endpoint renvoie le même
corps sensible :
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
La seule chose qui varie est la façon dont le contrôleur écrit la réponse.
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
Seul /vuln/content-length/account change d'état lorsque la CVE est corrigée, c'est donc le seul
endpoint dont exploit.sh tire son verdict. Les autres sont des contrôles.
setIntHeader("Content-Length", n) → contournement totalTrois lignes de code de contrôleur d'apparence ordinaire suppriment tous les en-têtes que Spring Security promettait :
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
Pas de X-Frame-Options, pas de X-Content-Type-Options, pas de Cache-Control, pas de Pragma, pas d'Expires, pas de
X-XSS-Protection. Comparez avec /safe/account, qui porte les six. La réponse est encadrable par n'importe quelle
origine, sensible au reniflage MIME et cachable — tout en servant un numéro de carte.
Corrigé : une version antérieure de ce README appelait cela « exploit 2 » et affirmait qu'il s'agissait de la
condition documentée par l'avis. Cela ne fait pas partie de CVE-2026-22732 et aucune mise à niveau ne le corrige.
CacheControlHeadersWriter est identique octet pour octet en 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (dernière vulnérable) et
6.5.9 (première corrigée) — vérifié en comparant les jars de sources. Sa Javadoc énonce le comportement
sans détour : « Insère des en-têtes pour empêcher la mise en cache si aucun en-tête de contrôle du cache n'a été
spécifié. »
Cela vaut quand même la peine d'être démontré, car la fuite est réelle et le risque résiduel survit au correctif.
CacheControlHeadersWriter abandonne si Cache-Control, Expires ou Pragma est déjà
présent, donc définir l'un des trois supprime toutes les directives no-store de Spring Security.
Une seule ligne bien intentionnée suffit :
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 compte comme « présent » dans le tableau ci-dessus, mais avec une valeur favorable à l'attaquant choisie par l'application —
l'Expires: 0 de Spring Security a été remplacé, pas simplement supprimé. Les données du porteur de carte sont désormais stockables par
chaque navigateur et proxy partagé sur le chemin jusqu'en 2099.
Sur la version corrigée, /vuln/content-length/account bascule vers NOT cacheable, tandis que
/vuln/cache/account reste exactement comme ci-dessus. Seul le code applicatif ou un reverse proxy le corrige —
ce qui est la chose utile à dire à voix haute dans une démo : mettre à niveau la bibliothèque ferme la CVE et laisse
ceci intact.
Plusieurs articles largement diffusés — y compris un dépôt de reproduction public — listent
response.getOutputStream() et response.flushBuffer() comme déclencheurs, expliquant que « la réponse
est validée avant que Spring Security puisse injecter ses en-têtes ». Sur Spring Security 5.7.11, c'est
faux. Les deux endpoints délivrent les six en-têtes.
/diag/committed montre pourquoi l'explication ne tient pas. Après une écriture de 12 Ko, la réponse est réellement
validée à l'intérieur du contrôleur, et pourtant les en-têtes arrivent toujours :
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper redéfinit flushBuffer() et les écritures du flux de sortie, donc il obtient ses
en-têtes avant ces validations. L'ordre de validation seul n'est pas le bug ; le chemin avec Content-Length
déclaré l'est. L'avis Spring lui-même ne cautionne jamais l'histoire de l'ordre de validation.
Conserver ces deux endpoints rend la PoC falsifiable : elle montre ce qui ne se reproduit pas aussi clairement que ce qui se reproduit, et les deux restent verts à travers le correctif, ce qui rend le seul endpoint qui bascule significatif.
Une ligne dans pom.xml, rien d'autre. Aucun changement de source, aucun changement de propriété, aucune montée de version majeure de Spring Boot :