
# مختبر إعادة إنتاج لـ CVE-2026-3304، حالة سباق في async fileFilter في Multer تسبب استنزاف القرص عبر ملفات مؤقتة يتيمة. يتضمن خوادم Docker ضعيفة ومصححة، ونصوص استغلال، وتحليل السبب الجذري.
لأغراض تعليمية وبحثية فقط. مهاجمة الأنظمة دون إذن أمر غير قانوني.
تم بناء بيئة المختبر هذه من خلال تحليل تصحيح Multer 2.1.0 وكود الاختبار الرسمي الخاص به. الخادم القابل للاستغلال يحاكي الشروط الدقيقة الموضحة في اختبار التصحيح، والخادم المُصحح يعمل بإصدار Multer 2.1.0 للتأكد من أن الإصلاح يعمل.
| الحقل | التفاصيل |
|---|---|
| CVE | CVE-2026-3304 |
| الهدف | Multer < 2.1.0 (وسيط multipart/form-data الخاص بـ Node.js) |
| النوع | DoS — ملف يتيم (تنظيف غير مكتمل للملفات المؤقتة) |
| CWE | CWE-459: تنظيف غير مكتمل |
| CVSS 4.0 | 8.7 عالية |
| الإصدار المُصحح | Multer 2.1.0 |
طلب multipart مشوّه مع سمة name مفقودة على جزء ملف يتسبب في قيام Multer بإنشاء ملف مؤقت على القرص دون تنظيفه أبدًا. الطلبات المتكررة تستنزف مساحة القرص، مما يؤدي إلى رفض الخدمة (DoS).
تنبع الثغرة من تدفق معالجة استدعاء fileFilter داخل multer/lib/make-middleware.js.
عندما يقوم Multer ببث وتحليل طلب multipart جزءًا بجزء، يحدث التسلسل التالي:
[تسلسل أحداث التحليل — الإصدار القابل للاستغلال]
1. استلام ترويسة الجزء 1
→ استدعاء fileFilter(req, file, cb)
→ setImmediate(cb) → تأجيل الاستدعاء إلى دورة الحدث التالية
2. استلام جسم الجزء 1
→ فتح /tmp/uploads/<uuid>، وبدء كتابة البيانات
3. استلام ترويسة الجزء 2 (سمة name مفقودة)
→ يكتشف Multer 'name missing'
→ errorOccured = true ← تعيين علامة الخطأ
→ استدعاء abortWithCode('LIMIT_FIELD_KEY') → جدولة HTTP 500
4. تنفيذ استدعاء setImmediate (دورة الحدث التالية)
→ نتيجة fileFilter: includeFile = true (التدفق الطبيعي)
→ [خطأ] لا يتم فحص علامة errorOccured
→ استدعاء storage._handleFile() → الالتزام بالملف المؤقت على القرص
5. إرسال استجابة HTTP 500
→ يبقى الملف المؤقت على القرص (ملف يتيم)
make-middleware.js — Multer < 2.1.0)// استدعاء إكمال fileFilter (مؤجل عبر setImmediate)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
// ❌ لا يتم فحص errorOccured هنا أبدًا
// حتى لو تم تعيين خطأ أثناء تحليل الجزء 2، يستمر التنفيذ
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// يتم تسجيل الملف المؤقت في uploadedFiles ويُترك على القرص
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
لماذا يعتبر setImmediate هو المشكلة؟
تغليف fileFilter بـ setImmediate يؤجل استدعاءه إلى دورة الحدث التالية.
في هذه النافذة، يستمر busboy (محلل multipart) في تحليل ترويسات الجزء التالي،
ويكتشف name المفقود، ويعيّن errorOccured = true.
عندما يستأنف الاستدعاء، تكون حالة الخطأ قد تم تعيينها بالفعل — لكن الكود لا يفحصها أبدًا،
لذلك يتم استدعاء storage._handleFile دون قيد أو شرط ويتم كتابة الملف المؤقت على القرص.
التزام الإصلاح: 739919097d
تمت إضافة حارس if (errorOccured) واحد مباشرة بعد دخول استدعاء fileFilter،
قبل الوصول إلى storage._handleFile.
// استدعاء إكمال fileFilter (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [التصحيح] فحص errorOccured قبل المتابعة
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // تصريف التدفق — لا يتم كتابة ملف على القرص
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
| العنصر | قابل للاستغلال (< 2.1.0) | مُصحح (2.1.0) |
|---|---|---|
فحص errorOccured | ❌ غير مُفحص | ✅ يُفحص فورًا عند دخول الاستدعاء |
استدعاء _handleFile عند الخطأ | نعم | محظور |
| تنظيف الملف المؤقت | ❌ مفقود | ✅ عبر fileStream.resume() |
| الملفات اليتيمة لكل طلب | 1 | 0 |
لماذا يقوم fileStream.resume() بالتنظيف:
استدعاء fileStream.resume() يصرف ويتجاهل التدفق دون تمريره إلى DiskStorage،
لذلك لا يتم كتابة أي ملف ولا يبقى شيء على القرص.
قام فريق Multer بتضمين اختبار Mocha التالي في تصحيح 2.1.0 للتحقق من الإصلاح.
أصبح هذا الاختبار المخطط الأساسي لبيئة المختبر هذه — الخادم القابل للاستغلال
يحاكي الإعداد الدقيق الموضح هنا (setImmediate fileFilter + طلب multipart مشوّه)،
ويتم التحقق من السلوك المتوقع مقابل كل من الإصدارين القابل للاستغلال والمُصحح.
/* eslint-env mocha */
var assert = require('assert')
var fs = require('fs')
var os = require('os')
var path = require('path')
var http = require('http')
var express = require('express')
var multer = require('../')
describe('async fileFilter cleanup', function () {
it('does not leave orphan files when request aborts with missing field name', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Vulnerability trigger: async fileFilter via setImmediate
var upload = multer({
dest: uploadDir,
fileFilter: function (req, file, cb) {
setImmediate(function () { cb(null, true) })
}
})
app.post('/upload', upload.any(), function (req, res) {
res.json({ success: true })
})
// Error handler: respond with 400
app.use(function (err, req, res, next) {
res.status(400).json({ error: err.code })
})
var server = app.listen(0, function () {
var port = server.address().port
var boundary = 'TestBound'
// Malicious body: Part 1 valid, Part 2 missing name attribute
var body =
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' +
'Content-Type: application/octet-stream\r\n\r\nORPHAN FILE DATA\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; filename="b.bin"\r\n' + // ← name= missing
'Content-Type: application/octet-stream\r\n\r\nx\r\n' +
'--' + boundary + '--\r\n'
var req = http.request({
hostname: 'localhost',
port: port,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': Buffer.byteLength(body)
}
}, function (res) {
res.resume()
res.on('end', function () {
setTimeout(function () {
var files = fs.readdirSync(uploadDir)
// Assert 1: server must respond with 400 (error)
assert.strictEqual(res.statusCode, 400)
// Assert 2: no orphaned files must remain on disk
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| التأكيد | المعنى |
|---|---|
res.statusCode === 400 | يرفض Multer الطلب المشوّه بشكل صحيح |
files.length === 0 | لا توجد ملفات يتيمة متبقية على القرص (تم التحقق من التصحيح) |
على إصدار قابل للاستغلال (< 2.1.0)، تكون قيمة files.length مساوية لـ 1 ويفشل التأكيد.
يعكس هذا المختبر مباشرة إعداد اختبار التصحيح أعلاه.
الخادم القابل للاستغلال (Dockerfile + app/server.js) يعمل بإصدار Multer 2.0.2 مع fileFilter غير متزامن
باستخدام setImmediate — شرط التشغيل الدقيق من اختبار التصحيح: