
Laboratorio de reproducción para CVE-2026-3304, una condición de carrera en el fileFilter asíncrono de Multer que provoca el agotamiento del disco mediante archivos temporales huérfanos. Incluye servidores Docker vulnerables y parcheados, scripts de explotación y análisis de la causa raíz.
Solo con fines educativos y de investigación. Atacar sistemas sin autorización es ilegal.
Este entorno de laboratorio fue construido analizando el parche de Multer 2.1.0 y su código de prueba oficial. El servidor vulnerable replica las condiciones exactas descritas en la prueba del parche, y el servidor parcheado ejecuta Multer 2.1.0 para confirmar que la corrección funciona.
| Campo | Detalles |
|---|---|
| CVE | CVE-2026-3304 |
| Objetivo | Multer < 2.1.0 (middleware multipart/form-data de Node.js) |
| Tipo | DoS — Archivo Huérfano (limpieza incompleta de archivos temporales) |
| CWE | CWE-459: Limpieza Incompleta |
| CVSS 4.0 | 8.7 ALTA |
| Versión Parcheada | Multer 2.1.0 |
Una solicitud multipart malformada con un atributo name faltante en una parte de archivo hace que Multer cree un archivo temporal en el disco pero nunca lo limpie. Las solicitudes repetidas agotan el espacio en disco, resultando en una Denegación de Servicio.
La vulnerabilidad se origina en el flujo de manejo del callback fileFilter dentro de multer/lib/make-middleware.js.
Cuando Multer transmite y analiza una solicitud multipart parte por parte, ocurre la siguiente secuencia:
[Secuencia de eventos de análisis — versión vulnerable]
1. Se recibe el encabezado de la Parte 1
→ se llama a fileFilter(req, file, cb)
→ setImmediate(cb) → callback diferido al siguiente tick del bucle de eventos
2. Se recibe el cuerpo de la Parte 1
→ se abre /tmp/uploads/<uuid>, los datos comienzan a escribirse
3. Se recibe el encabezado de la Parte 2 (falta el atributo name)
→ Multer detecta 'name faltante'
→ errorOccured = true ← se establece la bandera de error
→ se llama a abortWithCode('LIMIT_FIELD_KEY') → se programa HTTP 500
4. El callback de setImmediate se ejecuta (siguiente tick del bucle de eventos)
→ resultado de fileFilter: includeFile = true (flujo normal)
→ [ERROR] la bandera errorOccured NO se verifica
→ se llama a storage._handleFile() → el archivo temporal se confirma en el disco
5. Se envía la respuesta HTTP 500
→ el archivo temporal permanece en el disco (archivo huérfano)
make-middleware.js — Multer < 2.1.0)// callback de finalización de fileFilter (diferido mediante 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 se verifica aquí
// incluso si se estableció un error durante el análisis de la Parte 2, la ejecución continúa
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// el archivo temporal se registra en uploadedFiles y se deja en el disco
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
¿Por qué setImmediate es el problema?
Envolver fileFilter con setImmediate difiere su callback al siguiente tick del bucle de eventos.
En esa ventana, busboy (el analizador multipart) continúa analizando los encabezados de la siguiente parte,
descubre el name faltante y establece errorOccured = true.
Cuando el callback se reanuda, el estado de error ya está establecido — pero el código nunca lo verifica,
por lo que storage._handleFile se llama incondicionalmente y el archivo temporal se escribe en el disco.
Commit de corrección: 739919097d
Se agregó una única protección if (errorOccured) inmediatamente después de la entrada del callback de fileFilter,
antes de que se alcance storage._handleFile.
// callback de finalización de fileFilter (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PARCHE] Verificar errorOccured antes de continuar
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // drenar el flujo — no se escribe ningún archivo en el 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()
})
})
| Elemento | Vulnerable (< 2.1.0) | Parcheado (2.1.0) |
|---|---|---|
Verificación de errorOccured | ❌ No verificada | ✅ Verificada inmediatamente en la entrada del callback |
_handleFile llamado en caso de error | Sí | Bloqueado |
| Limpieza de archivos temporales | ❌ Faltante | ✅ Mediante fileStream.resume() |
| Archivos huérfanos por solicitud | 1 | 0 |
Por qué fileStream.resume() limpia:
Llamar a fileStream.resume() drena y descarta el flujo sin pasarlo a DiskStorage,
por lo que no se escribe ningún archivo y no queda nada en el disco.
El equipo de Multer incluyó la siguiente prueba Mocha en el parche 2.1.0 para verificar la corrección.
Esta prueba se convirtió en el modelo para este entorno de laboratorio — el servidor vulnerable
replica la configuración exacta descrita aquí (setImmediate fileFilter + solicitud multipart malformada),
y el comportamiento esperado se verifica tanto en las versiones vulnerable como parcheada.
/* 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('limpieza de fileFilter asíncrono', function () {
it('no deja archivos huérfanos cuando la solicitud se aborta con nombre de campo faltante', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Disparador de vulnerabilidad: fileFilter asíncrono mediante 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 })
})
// Manejador de errores: responder con 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'
// Cuerpo malicioso: Parte 1 válida, Parte 2 sin 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\nORPHAN FILE DATA\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; filename="b.bin"\r\n' + // ← name= faltante
'Content-Type: application/octet-stream\r\n\r\nx\r\n' +
'--' + boundary + '--\r\n'