
# Лаборатория воспроизведения CVE-2026-3304 Лаборатория воспроизведения для CVE-2026-3304 — гонки в асинхронном fileFilter в Multer, приводящей к исчерпанию диска через осиротевшие временные файлы. Включает уязвимые и пропатченные Docker-серверы, эксплойт-скрипты и анализ первопричины.
Только для образовательных и исследовательских целей. Атака на системы без авторизации незаконна.
Эта лабораторная среда была создана на основе анализа патча 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 |
Некорректный multipart-запрос с отсутствующим атрибутом name в файловой части приводит к тому, что Multer создает временный файл на диске, но никогда его не удаляет. Повторные запросы исчерпывают дисковое пространство, что приводит к отказу в обслуживании.
Уязвимость возникает в потоке обработки обратного вызова fileFilter внутри multer/lib/make-middleware.js.
Когда Multer потоково обрабатывает и разбирает multipart-запрос по частям, происходит следующая последовательность:
[Последовательность событий разбора — уязвимая версия]
1. Получен заголовок части 1
→ вызывается fileFilter(req, file, cb)
→ setImmediate(cb) → обратный вызов откладывается до следующего тика цикла событий
2. Получено тело части 1
→ открывается /tmp/uploads/<uuid>, начинается запись данных
3. Получен заголовок части 2 (атрибут name отсутствует)
→ Multer обнаруживает 'name missing'
→ errorOccured = true ← установлен флаг ошибки
→ вызывается abortWithCode('LIMIT_FIELD_KEY') → запланирован HTTP 500
4. Срабатывает обратный вызов setImmediate (следующий тик цикла событий)
→ результат fileFilter: includeFile = true (обычный поток)
→ [ОШИБКА] флаг 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 является проблемой?
Обертывание fileFilter в setImmediate откладывает его обратный вызов до следующего тика цикла событий.
В этом окне busboy (парсер multipart) продолжает разбирать заголовки следующей части,
обнаруживает отсутствующий name и устанавливает errorOccured = true.
Когда обратный вызов возобновляется, состояние ошибки уже установлено — но код никогда его не проверяет,
поэтому storage._handleFile вызывается безусловно, и временный файл записывается на диск.
Коммит исправления: 739919097d
Одна проверка if (errorOccured) была добавлена сразу после входа в обратный вызов fileFilter,
до достижения storage._handleFile.
// Обратный вызов завершения 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 включила следующий тест Mocha в патч 2.1.0 для проверки исправления.
Этот тест стал основой для данной лабораторной среды — уязвимый сервер
воспроизводит точную конфигурацию, описанную здесь (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)