Multipart Form-Data Injection в PHP и Python: как ломают парсеры и как блокировать атаки

Multipart Form-Data Injection в PHP и Python: как ломают парсеры и как блокировать атаки

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? Нет. Поддерживать все граничные случаи трудно; безопаснее обновлять и настраивать зрелые библиотеки.

Полезные ссылки и справочники

Вместо вывода: договоритесь с собой и с кодом

Инъекции в multipart держатся на неявных предположениях. Снимите их: определите политику по дублям, зафиксируйте допустимые имена и типы, включите лимиты и журналирование аномалий, добавьте несколько «грязных» автотестов. Это закрывает большую часть уязвимостей ещё на пороге. Остальное — вопрос регулярных обновлений библиотек и аккуратного обращения с файлами пользователей.

boundary form-data HTTP injection multipart PHP Python парсер
Alt text
Обращаем внимание, что все материалы в этом блоге представляют личное мнение их авторов. Редакция SecurityLab.ru не несет ответственности за точность, полноту и достоверность опубликованных данных. Вся информация предоставлена «как есть» и может не соответствовать официальной позиции компании.

А что, если жизнь на Земле — это ошибка?

Учёный показал: собрать живую клетку случайно невозможно. Тогда как это произошло? Читайте, почему мы до сих пор не знаем ответа на главный вопрос человечества.