
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.
| 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 :
<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>
Cela ré-épingle spring-security.version de 5.7.11 à 5.7.14-0.cgr.2 (et
spring-framework.version de 5.3.31 à 5.3.39-0.cgr.4) via le
spring-boot-dependencies du parent. Mesuré :
| 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 |
En comparant les jars de sources de spring-security-web, un seul fichier compte. 5.7.11 →
5.7.14-0.cgr.2 ajoute les redéfinitions setHeader / setIntHeader / addIntHeader à
OnCommittedResponseWrapper, chacune routant Content-Length via setContentLength() :
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Avant le correctif, seul addHeader faisait cela, donc setIntHeader("Content-Length", n) laissait la longueur suivie par le wrapper à
0, onResponseCommitted() ne se déclenchait jamais, et HeaderWriterFilter n'écrivait jamais ses
en-têtes avant que Tomcat ne valide la réponse.
Le même bloc apparaît mot pour mot en comparant l'amont 6.5.8 (dernière vulnérable) avec 6.5.9
(première corrigée), donc il s'agit du correctif officiel rétroporté, pas d'une réimplémentation. La version Chainguard
ajoute deux gardes de nullité que l'amont 6.5.9 n'a pas (value != null sur la surcharge String, et
(csq != null) ? csq.length() : 4 dans append).
Spring Security 5.7.x est en fin de vie en amont ; les correctifs OSS n'arrivent que dans 6.4.15 / 6.5.9 / 7.0.4+. Si une version 5.7.x reconstruite n'est pas une option :
HeaderWriterFilter.shouldWriteHeadersEagerly = true via un
ObjectPostProcessor. Selon l'avis, cela change le comportement : les en-têtes écrits par l'application
ne remplacent alors que des en-têtes spécifiques plutôt que de supprimer ceux de Spring Security. Celui-ci corrige aussi
/vuln/cache/account, ce que la montée de version ne fait pas.Aucune de ces options n'est câblée dans ce projet, donc le comportement vulnérable est la valeur par défaut et
l'état corrigé est atteignable par le seul changement de <version> ci-dessus.
Boot 2.7.18 épingle Tomcat 9.0.83, que grype . signale avec 34 CVE (4 Critiques). Toutes sont
corrigées en 9.0.118 ou en dessous, et 9.0.118 est la dernière version 9.0.x — donc une seule propriété règle l'ensemble :
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies déclare chaque artefact tomcat-embed-* via cette seule propriété, donc
la remplacer ré-épingle core, el et websocket ensemble. Vérifié :
$ 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
Contrainte : restez sur la ligne 9.0.x. Tomcat 10+ a déplacé l'API Servlet vers jakarta.* tandis que Spring
Framework 5.3 compile contre javax.servlet, donc une montée en 10.x/11.x échoue à l'exécution avec
NoClassDefFoundError sur les types servlet.
Même mécanisme appliqué au reste des dépendances gérées de Boot 2.7.18. Aucune montée de version majeure, et aucune mise à niveau de Spring Boot :
| Propriété | Défaut Boot 2.7.18 | Épinglé ici | Raison du plafond |
|---|---|---|---|
tomcat.version | 9.0.83 | 9.0.118 | dernière 9.0.x ; 10+ est jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | dernière 5.3.x OSS sur Central |
jackson-bom.version | 2.13.5 | 2.22.2 | dernière 2.x |
log4j2.version | 2.17.2 | 2.26.1 | dernière 2.x |
snakeyaml.version | 1.30 | 1.33 | dernière 1.x ; le correctif pour la CVE restante est 2.0 |
logback.version | 1.2.12 | 1.2.13 | dernière 1.2.x — voir ci-dessous |
spring-security.version | 5.7.11 | laissée telle quelle | c'est le sujet de la démo |
Ces remplacements interagissent avec le parent corrigé, donc sachez ce qu'ils font avant de faire la démo. Sur
2.7.18-0.cgr.3, le parent fournit déjà tomcat.version 9.0.118, logback.version 1.2.13 et
snakeyaml.version 1.33 — ces trois lignes deviennent des doublons exacts et peuvent être supprimées sans
rien changer. Les lignes jackson-bom.version et log4j2.version font encore un vrai travail : le
parent corrigé conserve les 2.13.5 / 2.17.2 d'origine de Boot, donc les remplacements l'emportent et ces deux dépendances
se résolvent en versions amont pures plutôt qu'en versions construites par Chainguard. spring-framework.version est
commentée à dessein, ce qui laisse passer le 5.3.39-0.cgr.4 du parent.
grype .| État | Constats | Répartition |
|---|---|---|
| Boot 2.7.18 d'origine | 99 | 7C / 39H / 38M / 15L |
| + montée Tomcat | 65 | 3C / 23H / 29M / 10L |
| + toutes les montées dans la majeure | 43 | 3C / 12H / 18M / 10L |
Entièrement nettoyés : Tomcat (34), Jackson (7), log4j (1). Globalement 99 → 43, Highs 39 → 12.
Vérifié après chaque montée : l'application démarre sur Tomcat/9.0.118, les six endpoints renvoient 200, et la
CVE se reproduit octet pour octet. Spring Boot est toujours 2.7.18 et Spring Security toujours 5.7.11, donc
CVE-2026-22732 est intacte — ce qui est le but de cette section, et aussi sa limite honnête :
corriger tout ce qui l'entoure ne fait rien pour la CVE du framework applicatif. Corriger celle-là nécessite
la montée du parent, pas une propriété.
La dernière 1.x est 1.6.3 — même majeure, donc nominalement dans le périmètre. Cela ne fonctionne pas. Logback 1.3+ a remplacé
le StaticLoggerBinder de SLF4J 1.7 par le fournisseur ServiceLoader de SLF4J 2.x, et le
LogbackLoggingSystem de Boot 2.7 appelle StaticLoggerBinder directement. Mesuré avec 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)
S'échapper de cela nécessite SLF4J 2.x (une montée majeure) et un système de journalisation Boot 3.x. 1.2.13 est le vrai plafond, laissant 6 constats Logback (2 Moyens, 4 Faibles) non corrigeables sur cette ligne.
| Composant | Pourquoi il ne peut pas être corrigé dans la majeure |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 est la dernière 5.3.x OSS ; 14 des 15 constats webmvc n'ont aucun correctif du tout, et la 5.3.42 que cite grype est commerciale uniquement |
| logback-core (6) | nécessite SLF4J 2.x, voir ci-dessus |
| spring-security-* (8) | ligne en fin de vie ; CVE-2026-22732 est délibérée |
| spring-boot / -autoconfigure (3) | aucun correctif publié pour 2.7.x |
| snakeyaml (1) | CVE-2022-1471 n'est corrigée qu'en 2.0 |
Deux des trois Critiques restants méritent d'être lus correctement plutôt que par score :
HttpInvokerServiceExporter. Cette application n'utilise pas HTTP Invoker, donc ce n'est pas atteignable ici.5.7.14-0.cgr.2 dépasse la version du correctif.Le résidu est structurel : Spring Framework 5.3.x et Spring Security 5.7.x sont tous deux en fin de vie. C'est cela, pas Tomcat ou Jackson, le véritable argument pour une migration vers 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'authentification est permitAll et CSRF est désactivé pour que curl fonctionne sans authentification — ni l'un ni l'autre ne fait partie de cette CVE.