
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.
Spring Security descarta silenciosamente las cabeceras de seguridad de las respuestas HTTP. Solo para uso demostrativo/educativo; ejecútalo únicamente contra esta aplicación local.
| 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: