Multipart/form-data часто воспринимают как «транспорт для файлов»: браузер собрал части, сервер разобрал, дальше бизнес-логика. Но именно на стыке формата, стандартов и библиотек рождаются неожиданные эффекты. Атакующий играет на нюансах: дубликаты полей, спорная трактовка boundary, особые параметры в Content-Disposition
, запутанные имена с «квадратными скобками». Итог — проверка проходит, а дальше в систему попадает совсем не то, что ожидалось.
Что такое multipart/form-data и где у него слабые места
multipart/form-data
разбивает содержимое на части (parts), которые разделены строкой-границей (boundary). У каждой части есть заголовки (обычно Content-Disposition
с name
и, при загрузке, filename
; иногда Content-Type
) и тело — либо текстовое значение, либо байты файла.
Ошибки и расхождения чаще всего касаются:
- распознавания границ (поведение при лишних/отсутствующих CRLF, пробелах, вложенных границах);
- обработки параметров заголовков (
filename
,filename*
, кавычки, экранирование, пробелы, кодировки); - дубликатов полей (что делать, если пришло несколько
role=...
); - интерпретации имён вроде
user[role]
(для одних это вложенная структура, для других — просто строка); - доверия к заявленному
Content-Type
вместо проверки содержимого; - ресурсных лимитов (много мелких частей, огромные заголовки, временные файлы).
Ниже — образец «сырых» данных запроса для ориентира:
POST /upload HTTP/1.1
Host: example.test
Content-Type: multipart/form-data; boundary=----AaB03x
Content-Length: 348
------AaB03x
Content-Disposition: form-data; name="title"
Фото с отпуска
------AaB03x
Content-Disposition: form-data; name="file"; filename="beach.jpg"
Content-Type: image/jpeg
<байты JPEG>
------AaB03x--
Что считать Multipart/Form-Data Injection
Под «инъекцией» здесь будем понимать любой способ сформировать тело multipart так, чтобы парсер сервера увидел структуру или значения не такими, как предполагает прикладной код. Цели — обойти валидацию, подменить ключевое поле, протащить файл с неожиданным именем/типом, истощить ресурсы парсера.
Ходы, которые используют чаще всего:
- Дубликаты имён — разные библиотеки по-разному решают, какое значение «главное»;
- Игра с границами — лишние CRLF, пробелы, «ступеньки» перед boundary, вложенные разделители;
- Параметры заголовков —
filename*
с RFC 5987, странные кавычки, пробелы вокруг «=»; - Имена как «структуры» —
user[role]
,files[]
, смешанные индексы; - Несоответствие типа — заявлен одно MIME, по сигнатуре — другое;
- Нагрузка на парсер — множество крошечных частей, гигантские заголовки, глубокие «массивы» ключей.
PHP: особенности парсинга и характерные ловушки
В PHP разбор выполняет движок и SAPI: разработчик получает $_POST
и $_FILES
. Это удобно, но есть нюансы:
- Преобразование имён в массивы. Ключи вида
user[role]
,files[]
превращаются во вложенные структуры. Если глубина не ограничена, можно нарушить ожидания валидации. - Дубликаты имен. Одно имя может стать списком значений; порядок не всегда очевиден и зависит от версии и SAPI.
- Имена файлов. Использовать «как прислали» — риск коллизий и path traversal. Файлы следует переименовывать, а «человеческое» имя хранить только как проверенные метаданные.
- Лимиты.
post_max_size
,upload_max_filesize
,max_file_uploads
,max_input_vars
,max_input_time
— без них сервер легко перегружается.
Пример подмены структуры через глубину ключей:
------AaB03x
Content-Disposition: form-data; name="user[role]"
user
------AaB03x
Content-Disposition: form-data; name="user[role][is_admin]"
1
------AaB03x--
Если в коде ожидали строку user[role]
, а пришла вложенная конструкция, проверка может не сработать, тогда как глубже по стеку значение используется как «истина».
Python: что по умолчанию делают Flask/Django/FastAPI и другие
В Python нет единого поведения для всех фреймворков. Общая картина:
- Дубликаты. В Flask/Werkzeug и Django доступен список значений (
getlist()
). Но многие берут первое попавшееся значение черезget()
, не осознавая стратегию выбора. - Имена-«массивы». Большинство Python-стеков трактуют имя как строку, без автоматического «разворачивания»
[]
. Это снижает магию, но перенос кода «из мира PHP» без переосмысления часто даёт ошибки. - Кодировки параметров.
filename*
(RFC 5987) поддерживается неравномерно; подробности экранирования и кавычек — источник граничных багов. - Буферизация. WSGI/ASGI-реализации по-разному буферизуют части; без ограничений легко устроить «медленный» отказ в обслуживании.
Техника атаки: короткие и наглядные примеры
Дубликаты поля: разные значения для проверки и для бизнес-логики
Отправляем одно имя дважды: «безопасное» для валидации, «опасное» — чтобы попасть в логику.
------AaB03x
Content-Disposition: form-data; name="role"
user
------AaB03x
Content-Disposition: form-data; name="role"
admin
------AaB03x--
В PHP это может стать массивом; во Flask/Django надо использовать getlist()
, иначе выбор значения — вопрос реализации. Несогласованность даёт шанс «нажать нужную кнопку».
Границы и пустые строки: когда парсер «теряет» часть
Лишние пустые строки или пробелы перед boundary могут привести к разному прочтению структуры.
Content-Type: multipart/form-data; boundary=----AaB03x
------AaB03x
Content-Disposition: form-data; name="meta"
ok
------AaB03x
Content-Disposition: form-data; name="file"; filename="x.php"
Content-Type: application/octet-stream
------AaB03x--
Один парсер воспримет пробелы как часть предыдущего тела, второй — как валидный разделитель. Валидация видит «только meta», а реальный файл просачивается ниже по стеку.
filename*
против filename
: кто главнее
Если фильтры ориентируются на filename
, а библиотека — на filename*
, имя можно навязать через RFC 5987:
Content-Disposition: form-data; name="file"; filename="safe.txt"; filename*=UTF-8''..%2F..%2Fvar%2Fwww%2Fhtml%2Fshell.php
Правильная защита — полностью игнорировать пользовательские пути и сохранять файл под сгенерированным именем.
Распространённый случай при миграциях: user[role][0]
------AaB03x
Content-Disposition: form-data; name="user[role][0]"
admin
------AaB03x--
Для PHP это массив, а часть логики может рассчитывать на строку. Несоответствие ожиданий — удобная щель для обхода проверки.
Несовпадение MIME и реального содержимого
Заявить image/png
в заголовке, но положить PDF в теле — трюк старый, но до сих пор рабочий там, где доверяют заголовку части, а не сигнатуре файла.
Content-Disposition: form-data; name="avatar"; filename="me.png"
Content-Type: image/png
%PDF-1.7
...
Много маленьких частей: DoS за счёт большого числа частей
Тысячи коротких частей с длинными заголовками, множество временных файлов, рост потребления CPU и диска — и приложение уже не отвечает. Если нет лимитов на количество частей и размеры, сценарий воспроизводится без изысков.
Как это тестировать: инструменты и подход
Поддерживайте рядом набор инструментов: удобнее проверять гипотезы, когда можно собрать «сырое» тело и посмотреть, что именно видит сервер.
- curl — базовая работа с multipart: дубликаты, явные boundary, «сырые» тела;
- Burp Suite — Repeater/Intruder для ручного редактирования и фуззинга;
- httpbin и webhook.site — чтобы увидеть, что реально ушло;
- Postman и HTTPie — сборка сложных форм и автотесты коллекциями.
Несколько приёмов с curl
:
# Дубликаты полей: --form можно повторять
curl -i -X POST https://target/upload
-F "role=user"
-F "role=admin"
-F "file=@beach.jpg;type=image/jpeg"
# Явный boundary и «сырое» тело
curl -i -X POST [https://target/upload](https://target/upload)
-H 'Content-Type: multipart/form-data; boundary=----AaB03x'
--data-binary @payload.txt
Чек-лист защиты (короткая версия)
Свод правил, который закрывает основные дыры ещё до прикладной логики:
- Дубликаты. Для критичных полей — отказ (HTTP 400) при наличии дублей. Для остальных — явная стратегия (например, «берём первое, остальные логируем»).
- Белые списки имён полей, ожидаемых типов и допустимых значений.
- Имена файлов. Никогда не используйте клиентское имя для пути на диске. Сохраняйте под своим идентификатором (UUID), «оригинал» — только как метаданные после очистки.
- Проверка содержимого по сигнатурам (magic numbers) и размеру; MIME из заголовка — вторично.
- Лимиты: размер тела, количество частей, размер заголовков, глубина ключей. Для «тяжёлых» эндпоинтов — отдельные, более строгие настройки.
- Стриминг и backpressure: не держите большие файлы в памяти; пишите потоково на диск/облако.
- Кодировки и экранирование параметров
Content-Disposition
; некорректные — в отказ. - Логи аномалий: фиксируйте дубликаты, пустые части, слишком длинные параметры, всплески 4xx.
- Регулярные обновления библиотек парсинга и отключение самописных «велосипедов».
PHP: практические настройки и приёмы
Начните с конфигурации и строгой обработки входящих данных:
post_max_size
,upload_max_filesize
,max_file_uploads
,max_input_vars
,max_input_time
— выставьте значения под вашу нагрузку и сценарии;upload_tmp_dir
— отдельный том с квотами, без права исполнения, с мониторингом места;- Переименовывайте файлы при сохранении, храните исходное имя только как очищенные метаданные;
- Ограничьте глубину «массивов» в именах ключей и допустимые шаблоны (
files[]
, не более N элементов); - Проверяйте содержимое через
finfo
и чтение «магических» байтов, не полагайтесь на$_FILES['type']
; - В фреймворках (Symfony/Laravel) используйте рекомендованные абстракции; не читайте
php://input
напрямую без необходимости.
Python: практические настройки и приёмы
Точки контроля — реверс-прокси, сервер приложений и код фреймворка:
- На уровне прокси (например, Nginx) задайте
client_max_body_size
,client_body_timeout
, буферы заголовков; - В приложении ограничьте размер тела и количество частей (мидлвари для Starlette/FastAPI, настройки Django);
- Работайте со списками значений (
getlist()
) и задайте политику по дублям; - Сохраняйте файлы потоково и под своим именем, проверяйте сигнатуры;
- Используйте валидаторы схем (Pydantic/Marshmallow) уже после своей «санитарной» обработки входа;
- Разведите эндпоинты: загрузки — отдельно и с более жёсткими лимитами и логированием.
Политика по дубликатам: сформулируйте один раз и закрепите тестами
Самая частая причина инцидентов — не поведение парсера, а неопределённость. Для ключевых полей выберите одно из двух: запретить дубликаты или детерминированно обрабатывать (например, брать только первое). Опишите это в контракте API и добавьте автотесты с «грязными» примерами.
Мини-тесты: как зафиксировать правила в коде
Ниже — эскиз теста для ASGI-приложения: проверяем, что дубликаты критичного поля режутся на входе.
<!-- псевдокод: идею легко перенести на ваш стек -->
def test_reject_duplicates(client):
boundary = "----AaB03x"
body = f"""------AaB03x
Content-Disposition: form-data; name="role"
user
------AaB03x
Content-Disposition: form-data; name="role"
admin
------AaB03x--"""
r = client.post(
"/upload",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
data=body.encode("utf-8"),
)
assert r.status_code == 400
Аналогично можно проверить лимиты по размеру, количеству частей и корректность обработки filename*
.
Реверс-прокси и сервер: где удобно «отрезать лишнее»
Часть проблем проще решать до приложения — на уровне Nginx/Apache и сервера приложений:
- Nginx:
client_max_body_size
,client_body_timeout
,client_body_buffer_size
,large_client_header_buffers
; отдельныйlocation
для загрузок с жёсткими лимитами; - Apache:
LimitRequestBody
,LimitRequestFieldSize
,LimitRequestFields
; - Uvicorn/Gunicorn: параметры воркеров и таймаутов; ограничение размеров через мидлвари/конфиг.
Что мониторить: быстрые индикаторы проблемы
Даже без явной атаки полезно видеть аномалии:
- всплеск числа частей на запрос и доли «пустых» частей;
- дубликаты имён у критичных полей (
role
,price
,plan
и т.п.); - необычно длинные параметры
filename
/filename*
и частота нестандартных кодировок; - рост 4xx на эндпоинтах загрузки, увеличение времени парсинга;
- рост использования диска в каталоге временных файлов.
Короткий FAQ
Нужно ли запрещать filename*
? Нет. Просто не используйте его содержимое для пути на диске. Сохраняйте под своим именем, а оригинал храните как проверенные метаданные.
Дубли полей — всегда ошибка? Не всегда. Для чекбоксов и тегов список значений — норма. Важно отделять такие поля от критичных и применять разные политики.
Достаточно ли смотреть на расширение файла? Нет. Проверьте сигнатуру и лимиты, а ещё — поведение downstream-процессоров (например, генераторов превью).
Стоит ли писать свой парсер multipart? Нет. Поддерживать все граничные случаи трудно; безопаснее обновлять и настраивать зрелые библиотеки.
Полезные ссылки и справочники
- Спецификация формата: RFC 7578 (multipart/form-data) , исторический RFC 2388 .
- Параметры с кодировками: RFC 5987 (
filename*
и др.). - PHP: Загрузка файлов .
- Werkzeug/Flask: MultiDict .
- Django: File Uploads .
- Starlette/FastAPI: Requests & File Uploads , Request Files .
- Инструменты: curl , Burp Suite , HTTPie , httpbin , webhook.site .
- OWASP: Unrestricted File Upload .
Вместо вывода: договоритесь с собой и с кодом
Инъекции в multipart держатся на неявных предположениях. Снимите их: определите политику по дублям, зафиксируйте допустимые имена и типы, включите лимиты и журналирование аномалий, добавьте несколько «грязных» автотестов. Это закрывает большую часть уязвимостей ещё на пороге. Остальное — вопрос регулярных обновлений библиотек и аккуратного обращения с файлами пользователей.