Un Web Application Firewall es un motor de inspección de cuerpo. Lo pones delante de una aplicación porque lee el cuerpo de la petición y decide si la deja pasar. Cualquier cosa que permita que el cuerpo se salte la inspección es, por definición, un bypass del WAF.
CVE-2026-44982 fue ese tipo de bypass en el componente AppSec de CrowdSec. Los mismos bytes de payload. El mismo endpoint objetivo. Un cambio de cabecera de Content-Length a Transfer-Encoding: chunked hace que la respuesta pase de 403 Forbidden a 200 OK y deja el payload aterrizando en la aplicación intacto.
CrowdSec es una plataforma de seguridad open source que trae un WAF embebido llamado AppSec, montado encima de Coraza. La mayoría de despliegues ponen un bouncer (nginx, OpenResty, Traefik) delante de la aplicación, el bouncer espeja cada petición hacia el listener de AppSec, AppSec ejecuta las reglas de Coraza y le dice al bouncer si permite o bloquea. Las versiones 1.5.0 hasta la 1.7.7 incluida de CrowdSec llevaron un bug en el manejo del cuerpo dentro del listener de AppSec que hacía que cualquier regla de Coraza que mirase el cuerpo de la petición fallase en silencio cuando la petición no llevaba un Content-Length positivo. La 1.7.8 trae el parche.
La función vulnerable es NewParsedRequestFromRequest en pkg/appsec/request.go. Las líneas relevantes del código fuente de 1.7.7:
func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry) (ParsedRequest, error) {
var err error
contentLength := max(r.ContentLength, 0)
body := make([]byte, contentLength)
if r.Body != nil {
_, err = io.ReadFull(r.Body, body)
if err != nil {
return ParsedRequest{}, fmt.Errorf("unable to read body: %s", err)
}
}
// ... cuerpo entregado a Coraza ...
}El net/http de Go rellena r.ContentLength a partir del framing de la petición. Para peticiones HTTP/1.1 con cabecera Content-Length, el valor es el entero que la cabecera nombra. Para peticiones HTTP/1.1 que usan Transfer-Encoding: chunked en su lugar, y para peticiones HTTP/2 que llegan sin pseudo-cabecera content-length, r.ContentLength vale -1. Content-Length es una forma de framear el cuerpo (RFC 9112), chunked es la otra, y las dos son válidas (RFC 9113 §8.1.1). La API de Go usa -1 como el centinela "todavía no sé cuánto mide el cuerpo".
max(-1, 0) es 0. El slice subyacente tiene longitud cero. Se le pide a io.ReadFull que lea cero bytes de r.Body, lo cual satisface inmediatamente sin drenar el stream. El array de bytes que se entrega a Coraza para la inspección de cuerpo está vacío. Cualquier regla que apunte a REQUEST_BODY, BODY_ARGS, ARGS_POST, JSON o XML evalúa contra nada y no produce hit. Coraza devuelve allow. El bouncer reenvía la petición original, framing y cuerpo intactos, al backend.
No hay entrada en el log. Desde el punto de vista del operador, la petición se escaneó y se aprobó.
La versión visceral del bypass. Stack: el ejemplo docker-compose del repo maxlerebourg/crowdsec-bouncer-traefik-plugin, actualizado a crowdsec:v1.7.7 y plugin v1.5.0, con la colección por defecto appsec-virtual-patching. La regla a disparar es crowdsecurity/vpatch-CVE-2021-3129, el virtual patch del RCE de Laravel Ignition, que matchea cuando la URL termina en /_ignition/execute-solution y el cuerpo contiene php:// o phar://.
Payload (186 bytes):
{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"username","viewFile":"php://filter/convert.base64-encode/resource=/etc/passwd"}}Caso A (Content-Length, bloqueado):
curl -sS -i -X POST "http://127.0.0.1:18000/foo/_ignition/execute-solution" \
-H "Content-Type: application/json" \
--data "$BODY"
# → HTTP/1.1 403 ForbiddenCaso B (Transfer-Encoding: chunked, bypaseado):
printf '%s' "$BODY" | curl -sS -i -X POST "http://127.0.0.1:18000/foo/_ignition/execute-solution" \
-H "Content-Type: application/json" \
-H "Transfer-Encoding: chunked" \
--data-binary @-
# → HTTP/1.1 200 OK (el handler del backend que hace echo de la petición se ejecutó)Los mismos bytes de payload. La única diferencia entre las dos peticiones en el cable es si el cliente frameó el cuerpo con Content-Length o con chunked. El WAF para la primera. El WAF no ve la segunda.
El fix de 1.7.8 sustituye la línea de dimensionado del buffer por un helper readRequestBody. Extracto:
func readRequestBody(r *http.Request, bodySettings BodySettings, logger *log.Entry) (body []byte, truncated, exceeded bool, err error) {
if r.Body == nil {
return nil, false, false, nil
}
maxSize := bodySettings.MaxSize
if maxSize <= 0 {
maxSize = DefaultMaxBodySize
}
// lectura acotada vía io.LimitReader; un timeout de lectura se
// trata como fin de cuerpo para que un cliente lento no atasque
// la petición entera.
...
}El helper lee hasta bodySettings.MaxSize del stream sin importar lo que declare r.ContentLength, y aplica una acción configurable por el operador (truncar o rechazar) cuando el cuerpo supera el límite. La release 1.7.8 expone BodySettings como una nueva sección de configuración. El bug de una sola línea para dimensionar al principio del fichero se sustituye por una función cuyo nombre describe lo que hace.
El CVE publicado puntúa el aviso en CVSS 7.2 (S:C, C:L I:L A:N) bajo CWE-693, Fallo de Mecanismo de Protección. La ficha completa está en la página de CVE-2026-44982. La puntuación asume que el listener de AppSec es alcanzable a través de un bouncer, que es la forma de despliegue por defecto. El impacto operativo real depende de lo que haya detrás del WAF. Un virtual patch como CVE-2021-3129 pasando de "filtrado" a "entregado" invierte la razón por la que el WAF estaba en ese endpoint.
Ahora la parte que no aparece en el registro del CVE. El mismo error de asunción ("si no hay Content-Length positivo, no hay cuerpo") se publicó en dos componentes más del ecosistema de CrowdSec: un bouncer oficial de CrowdSec y la integración de Traefik de terceros más utilizada. Los dos se abordaron en releases posteriores, con comportamientos por defecto distintos y sin asignación de CVE.
El primero es lua-cs-bouncer, mantenido dentro de la organización CrowdSec. Es la librería Lua de la que dependen cs-nginx-bouncer y cs-openresty-bouncer. Llama a ngx.req.read_body() y descarta el cuerpo si no puede leerlo. Extracto de lib/crowdsec.lua:
local function get_body()
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
-- This means that we will likely miss body, but AFAIK,
-- there's no workaround for this
return nil
end
return body
endEl comentario es honesto sobre lo que pasa. Para peticiones HTTP/2 sin cabecera content-length explícita, el read_body de ngx_lua no devuelve el cuerpo, get_body devuelve nil, y el llamador en AppSecCheck construye una petición GET sin cuerpo hacia el listener de AppSec. A AppSec se le pregunta "¿está bien esta petición?", no escanea nada, devuelve allow.
lua-cs-bouncer v1.0.14 añade una opción configurable por el operador, APPSEC_DROP_UNREADABLE_BODY. Cuando se pone a true el bouncer deniega la petición y devuelve la remediación de fallback configurada. La flag por defecto es false. El operador tiene que habilitarla explícitamente para que el bouncer falle cerrado en tráfico HTTP/2 sin content-length; sin ella, una simple actualización no cierra el bypass en configuración por defecto.
El segundo es maxlerebourg/crowdsec-bouncer-traefik-plugin, un proyecto de terceros mantenido fuera de la organización CrowdSec. La propia documentación de CrowdSec lo enlaza como la integración canónica con Traefik, que es como acaba delante de tantos despliegues. Su función appsecQuery condiciona el forward del cuerpo en un predicado que incluye httpReq.ContentLength > 0. HTTP/1.1 chunked tiene ContentLength == -1. HTTP/2 sin content-length tiene ContentLength == -1 o 0. Cualquiera de las dos falla el predicado, dispara la rama else, y el plugin construye http.NewRequest(http.MethodGet, routeURL, nil). El listener de AppSec recibe un GET sin cuerpo y devuelve allow.
La v1.6.0 del plugin elimina el predicado ContentLength > 0. La condición ahora es appsecBodyLimit > 0 && httpReq.Body != nil, y el cuerpo se lee con io.LimitReader acotado al límite configurado sin importar lo que la cabecera declare. Las versiones 1.5.0 y 1.5.1 siguen siendo vulnerables. La actualización a v1.6.0 cierra el bypass en configuración por defecto.
Los dos bugs terminan igual antes del parche: una petición con un cuerpo que el WAF debería haber inspeccionado llega a AppSec sin cuerpo, AppSec da luz verde, el bouncer reenvía la petición original (intacta) al backend. Las capturas de paquetes del hop plugin-a-AppSec lo confirman en el cable: el caso Content-Length es un POST / con el cuerpo malicioso completo, el caso chunked es un GET / sin cuerpo, payload idéntico en la capa pública.
El hueco práctico: los operadores actualizan basándose en alertas de CVE. El fix del core de CrowdSec en v1.7.8 aparece en cualquier panel de WAF que vigile feeds de CVE. Los fixes de los bouncers salen como releases ordinarias. En un despliegue con cs-nginx-bouncer o cs-openresty-bouncer la actualización por sí sola no basta: el operador también tiene que poner APPSEC_DROP_UNREADABLE_BODY=true en la configuración del bouncer. En un despliegue con el plugin de Traefik, actualizar a v1.6.0 cierra el bypass en configuración por defecto. En cualquiera de los dos casos, el fix que más importa para esos despliegues es el del bouncer, no el del core, y ese fix del bouncer no lleva CVE que los operadores puedan seguir.
El hallazgo vino de una lectura-auditoría del código del listener de AppSec, no de fuzzing. La señal en el código era max(r.ContentLength, 0) seguido de io.ReadFull sobre un buffer dimensionado por ese entero. Cualquier ruta de código que calcule un tamaño de buffer para el cuerpo a partir de una cabecera de framing controlada por el cliente, y luego use ReadFull contra un buffer de ese tamaño, confía implícitamente en que el cliente declare correctamente la longitud del cuerpo. El encoding chunked (HTTP/1.1) y HTTP/2 sin longitud son las dos formas en que el framing aplaza esa declaración legítimamente. Un WAF que no contempla ninguna de las dos es un WAF que se puede saltar.
Reportado a través de GHSA en los tres repositorios afectados. CrowdSec asignó CVE-2026-44982 para el hallazgo del core. Los fixes de los bouncers salieron sin asignación de CVE en lua-cs-bouncer v1.0.14 y en maxlerebourg/crowdsec-bouncer-traefik-plugin v1.6.0.
Cronología de CVE-2026-44982. 22 de abril de 2026, vulnerabilidad descubierta, reportada a CrowdSec a través de GitHub Security Advisories ese mismo día. 11 de mayo de 2026, CrowdSec v1.7.8 publicada con el parche. 27 de mayo de 2026, CVE-2026-44982 publicado. CVSS 7.2, CWE-693. Cinco semanas desde el reporte hasta el CVE público.
