
Exploit PoC pour CVE-2026-56848, une vulnérabilité heap-use-after-free de Node.js HTTP/2 permettant un déni de service (DoS) à distance sans authentification. Inclut un déclencheur raw-socket, des instructions de compilation ASan et une cible basée sur Docker.
Une faille dans la gestion HTTP/2 de Node.js permet à
nghttp2_session_mem_send()d'être appelée de manière réentrante pendant quenghttp2_session_mem_recv()est en cours d'exécution, ce qui entraîne un heap-use-after-free.Cette vulnérabilité affecte Node.js 26.x, 24.x et 22.x.
(Remarque : les versions mentionnées dans la description s'appliquent uniquement au paquet nodejs amont et non au paquet nodejs tel que distribué par Alpine.
| Ligne de version | Vulnérable | Corrigé |
|---|---|---|
| 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 à distance, non authentifié, via corruption du tas)daa6d25e3dce — "http2: defer rst stream while in scope" (nodejs-private/node-private#921)Http2Stream::SubmitRstStream() dans src/node_http2.cc force une purge des données sortantes en attente avant de mettre le RST_STREAM en file d'attente :```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()` appelle `nghttp2_session_mem_send()` ([node_http2.cc:1970](https://github.com/nodejs/node/blob/v22.23.1/src/node_http2.cc#L1970)). Sa seule protection contre la réentrance est `is_sending()`, qui protège contre *un envoi pendant un envoi* (une écriture déjà en cours) — **pas contre un envoi pendant une réception**. Quand `SubmitRstStream()` s’exécute depuis l’intérieur d’une chaîne de callbacks `nghttp2_session_mem_recv()` (dans la portée), la purge exécute `mem_send()` de manière réentrante.
Le `mem_send()` réentrant transmet les trames dont le traitement côté envoi détruit les flux (`nghttp2_session_close_stream_on_goaway()` → `on_stream_close` → `Http2Stream::Destroy()` → libération du `Http2Stream` C++). Le flux libéré est toujours référencé par l’opération de réception en cours : `SubmitRstStream()` lui-même continue de s’exécuter sur le `this` libéré (son `FlushRstStream()` final lit `is_destroyed()`), et le `mem_recv()` externe continue de parcourir l’état des trames/en-têtes du flux fermé → **heap-use-after-free**.
### Chaîne de déclenchement (le tout dans un seul appel `nghttp2_session_mem_recv()`)
1. L’attaquant envoie `GOAWAY(lastStreamID=0, NO_ERROR)` immédiatement suivi de trames `HEADERS` pour de nouveaux flux (3, 5, 7, …) dans un seul segment TCP.
2. Le `mem_recv()` du serveur traite GOAWAY → JS `session.close()` → `session.closed = true` et un GOAWAY sortant est **soumis mais pas encore envoyé**.
3. nghttp2 ne refuse les nouveaux flux entrants qu’une fois que GOAWAY est effectivement *envoyé* (`session_allow_incoming_new_stream()` vérifie `TERM_ON_SEND | SENT`, pas `SUBMITTED`), donc `HEADERS(3)` est encore accepté.
4. `onSessionHeaders()` côté JS voit un nouveau flux sur une session fermée et le refuse : `handle.rstStream(NGHTTP2_REFUSED_STREAM)` (lib/internal/http2/core.js).
5. Le `SubmitRstStream(NGHTTP2_REFUSED_STREAM)` C++ s’exécute dans la portée (dans `mem_recv`), `REFUSED_STREAM ≠ CANCEL` → enchaîne sur `SendPendingData()` → **`nghttp2_session_mem_send()` réentrant**.
6. L’envoi réentrant transmet le GOAWAY sortant ; le traitement du GOAWAY par nghttp2 côté envoi ferme les flux entrants avec id > 1 (`session_close_stream_on_goaway(..., NGHTTP2_REFUSED_STREAM)`), déclenchant `on_stream_close` → `Http2Stream::Destroy()` libère l’objet flux C++ du flux 3.
7. L’exécution retourne dans `SubmitRstStream()` sur l’objet libéré (`FlushRstStream()`), et le `mem_recv()` externe reprend sur un état session/flux corrompu → UAF.
Preuve avec `NODE_DEBUG_NATIVE=http2` sur un serveur vulnérable (une lecture de 86 octets) :```
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 sortie au niveau du fil est identique sur les builds vulnérables et corrigés (les deux finissent par n'envoyer que le GOAWAY sortant — sur les builds vulnérables, le RST est soumis contre un flux déjà fermé, sur les builds corrigés, nghttp2 abandonne les RST en file d'attente une fois le GOAWAY envoyé en premier). La différence est interne, visible avec NODE_DEBUG_NATIVE=http2 :
sending pending data apparaît entre sending rst_stream with code 7 et stream 3 closed with code: 7 — le mem_send() réentrant s'exécute en pleine réception et ferme/détruit le flux 3 pendant que mem_recv() est toujours en vol (crash sous ASan).sending pending data entre les deux — le RST est simplement mis en file d'attente ; le flux 3 ne se ferme que lors du flush normal post-réception.server.js # minimal http2.createServer() target (no handler needed) exploit.js # raw-socket HTTP/2 client that drives the trigger
### Exécution rapide```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 compilation ASan locale utilisée ci-dessous) n'est pas suivi dans git — compilez-le
avec les instructions de la section "Compilation d'un Node.js vulnérable instrumenté avec ASan",
ou passez par Docker.
L'exploit rapporte le statut de chaque connexion ; SKIPPED (no handshake) après la première
connexion signifie que la cible a déjà succombé à l'attaque.
Le Dockerfile construit une cible vulnérable v22.23.1 avec ASan dans un conteneur (aucune chaîne d'outils locale n'est nécessaire — seul le démon Docker est requis) :```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