Загрузка файлов без веб-шелла: защита upload-формы
Файл, который пользователь отправляет на сайт, может стать исполняемым скриптом, XSS-пayload или причиной отказа в обслуживании. Разбираем защиту upload-формы на PHP 8.3+: от хранения до проверки содержимого.

Загрузка файла превращается в уязвимость не в тот момент, когда сервер принимает расширение .php, а раньше: когда приложение доверяет имени, MIME-типу или каталогу хранения. Ниже — практическая схема для PHP 8.3+ и обычного веб-приложения: как принимать файлы, где их хранить, что проверять и почему одного запрета на .php недостаточно.
Сначала уберите файл из зоны исполнения
Главное правило — пользовательский файл не должен лежать в каталоге, из которого веб-сервер может выполнить скрипт. Лучше хранить загрузки за пределами document root, например в /srv/app-storage/uploads, а скачивание отдавать через контроллер приложения. Тогда даже файл с содержимым PHP не получает прямого URL для исполнения.
Если файл должен быть доступен по прямой ссылке, настройте отдельный каталог так, чтобы сервер отдавал его только как данные. Конкретная конфигурация зависит от Nginx, Apache, PHP-FPM и способа деплоя; проверяйте её на тестовом стенде запросом к файлу с расширением, которое ваш сервер обычно исполняет. Важна не запись «запретить PHP», а подтверждение, что сервер действительно возвращает файл или отказ, но не запускает интерпретатор.
Самая надёжная проверка upload-системы — не вопрос «какое расширение разрешено», а вопрос «может ли загруженный байт-код попасть в цепочку исполнения».
Что проверять до сохранения
Проверки должны идти в несколько слоёв. Имя файла от пользователя нельзя использовать как имя на диске: в нём могут быть управляющие символы, неожиданные расширения и попытки обойти нормализацию пути. Сгенерируйте собственное имя, например случайный идентификатор, а исходное имя сохраните только как метаданные после очистки.
- Проверьте ошибку загрузки. В PHP поле
errorдолжно быть равноUPLOAD_ERR_OK. Иначе файл мог не загрузиться полностью или превысить лимит запроса. - Ограничьте размер. Проверяйте его в приложении и согласуйте лимиты формы, PHP и веб-сервера. Иначе пользователь увидит отказ на другом слое и не поймёт причину.
- Определите MIME по содержимому. Значение заголовка
Content-Typeприходит от клиента и не считается доказательством. - Сопоставьте MIME с разрешённым расширением. Расширение генерируйте сами, а не берите из оригинального имени.
- Проверьте формат специализированным парсером. Для изображения одного MIME мало: декодируйте файл библиотекой изображений, проверьте размеры и при необходимости пересохраните его.
- Переместите файл атомарно и зафиксируйте результат. В базе храните внутренний идентификатор, MIME, размер, хеш и статус проверки, но не используйте пользовательское имя в пути.
Минимальная проверка в PHP 8.3+
Фрагмент ниже проверяет обычную загрузку через multipart/form-data, размер, реальный MIME и перемещает файл в каталог вне document root. Это не универсальный валидатор изображений, PDF или архивов: для таких форматов нужен отдельный парсер.
<?php
function saveUploadedDocument(array $file, string $storageDirectory): array
{
$allowedTypes = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
$maximumSize = 10 * 1024 * 1024;
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Файл не загрузился');
}
if (!isset($file['tmp_name'], $file['size']) || !is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException('Некорректный временный файл');
}
if ($file['size'] < 1 || $file['size'] > $maximumSize) {
throw new RuntimeException('Недопустимый размер файла');
}
$fileInfo = finfo_open(FILEINFO_MIME_TYPE);
if ($fileInfo === false) {
throw new RuntimeException('Не удалось определить MIME-тип');
}
$mimeType = finfo_file($fileInfo, $file['tmp_name']);
finfo_close($fileInfo);
if (!is_string($mimeType) || !isset($allowedTypes[$mimeType])) {
throw new RuntimeException('Недопустимый формат файла');
}
$storedName = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mimeType];
$storedPath = rtrim($storageDirectory, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . $storedName;
if (!move_uploaded_file($file['tmp_name'], $storedPath)) {
throw new RuntimeException('Не удалось сохранить файл');
}
return [
'name' => $storedName,
'mime' => $mimeType,
'size' => (int) $file['size'],
];
}Путь $storageDirectory должен существовать, принадлежать пользователю процесса PHP и находиться вне публичного каталога. Перед использованием добавьте авторизацию, CSRF-защиту и проверку того, кому принадлежит загружаемый объект. MIME-проверка не заменяет антивирус и не доказывает, что формат безопасен для любого последующего обработчика.
Почему запрет расширения не спасает
Проверка имени вроде «если строка заканчивается на .php, отказать» закрывает только один сценарий. На практике опасность возникает из-за сочетания нескольких ошибок.
| Подход | Что он закрывает | Где ломается |
|---|---|---|
Запретить .php | Очевидную загрузку PHP-скрипта | Не защищает от двойных расширений, SVG, подмены MIME и неверной настройки сервера |
Проверять заголовок Content-Type | Отсекает часть случайных файлов | Значение отправляет клиент и его легко изменить |
| Проверять MIME по содержимому | Отсекает файлы, не похожие на разрешённый формат | Не решает проблему исполнения и уязвимостей парсера |
| Хранить вне document root | Убирает прямое исполнение через веб-сервер | Не защищает от вредоносного содержимого при скачивании или обработке |
| Разрешать фиксированный список форматов и генерировать имя | Снижает поверхность атаки | Требует отдельной проверки каждого формата и контроля доступа |
Особенно опасны SVG и HTML-подобные форматы. SVG может содержать сценарии и внешние ссылки; если браузер отдаёт его с неподходящим MIME или в контексте вашего домена, загрузка превращается в XSS. Если SVG не нужен, не принимайте его. Если нужен — очищайте проверенным специализированным инструментом и отдавайте с корректными заголовками, лучше с отдельного домена.
Проверка формата: от расширения к структуре
Изображения
После определения MIME попробуйте открыть изображение библиотекой, которую приложение использует для обработки. Проверьте максимальные ширину, высоту и общий объём декодированных данных: небольшой файл может потребовать много памяти после распаковки. Для пользовательских фотографий безопаснее создать новое изображение из декодированного содержимого, удалить метаданные и сохранить в нужном формате. Исходник при этом не должен автоматически становиться публичным.
PDF и документы
Не открывайте загруженный PDF в административном интерфейсе без необходимости и не передавайте его непроверенным конвертерам. Конвертация — отдельная граница безопасности: запуск внешнего процесса требует ограничения прав, времени, памяти и сетевого доступа. Если файл нужен только для скачивания, не преобразуйте его без причины.
Архивы
Архивы требуют проверки содержимого после распаковки. Нельзя бездумно извлекать путь вроде ../../config.php или создавать тысячи файлов из небольшого архива. Нормализуйте каждый путь, запрещайте выход за целевой каталог, ограничивайте число файлов и итоговый размер. Если архив не нужен по бизнес-сценарию, не добавляйте его поддержку: это дешевле любой последующей защиты.
Сноска. Веб-шелл — загруженный или внедрённый серверный скрипт, через который злоумышленник пытается выполнять команды или управлять файлами через HTTP. Для его появления не всегда нужен полноценный файл .php: достаточно ошибки в маршрутизации, обработчике или настройке сервера.
Что ломается в продакшене
- Лимиты расходятся. Nginx может отклонить запрос раньше PHP, а PHP — раньше приложения. Показывайте пользователю понятную ошибку и логируйте причину без записи содержимого файла.
- Временный каталог переполняется. До проверки файл уже лежит на диске. Ограничьте общий объём, очищайте неиспользованные временные файлы и следите за свободным местом.
- Файл доступен по предсказуемому URL. Случайное имя снижает угадывание, но не заменяет авторизацию. Приватный документ отдавайте после проверки прав.
- Обработчик доверяет расширению. Генераторы превью, офисные конвертеры и индексаторы могут иметь собственные уязвимости. Запускайте их с минимальными правами и не давайте им доступ к секретам приложения.
- Проверка происходит после публикации. Сначала сохраняйте объект в карантин, затем проверяйте и только после успешного результата переводите его в статус доступного.
Что делать дальше
Сначала составьте таблицу разрешённых форматов: MIME, расширение, максимальный размер, способ проверки и необходимость публичного доступа. Затем создайте тесты для пустого файла, превышения лимита, неверного MIME, двойного расширения, SVG, архива с выходом из каталога и файла с исполняемым содержимым. После деплоя измеряйте количество отклонённых загрузок, ошибки парсеров, заполнение временного хранилища и обращения к файлам без права доступа.
Отдельно проверьте серверную конфигурацию на тестовом домене: загруженный файл не должен исполняться ни по исходному имени, ни по сгенерированному. Инвалидация уже загруженных файлов, антивирусное сканирование и безопасная раздача через CDN — следующие задачи, а не замена базовой изоляции.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: OWASP File Upload Cheat Sheet, PHP Manual: Handling file uploads
Частые вопросы
Достаточно ли запретить расширения php, phtml и phar?
Можно ли хранить пользовательские файлы в public/uploads?
Почему нельзя доверять Content-Type из формы?
Нужен ли антивирус для каждой загрузки?
Что делать, если файл уже попал в публичный каталог?
Можно ли разрешить SVG и архивы без отдельной инфраструктуры?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.