
# Laboratorio di riproduzione per CVE-2026-3304, una condizione di gara in fileFilter asincrono di Multer che causa l'esaurimento del disco tramite file temporanei orfani. Include server Docker vulnerabili e corretti, script di exploit e analisi della causa principale.
Solo per scopi educativi e di ricerca. Attaccare sistemi senza autorizzazione è illegale.
Questo ambiente di laboratorio è stato costruito analizzando la patch di Multer 2.1.0 e il suo codice di test ufficiale. Il server vulnerabile replica le condizioni esatte descritte nel test della patch, e il server corretto esegue Multer 2.1.0 per confermare che la correzione funziona.
| Campo | Dettagli |
|---|---|
| CVE | CVE-2026-3304 |
| Target | Multer < 2.1.0 (middleware multipart/form-data di Node.js) |
| Tipo | DoS — File Orfano (pulizia incompleta dei file temporanei) |
| CWE | CWE-459: Pulizia Incompleta |
| CVSS 4.0 | 8.7 ALTO |
| Versione Corretta | Multer 2.1.0 |
Una richiesta multipart malformata con un attributo name mancante su una parte di file fa sì che Multer crei un file temporaneo su disco senza mai pulirlo. Richieste ripetute esauriscono lo spazio su disco, provocando un Denial of Service.
La vulnerabilità ha origine nel flusso di gestione del callback fileFilter all'interno di multer/lib/make-middleware.js.
Quando Multer trasmette e analizza una richiesta multipart parte per parte, si verifica la seguente sequenza:
[Sequenza degli eventi di parsing — versione vulnerabile]
1. Intestazione della Parte 1 ricevuta
→ fileFilter(req, file, cb) chiamato
→ setImmediate(cb) → callback rimandato al tick successivo del ciclo di eventi
2. Corpo della Parte 1 ricevuto
→ /tmp/uploads/<uuid> aperto, i dati iniziano a essere scritti
3. Intestazione della Parte 2 ricevuta (attributo name mancante)
→ Multer rileva 'name mancante'
→ errorOccured = true ← flag di errore impostato
→ abortWithCode('LIMIT_FIELD_KEY') chiamato → HTTP 500 pianificato
4. Il callback di setImmediate viene eseguito (tick successivo del ciclo di eventi)
→ risultato di fileFilter: includeFile = true (flusso normale)
→ [BUG] il flag errorOccured NON viene controllato
→ storage._handleFile() chiamato → file temporaneo salvato su disco
5. Risposta HTTP 500 inviata
→ il file temporaneo rimane su disco (file orfano)
make-middleware.js — Multer < 2.1.0)// callback di completamento di fileFilter (rimandato tramite 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 non viene mai controllato qui
// anche se è stato impostato un errore durante il parsing della Parte 2, l'esecuzione continua
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// il file temporaneo viene registrato in uploadedFiles e lasciato su disco
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Perché setImmediate è il problema?
Avvolgere fileFilter con setImmediate rimanda il suo callback al tick successivo del ciclo di eventi.
In quella finestra, busboy (il parser multipart) continua ad analizzare le intestazioni della parte successiva,
scopre il name mancante e imposta errorOccured = true.
Quando il callback riprende, lo stato di errore è già impostato — ma il codice non lo controlla mai,
quindi storage._handleFile viene chiamato incondizionatamente e il file temporaneo viene scritto su disco.
Commit di correzione: 739919097d
Un singolo controllo if (errorOccured) è stato aggiunto immediatamente dopo l'ingresso del callback fileFilter,
prima che venga raggiunto storage._handleFile.
// callback di completamento di fileFilter (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PATCH] Controlla errorOccured prima di procedere
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // drena lo stream — nessun file scritto su disco
}
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()
})
})
| Elemento | Vulnerabile (< 2.1.0) | Corretto (2.1.0) |
|---|---|---|
Controllo errorOccured | ❌ Non controllato | ✅ Controllato immediatamente all'ingresso del callback |
_handleFile chiamato in caso di errore | Sì | Bloccato |
| Pulizia del file temporaneo | ❌ Mancante | ✅ Tramite fileStream.resume() |
| File orfani per richiesta | 1 | 0 |
Perché fileStream.resume() pulisce:
Chiamare fileStream.resume() drena e scarta lo stream senza passarlo a DiskStorage,
quindi nessun file viene scritto e nulla rimane su disco.
Il team di Multer ha incluso il seguente test Mocha nella patch 2.1.0 per verificare la correzione.
Questo test è diventato il modello per questo ambiente di laboratorio — il server vulnerabile
replica la configurazione esatta qui descritta (setImmediate fileFilter + richiesta multipart malformata),
e il comportamento atteso viene verificato sia sulla versione vulnerabile che su quella corretta.
/* 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('pulizia async fileFilter', function () {
it('non lascia file orfani quando la richiesta viene interrotta con nome campo mancante', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Trigger della vulnerabilità: fileFilter async tramite 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 })
})
// Gestore degli errori: risponde con 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'
// Corpo dannoso: Parte 1 valida, Parte 2 con attributo name mancante
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= mancante
'Content-Type: application/octet-stream\r\n\r\nx\r\n' +
'--' + boundary + '--\r\n'