
PoC-эксплойт для CVE-2026-56848 — ошибка heap-use-after-free в HTTP/2-реализации Node.js, позволяющая удалённую неаутентифицированную DoS-атаку. Включает raw-socket триггер, инструкции по сборке с ASan и Docker-цель.
Ошибка в обработке HTTP/2 в Node.js допускает реентерабельный вызов
nghttp2_session_mem_send()во время выполненияnghttp2_session_mem_recv(), что приводит к использованию после освобождения в куче (heap-use-after-free).Эта уязвимость затрагивает Node.js 26.x, 24.x и 22.x.
(Примечание: указанные в описании версии относятся только к пакету nodejs из вышестоящего репозитория и не относятся к пакету nodejs, распространяемому через Alpine.
| Линия релиза | Уязвимые | Исправленные |
|---|---|---|
| 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-атака через повреждение кучи)daa6d25e3dce — "http2: defer rst stream while in scope" (nodejs-private/node-private#921)Http2Stream::SubmitRstStream() в src/node_http2.cc принудительно очищает ожидающие исходящие данные перед постановкой 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()` вызывает `nghttp2_session_mem_send()` ([node_http2.cc:1970](https://github.com/nodejs/node/blob/v22.23.1/src/node_http2.cc#L1970)). Его единственная защита от повторного входа — `is_sending()`, которая защищает от *отправки во время отправки* (когда запись уже выполняется) — **но не от отправки во время получения**. Когда `SubmitRstStream()` выполняется из цепочки обратных вызовов `nghttp2_session_mem_recv()` («в области видимости»), сброс запускает `mem_send()` реентерабельно.
Реентерабельный `mem_send()` сбрасывает кадры, обработка которых на стороне отправки уничтожает потоки (`nghttp2_session_close_stream_on_goaway()` → `on_stream_close` → `Http2Stream::Destroy()` → освобождение C++-объекта `Http2Stream`). На освобождённый поток всё ещё ссылается выполняющаяся операция приёма: сам `SubmitRstStream()` продолжает выполняться на освобождённом `this` (его завершающий `FlushRstStream()` читает `is_destroyed()`), а внешний `mem_recv()` продолжает обходить состояние кадров/заголовков закрытого потока → **использование после освобождения**.
### Цепочка триггера (всё внутри одного вызова `nghttp2_session_mem_recv()`)
1. Атакующий отправляет `GOAWAY(lastStreamID=0, NO_ERROR)`, сразу за которым следуют кадры `HEADERS` для новых потоков (3, 5, 7, …) в одном TCP-сегменте.
2. `mem_recv()` сервера обрабатывает GOAWAY → JS `session.close()` → `session.closed = true`, и исходящий GOAWAY **подан на отправку, но ещё не отправлен**.
3. nghttp2 отклоняет новые входящие потоки только после того, как GOAWAY действительно *отправлен* (`session_allow_incoming_new_stream()` проверяет `TERM_ON_SEND | SENT`, а не `SUBMITTED`), поэтому `HEADERS(3)` всё ещё принимается.
4. JS-функция `onSessionHeaders()` видит новый поток на закрытой сессии и отклоняет его: `handle.rstStream(NGHTTP2_REFUSED_STREAM)` (lib/internal/http2/core.js).
5. C++ `SubmitRstStream(NGHTTP2_REFUSED_STREAM)` выполняется в области видимости (внутри `mem_recv`), `REFUSED_STREAM ≠ CANCEL` → переход к `SendPendingData()` → **реентерабельный `nghttp2_session_mem_send()`**.
6. Реентерабельная отправка сбрасывает исходящий GOAWAY; обработка GOAWAY на стороне отправки в nghttp2 закрывает входящие потоки с id > 1 (`session_close_stream_on_goaway(..., NGHTTP2_REFUSED_STREAM)`), вызывая `on_stream_close` → `Http2Stream::Destroy()` освобождает C++-объект потока 3.
7. Выполнение возвращается в `SubmitRstStream()` на освобождённом объекте (`FlushRstStream()`), а внешний `mem_recv()` продолжает работу с повреждённым состоянием сессии/потока → UAF.
Доказательство из `NODE_DEBUG_NATIVE=http2` на уязвимом сервере (одно 86-байтовое чтение):```
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
Вывод на уровне протокола идентичен для уязвимых и пропатченных сборок (обе в итоге отправляют только исходящий GOAWAY — в уязвимых сборках RST отправляется на уже закрытый поток, в пропатченных nghttp2 отбрасывает поставленные в очередь RST после того, как GOAWAY уходит первым). Разница внутренняя и видна с помощью NODE_DEBUG_NATIVE=http2:
sending pending data появляется между sending rst_stream with code 7 и stream 3 closed with code: 7 — реентерабельный mem_send() выполняется в середине приёма данных и закрывает/уничтожает поток 3, пока mem_recv() ещё работает (крах под ASan).sending pending data между ними отсутствует — RST лишь ставится в очередь; поток 3 закрывается только во время обычного завершающего сброса после приёма.server.js # minimal http2.createServer() target (no handler needed) exploit.js # raw-socket HTTP/2 client that drives the trigger
### Быстрый запуск```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 (используемая ниже локальная ASan-сборка) не отслеживается в git — соберите его по инструкциям в разделе «Сборка уязвимого Node.js с инструментированием ASan» или используйте Docker-подход.
Эксплойт выводит статус по каждому соединению; SKIPPED (no handshake) после первого соединения означает, что цель уже погибла от атаки.
Dockerfile собирает уязвимую цель v22.23.1 с ASan внутри контейнера (локальный инструментарий не нужен — только демон 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