
Laboratoire de reproduction pour CVE-2026-3304, une condition de course asynchrone dans fileFilter de Multer provoquant l'épuisement du disque via des fichiers temporaires orphelins. Inclut des serveurs Docker vulnérables et corrigés, des scripts d'exploitation et une analyse de la cause racine.
Uniquement à des fins éducatives et de recherche. Attaquer des systèmes sans autorisation est illégal.
Cet environnement de laboratoire a été construit en analysant le correctif de Multer 2.1.0 et son code de test officiel. Le serveur vulnérable reproduit les conditions exactes décrites dans le test du correctif, et le serveur corrigé exécute Multer 2.1.0 pour confirmer que le correctif fonctionne.
| Champ | Détails |
|---|---|
| CVE | CVE-2026-3304 |
| Cible | Multer < 2.1.0 (middleware Node.js multipart/form-data) |
| Type | DoS — Fichier orphelin (nettoyage incomplet des fichiers temporaires) |
| CWE | CWE-459 : Nettoyage incomplet |
| CVSS 4.0 | 8.7 ÉLEVÉ |
| Version corrigée | Multer 2.1.0 |
Une requête multipart malformée avec un attribut name manquant sur une partie de fichier amène Multer à créer un fichier temporaire sur le disque sans jamais le nettoyer. Des requêtes répétées épuisent l'espace disque, entraînant un déni de service.
La vulnérabilité provient du flux de gestion du rappel fileFilter dans multer/lib/make-middleware.js.
Lorsque Multer diffuse et analyse une requête multipart partie par partie, la séquence suivante se produit :
[Séquence d'événements d'analyse — version vulnérable]
1. En-tête de la partie 1 reçu
→ fileFilter(req, file, cb) appelé
→ setImmediate(cb) → rappel différé au prochain tick de la boucle d'événements
2. Corps de la partie 1 reçu
→ /tmp/uploads/<uuid> ouvert, les données commencent à être écrites
3. En-tête de la partie 2 reçu (attribut name manquant)
→ Multer détecte 'name manquant'
→ errorOccured = true ← indicateur d'erreur défini
→ abortWithCode('LIMIT_FIELD_KEY') appelé → HTTP 500 planifié
4. Le rappel setImmediate se déclenche (prochain tick de la boucle d'événements)
→ Résultat de fileFilter : includeFile = true (flux normal)
→ [BUG] l'indicateur errorOccured n'est PAS vérifié
→ storage._handleFile() appelé → fichier temporaire validé sur le disque
5. Réponse HTTP 500 envoyée
→ le fichier temporaire reste sur le disque (fichier orphelin)
make-middleware.js — Multer < 2.1.0)// Rappel de fin de fileFilter (différé via 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 n'est jamais vérifié ici
// même si une erreur a été définie lors de l'analyse de la partie 2, l'exécution continue
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// le fichier temporaire est enregistré dans uploadedFiles et laissé sur le disque
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Pourquoi setImmediate est-il le problème ?
Envelopper fileFilter avec setImmediate diffère son rappel au prochain tick de la boucle d'événements.
Dans cette fenêtre, busboy (l'analyseur multipart) continue d'analyser les en-têtes de la partie suivante,
découvre le name manquant et définit errorOccured = true.
Lorsque le rappel reprend, l'état d'erreur est déjà défini — mais le code ne le vérifie jamais,
donc storage._handleFile est appelé inconditionnellement et le fichier temporaire est écrit sur le disque.
Commit du correctif : 739919097d
Un simple garde if (errorOccured) a été ajouté immédiatement après l'entrée du rappel fileFilter,
avant que storage._handleFile ne soit atteint.
// Rappel de fin de fileFilter (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [CORRECTIF] Vérifier errorOccured avant de continuer
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // vider le flux — aucun fichier écrit sur le disque
}
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()
})
})
| Élément | Vulnérable (< 2.1.0) | Corrigé (2.1.0) |
|---|---|---|
Vérification errorOccured | ❌ Non vérifié | ✅ Vérifié immédiatement à l'entrée du rappel |
_handleFile appelé en cas d'erreur | Oui | Bloqué |
| Nettoyage des fichiers temporaires | ❌ Manquant | ✅ Via fileStream.resume() |
| Fichiers orphelins par requête | 1 | 0 |
Pourquoi fileStream.resume() nettoie :
Appeler fileStream.resume() vide et rejette le flux sans le transmettre à DiskStorage,
donc aucun fichier n'est écrit et rien n'est laissé sur le disque.
L'équipe Multer a inclus le test Mocha suivant dans le correctif 2.1.0 pour vérifier la correction.
Ce test est devenu le modèle de cet environnement de laboratoire — le serveur vulnérable
reproduit la configuration exacte décrite ici (setImmediate fileFilter + requête multipart malformée),
et le comportement attendu est vérifié à la fois sur les versions vulnérable et corrigée.
/* 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)