
PoC de exploit para CVE-2026-56848, un heap-use-after-free en Node.js HTTP/2 que permite DoS remoto sin autenticación. Incluye disparador de raw-socket, instrucciones de compilación con ASan y objetivo basado en Docker.
Una falla en el manejo de HTTP/2 en Node.js permite que se llame a
nghttp2_session_mem_send()de forma reentrante mientrasnghttp2_session_mem_recv()está en ejecución, lo que resulta en un heap-use-after-free.Esta vulnerabilidad afecta a Node.js 26.x, 24.x y 22.x.
(Nota: las versiones mencionadas en la descripción se aplican solo al paquete nodejs upstream y no al paquete nodejs distribuido por Alpine.
| Release line | Vulnerable | Fixed |
|---|---|---|
| 22.x (LTS) | ≤ 22.23.1 | 22.23.2 |
| 24.x (LTS) | ≤ 24.18.0 | 24.18.1 |
| 26.x | ≤ 26.5.0 | 26.5.1 |
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H — DoS remoto no autenticado mediante corrupción del heap)daa6d25e3dce — "http2: defer rst stream while in scope" (nodejs-private/node-private#921)Http2Stream::SubmitRstStream() en src/node_http2.cc fuerza una purga de los datos salientes pendientes antes de poner en cola el RST_STREAM:```cpp
void Http2Stream::SubmitRstStream(const uint32_t code) {
CHECK(!this->is_destroyed());
code_ = code;
// (NGHTTP2_CANCEL is deferred — fix for an older double-free) if (session_->is_in_scope() && is_stream_cancel(code)) { session_->AddPendingRstStream(id_); return; }
// If possible, force a purge of any currently pending data here to make // sure it is sent before closing the stream. ... if (session_->SendPendingData() != 0) { // ← RE-ENTRANT mem_send() session_->AddPendingRstStream(id_); return; }
FlushRstStream(); }
`SendPendingData()` llama a `nghttp2_session_mem_send()` ([node_http2.cc:1970](https://github.com/nodejs/node/blob/v22.23.1/src/node_http2.cc#L1970)). Su única protección de reentrada es `is_sending()`, que protege contra *send-during-send* (una escritura ya en vuelo) — **no contra send-during-receive**. Cuando `SubmitRstStream()` se ejecuta desde dentro de una cadena de callbacks de `nghttp2_session_mem_recv()` ("en el ámbito"), la purga ejecuta `mem_send()` de forma reentrante.
El `mem_send()` reentrante envía tramas cuyo procesamiento en el lado de envío destruye streams (`nghttp2_session_close_stream_on_goaway()` → `on_stream_close` → `Http2Stream::Destroy()` → liberación del `Http2Stream` de C++). El stream liberado sigue siendo referenciado por la operación de recepción en vuelo: el propio `SubmitRstStream()` continúa ejecutándose sobre el `this` liberado (su `FlushRstStream()` final lee `is_destroyed()`), y el `mem_recv()` externo sigue recorriendo el estado de tramas/headers del stream cerrado → **heap-use-after-free**.
### Cadena de disparo (todo dentro de una sola llamada a `nghttp2_session_mem_recv()`)
1. El atacante envía `GOAWAY(lastStreamID=0, NO_ERROR)` inmediatamente seguido de tramas `HEADERS` para nuevas streams (3, 5, 7, …) en un solo segmento TCP.
2. El `mem_recv()` del servidor procesa GOAWAY → JS `session.close()` → `session.closed = true` y un GOAWAY saliente es **encolado pero aún no enviado**.
3. nghttp2 solo rechaza nuevas streams entrantes una vez que GOAWAY se ha *enviado* realmente (`session_allow_incoming_new_stream()` comprueba `TERM_ON_SEND | SENT`, no `SUBMITTED`), por lo que `HEADERS(3)` todavía se acepta.
4. El `onSessionHeaders()` de JS ve una nueva stream en una sesión cerrada y la rechaza: `handle.rstStream(NGHTTP2_REFUSED_STREAM)` (lib/internal/http2/core.js).
5. El `SubmitRstStream(NGHTTP2_REFUSED_STREAM)` de C++ se ejecuta en el ámbito (dentro de `mem_recv`), `REFUSED_STREAM ≠ CANCEL` → cae en `SendPendingData()` → **`nghttp2_session_mem_send()` reentrante**.
6. El envío reentrante envía el GOAWAY saliente; el procesamiento de GOAWAY en el lado de envío de nghttp2 cierra las streams entrantes con id > 1 (`session_close_stream_on_goaway(..., NGHTTP2_REFUSED_STREAM)`), disparando `on_stream_close` → `Http2Stream::Destroy()` libera el objeto stream de C++ para la stream 3.
7. La ejecución regresa a `SubmitRstStream()` sobre el objeto liberado (`FlushRstStream()`), y el `mem_recv()` externo se reanuda con el estado de sesión/stream corrupto → UAF.
Evidencia de `NODE_DEBUG_NATIVE=http2` en un servidor vulnerable (una lectura de 86 bytes):```
receiving 86 bytes, offset 0
complete frame received: type: 7 ← GOAWAY
submitting goaway ← GOAWAY submitted, NOT yet sent
beginning headers for stream 3 ← still accepted (only SUBMITTED)
handle headers frame for stream 3 ← JS: session.closed → refuse
sending rst_stream with code 7 ← SubmitRstStream(REFUSED_STREAM), in scope
sending pending data ← RE-ENTRANT mem_send()
stream 3 closed with code: 7 ← GOAWAY send closes stream 3
Removing stream: 3 / destroying stream ← Http2Stream freed mid-recv
La salida a nivel de cable es idéntica en las compilaciones vulnerables y parcheadas (ambas terminan enviando solo el GOAWAY saliente — en las compilaciones vulnerables el RST se envía contra un stream ya cerrado, en las compilaciones parcheadas nghttp2 descarta los RST en cola una vez que el GOAWAY sale primero). La diferencia es interna, visible con NODE_DEBUG_NATIVE=http2:
sending pending data aparece entre sending rst_stream with code 7 y stream 3 closed with code: 7 — el mem_send() reentrante se ejecuta a mitad de la recepción y cierra/destruye el stream 3 mientras mem_recv() sigue en curso (caída bajo ASan).sending pending data entre ellos — el RST simplemente se pone en cola; el stream 3 se cierra solo durante el vaciado normal posterior a la recepción.server.js # minimal http2.createServer() target (no handler needed) exploit.js # raw-socket HTTP/2 client that drives the trigger
### Ejecución rápida```bash
# Terminal 1: the target (any vulnerable node: 22.23.1 / 24.18.0 / 26.5.0 or older in their lines)
node server.js 8000 # NODE_BIN=/path/to/node for a specific binary
# Terminal 2: the attack — a crash shows up in terminal 1 (ASan report / segfault)
node exploit.js --port 8000 --iterations 200
./bin/node (la compilación local con ASan que se usa a continuación) no está rastreado en git — compílelo con las instrucciones de "Compilación de un Node.js vulnerable instrumentado con ASan" o use la ruta de Docker.
El exploit informa del estado por conexión; SKIPPED (no handshake) después de la primera conexión significa que el objetivo ya ha muerto a causa del ataque.
El Dockerfile construye un objetivo vulnerable v22.23.1 con ASan dentro de un contenedor (no se necesita ninguna cadena de herramientas local — solo el daemon de Docker):```bash
docker build -t cve-2026-56848 .
docker run --rm -p 8000:8000 --name cve-target cve-2026-56848
node exploit.js --port 8000 --iterations 10