
Prueba de concepto que demuestra CVE-2026-22732, una falla de Spring Security donde setIntHeader("Content-Length") elimina todos los encabezados de seguridad, con compilaciones vulnerables y parcheadas.
| CVE | CVE-2026-22732 (CWE-425), publicado el 2026-03-19 |
| CVSS 3.1 | 9.1 CRÍTICO — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Dependencia directa | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Componente vulnerable | spring-security-web / -config / -core 5.7.11 — solo transitivo, nunca declarado en pom.xml |
| Componente corregido | spring-security-web 5.7.14-0.cgr.2, alcanzado con un solo cambio de <version> — véase la transición |
| Verificado en | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Rangos afectados: 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 fija Spring Security 5.7.11, justo dentro del primer rango:
$ 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 — compila y arranca en :8080 (fija JDK 17)
./exploit.sh # terminal 2 — recorre todos los endpoints, compara las cabeceras
run.sh fija JAVA_HOME porque Spring Boot 2.7.x no puede ejecutarse sobre el JDK 25 que mvn resuelve por
defecto en esta máquina. Sobrescríbelo con JAVA_HOME_17=/path/to/jdk17.
También compila con -s settings-chainguard.xml por defecto, porque el parent parcheado no está en
Maven Central. Establece MAVEN_SETTINGS=/path/to/your/settings.xml para apuntar a otro sitio, o
MAVEN_SETTINGS= para compilar únicamente desde Central — lo cual solo funciona para el 2.7.18 original.
exploit.sh lee las versiones reales de spring-security-web y spring-boot desde
target/*.jar, de modo que su banner siempre informa de lo que realmente se está ejecutando en lugar de una cadena codificada.
SecurityConfig no aplica ninguna personalización de cabeceras en absoluto — los valores por defecto de
Spring Security están en vigor, que es exactamente en lo que confía una aplicación consciente de la seguridad. Cada endpoint devuelve el mismo
cuerpo sensible:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
Lo único que varía es cómo el controlador escribe la respuesta.
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 de estado cuando se parchea la CVE, por lo que es el único
endpoint del que exploit.sh deriva su veredicto. El resto son controles.
setIntHeader("Content-Length", n) → bypass totalTres líneas de código de controlador de aspecto corriente eliminan todas las cabeceras que Spring Security prometía:
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
Sin X-Frame-Options, sin X-Content-Type-Options, sin Cache-Control, sin Pragma, sin Expires, sin
X-XSS-Protection. Compárese con /safe/account, que las lleva las seis. La respuesta es enmarcable desde cualquier
origen, susceptible de sniffing de MIME y cacheable — mientras sirve un número de tarjeta.
Corregido: una versión anterior de este README llamaba a esto "exploit 2" y afirmaba que era la
condición que documenta el aviso. No forma parte de CVE-2026-22732 y ninguna actualización lo corrige.
CacheControlHeadersWriter es idéntico byte a byte en 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (último vulnerable) y
6.5.9 (primero corregido) — verificado comparando los jars de fuentes. Su Javadoc declara el comportamiento
sin rodeos: "Inserts headers to prevent caching if no cache control headers have been
specified."
Aun así merece la pena demostrarlo, porque la fuga es real y el riesgo residual sobrevive al parcheo.
CacheControlHeadersWriter se detiene si Cache-Control, Expires o Pragma ya están
presentes, de modo que fijar una cualquiera de las tres suprime todas las directivas no-store de
Spring Security. Una línea bienintencionada lo consigue:
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 cuenta como "presente" en la tabla anterior, pero con un valor favorable al atacante elegido por la aplicación —
el Expires: 0 de Spring Security fue reemplazado, no simplemente descartado. Los datos del titular de la tarjeta ahora son almacenables por
todos los navegadores y proxies compartidos en la ruta hasta 2099.
En la compilación parcheada /vuln/content-length/account pasa a NOT cacheable, mientras que
/vuln/cache/account permanece exactamente como arriba. Solo el código de la aplicación o un proxy inverso lo corrige —
que es lo útil que hay que decir en voz alta en una demo: actualizar la biblioteca cierra la CVE y deja
esto intacto.
Varios artículos ampliamente difundidos — incluido un repositorio público de reproducción — listan
response.getOutputStream() y response.flushBuffer() como desencadenantes, explicando que "la respuesta
se confirma antes de que Spring Security pueda inyectar sus cabeceras". En Spring Security 5.7.11 eso es
incorrecto. Ambos endpoints entregan las seis cabeceras.
/diag/committed muestra por qué la explicación no se sostiene. Tras una escritura de 12 KB la respuesta realmente está
confirmada dentro del controlador, y aun así las cabeceras llegan:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper sobrescribe flushBuffer() y las escrituras del flujo de salida, de modo que emite sus
cabeceras antes de esas confirmaciones. El orden de confirmación por sí solo no es el fallo; la ruta de Content-Length
declarado sí lo es. El propio aviso de Spring nunca respalda la historia del orden de confirmación.
Mantener estos dos endpoints hace que la PoC sea falsable: muestra lo que no se reproduce con la misma claridad que lo que sí, y ambos permanecen en verde a través del parche, que es lo que hace que el único endpoint que sí cambia sea significativo.
Una línea en pom.xml, nada más. Sin cambios en el código fuente, sin cambios de propiedades, sin salto de versión mayor 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>
Eso vuelve a fijar spring-security.version de 5.7.11 a 5.7.14-0.cgr.2 (y
spring-framework.version de 5.3.31 a 5.3.39-0.cgr.4) a través del
spring-boot-dependencies del parent. Medido:
| 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 |
Comparando los jars de fuentes de spring-security-web, solo importa un archivo. 5.7.11 →
5.7.14-0.cgr.2 añade las sobrescrituras de setHeader / setIntHeader / addIntHeader a
OnCommittedResponseWrapper, cada una enrutando Content-Length a través de setContentLength():
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Antes de la corrección solo addHeader hacía esto, de modo que setIntHeader("Content-Length", n) dejaba la longitud
rastreada del wrapper en 0, onResponseCommitted() nunca se disparaba, y HeaderWriterFilter nunca escribía sus
cabeceras antes de que Tomcat confirmara la respuesta.
El mismo fragmento aparece literalmente al comparar el upstream 6.5.8 (último vulnerable) con 6.5.9
(primero corregido), así que esta es la corrección oficial portada, no una reimplementación. La compilación de Chainguard
añade dos comprobaciones de nulos que el upstream 6.5.9 no tiene (value != null en la sobrecarga de String, y
(csq != null) ? csq.length() : 4 en append).
Spring Security 5.7.x está al final de su vida útil en upstream; las correcciones OSS llegan solo en 6.4.15 / 6.5.9 / 7.0.4+. Si una 5.7.x recompilada no es una opción:
HeaderWriterFilter.shouldWriteHeadersEagerly = true mediante un
ObjectPostProcessor. Según el aviso esto cambia el comportamiento: las cabeceras escritas por la aplicación entonces
sobrescriben solo cabeceras específicas en lugar de suprimir las de Spring Security. Este también corrige
/vuln/cache/account, cosa que el salto de versión no hace.Ninguna de estas está integrada en este proyecto, por lo que el comportamiento vulnerable es el predeterminado y el
estado parcheado es alcanzable con el único cambio de <version> anterior.
Boot 2.7.18 fija Tomcat 9.0.83, que grype . marca con 34 CVEs (4 Críticas). Todas ellas están
corregidas en 9.0.118 o inferior, y 9.0.118 es la versión más reciente de la línea 9.0.x — así que una sola propiedad limpia el conjunto:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies declara cada artefacto tomcat-embed-* a través de esa única propiedad, de modo que
sobrescribirla vuelve a fijar core, el y websocket juntos. Verificado:
$ 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
Restricción: mantente en la línea 9.0.x. Tomcat 10+ movió la API de Servlet a jakarta.* mientras que Spring
Framework 5.3 compila contra javax.servlet, por lo que un salto a 10.x/11.x falla en tiempo de ejecución con
NoClassDefFoundError en los tipos de servlet.
El mismo mecanismo aplicado al resto de las dependencias gestionadas de Boot 2.7.18. Sin saltos de versión mayor, y sin actualización de Spring Boot:
| Propiedad | Valor por defecto de Boot 2.7.18 | Fijado aquí | Motivo del techo |
|---|---|---|---|
tomcat.version | 9.0.83 | 9.0.118 | última 9.0.x; 10+ es jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | última 5.3.x OSS en Central |
jackson-bom.version | 2.13.5 | 2.22.2 | última 2.x |
log4j2.version | 2.17.2 | 2.26.1 | última 2.x |
snakeyaml.version | 1.30 | 1.33 | última 1.x; la corrección para la CVE restante es 2.0 |
logback.version | 1.2.12 | 1.2.13 | última 1.2.x — véase más abajo |
spring-security.version | 5.7.11 | sin tocar | es el objeto de la demo |
Estas sobrescrituras interactúan con el parent parcheado, así que conoce lo que hacen antes de hacer la demo. En
2.7.18-0.cgr.3 el parent ya proporciona tomcat.version 9.0.118, logback.version 1.2.13 y
snakeyaml.version 1.33 — esas tres filas se vuelven duplicados exactos y pueden eliminarse sin
cambiar nada. Las filas de jackson-bom.version y log4j2.version siguen haciendo trabajo real: el
parent parcheado mantiene los valores originales de Boot 2.13.5 / 2.17.2, así que las sobrescrituras ganan y esas dos dependencias
se resuelven a compilaciones upstream normales en lugar de las construidas por Chainguard. spring-framework.version está
comentada a propósito, que es lo que deja pasar el 5.3.39-0.cgr.4 del parent.
grype .| Estado | Hallazgos | Desglose |
|---|---|---|
| Boot 2.7.18 original | 99 | 7C / 39H / 38M / 15L |
| + salto de Tomcat | 65 | 3C / 23H / 29M / 10L |
| + todos los saltos dentro de la línea mayor | 43 | 3C / 12H / 18M / 10L |
Totalmente limpiados: Tomcat (34), Jackson (7), log4j (1). En conjunto 99 → 43, Altas 39 → 12.
Verificado tras cada salto: la aplicación arranca en Tomcat/9.0.118, los seis endpoints devuelven 200, y la
CVE se reproduce byte a byte. Spring Boot sigue siendo 2.7.18 y Spring Security sigue siendo 5.7.11, así que
CVE-2026-22732 no se toca — que es el objetivo de esta sección, y también su límite honesto:
parchear todo lo que la rodea no hace nada por la CVE del framework de aplicación. Corregir esa requiere
el salto del parent, no una propiedad.
La última 1.x es 1.6.3 — misma versión mayor, así que nominalmente dentro del alcance. No funciona. Logback 1.3+ reemplazó
el StaticLoggerBinder de SLF4J 1.7 por el proveedor ServiceLoader de SLF4J 2.x, y el
LogbackLoggingSystem de Boot 2.7 llama directamente a StaticLoggerBinder. Medido con 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)
Escapar de esto requiere SLF4J 2.x (un salto de versión mayor) y un sistema de logging de Boot 3.x. 1.2.13 es el techo real, dejando 6 hallazgos de Logback (2 Medios, 4 Bajos) imposibles de corregir en esta línea.
| Componente | Por qué no puede corregirse dentro de la línea mayor |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 es la última 5.3.x OSS; 14 de 15 hallazgos de webmvc no tienen corrección alguna, y la 5.3.42 que cita grype es solo comercial |
| logback-core (6) | necesita SLF4J 2.x, véase arriba |
| spring-security-* (8) | línea EOL; CVE-2026-22732 es deliberada |
| spring-boot / -autoconfigure (3) | no hay corrección publicada para 2.7.x |
| snakeyaml (1) | CVE-2022-1471 solo se corrige en 2.0 |
Dos de las tres Críticas restantes merecen leerse bien en lugar de por puntuación:
HttpInvokerServiceExporter. Esta aplicación no usa HTTP Invoker, así que no es alcanzable aquí.5.7.14-0.cgr.2 supera la versión de corrección.El residuo es estructural: Spring Framework 5.3.x y Spring Security 5.7.x están ambos EOL. Eso, no Tomcat ni Jackson, es el verdadero argumento para una migración 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
La autenticación es permitAll y CSRF está desactivado para que curl funcione sin autenticar — ninguna de las dos cosas forma parte de esta CVE.