Веб-шелл в папке uploads: как найти чужой php среди своих
Если на сайте есть форма загрузки файлов — аватар, вложение к заявке, картинка товара — и папка, куда она пишет, находится внутри веб-корня, у вас есть потенциальная точка входа для веб-шелла. Это не экзотика: связка «форма не проверяет содержимое файла» плюс «веб-сервер исполняет PHP из любой директории» встречается на самых обычных сайтах, включая свежепоставленные CMS. Разберём, как проверить свой сервер за 20-30 минут и как настроить его так, чтобы даже успешно загруженный чужой файл остался мёртвым куском текста, а не исполнялся.
Содержание
Почему веб-шелл вообще может оказаться в uploads
Механика почти всегда состоит из двух независимых проблем, каждая из которых сама по себе не критична, а вместе дают полное выполнение кода на сервере.
Первая — уязвимая форма загрузки. Она проверяет тип файла не по содержимому, а по чему-то, что легко подделать: расширению в имени файла, заголовку Content-Type, который целиком контролируется отправителем запроса, или короткому чёрному списку расширений, из которого забыли какой-нибудь .phtml или .php7. Иногда проверка смотрит только на последний фрагмент после точки, и файл с именем avatar.php.jpg благополучно проходит как «джейпег».
Вторая — конфигурация веб-сервера, обрабатывающая .php-файлы глобально, без исключений для директорий загрузок. Типичный блок вида location ~ \.php$ { fastcgi_pass ...; } в nginx запустит через php-fpm любой файл с нужным расширением, где бы он ни лежал — хоть в wp-content/uploads, хоть в public/avatars.
По отдельности ни одна из проблем не смертельна: дырявая форма без исполнения PHP в uploads — просто мусорный файл на диске, который никто не запустит. Но вместе это открытая дверь — загрузить произвольный код и тут же выполнить его по прямой ссылке вида https://example.com/uploads/2026/08/avatar.php. Подробный разбор одного такого случая, где всё закончилось рассылкой фишинга с сервера, есть в статье про веб-шелл, который нашли в папке загрузок — там видно, как эти два фактора сложились на практике.
Поиск подозрительных файлов: расширение и дата изменения
Первый и самый быстрый способ — посмотреть, какие исполняемые файлы вообще лежат там, где их быть не должно. В норме в папке для пользовательских загрузок не должно быть ни одного .php, .phtml, .php5, .php7 или .pht файла:
find /var/www -path "*/uploads/*" -regextype posix-extended \
-iregex ".*\.(php[0-9]?|phtml|pht|phar)(\..+)?$"
Если у вас не WordPress, замените uploads на реальное имя папки — storage/app/public, media, attachments. Стоит проверить все точки, куда приложение вообще что-то пишет, а не только очевидный «uploads» в URL.
Отдельно ищите файлы, где реальное содержимое не совпадает с расширением — .php с именем .jpg в конце этот поиск не поймает, но поймает проверка magic bytes через file:
find /var/www -path "*/uploads/*" -type f -exec file {} \; | grep -i "php script"
Второй ключевой фильтр — дата изменения. Если атака свежая, вредоносный файл почти всегда выделяется на фоне остального содержимого папки, которое обычно не трогают месяцами:
find /var/www -path "*/uploads/*" -type f -newermt "7 days ago" -name "*.php"
find /var/www -path "*/uploads/*" -type f -mtime -3 -printf "%T@ %p\n" | sort -n
-printf "%T@ %p\n" и sort -n выводят файлы в хронологическом порядке — если несколько подозрительных файлов появились с разницей в секунды или минуты, это почти наверняка автоматизированная атака или закрепление (злоумышленник кладёт несколько копий на случай, если одну найдут). Стоит также сверить mtime с ctime через stat: иногда время модификации подделывают через touch, чтобы файл не бросался в глаза при сортировке по дате, но время изменения метаданных (ctime) подделать без root-доступа сложнее.
stat --format="%Y %Z %n" /var/www/example.com/uploads/2026/08/*.php 2>/dev/null
Если mtime искусственно занижен (выставлен на полгода назад), а ctime свежий — это тревожный признак.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПоиск по характерным паттернам кода
Расширение и дата — быстрый фильтр, но не единственный: злоумышленник может назвать файл максимально безобидно и выставить старую дату. Поэтому вторым слоем стоит грепать по содержимому на характерные для веб-шеллов конструкции — не по сигнатурам конкретных вредоносов (они меняются быстрее, чем их заносят в базы), а по функциям, которые легитимному пользовательскому контенту не нужны в принципе.
grep -rlE "eval\s*\(|base64_decode\s*\(|assert\s*\(|system\s*\(|shell_exec\s*\(|passthru\s*\(|proc_open\s*\(" /var/www/*/uploads/ 2>/dev/null
eval() и assert() с динамическим аргументом выполняют произвольный код, base64_decode() часто используется, чтобы спрятать тело шелла от беглого просмотра, system(), shell_exec(), passthru() и proc_open() выполняют команды ОС. Ни одна из этих функций сама по себе не доказательство заражения — base64_decode легитимен и используется, например, для вложений. Но в папке, куда должны попадать только медиафайлы, любое совпадение — повод открыть файл и посмотреть глазами (не запускать).
Обычный загруженный файл — картинка, PDF — никогда не содержит обращений к суперглобальным массивам PHP, потому что не является PHP-кодом вообще:
grep -rlE "\\\$_(GET|POST|REQUEST|COOKIE)\s*\[" /var/www/*/uploads/ 2>/dev/null
Можно объединить оба признака и вывести файлы, отсортированные по числу совпадений — так удобнее выбрать, что смотреть в первую очередь:
for f in $(find /var/www -path "*/uploads/*" -type f \( -name "*.php" -o -name "*.phtml" \)); do
count=$(grep -ocE "eval\(|base64_decode\(|assert\(|system\(|\\\$_(GET|POST|REQUEST)" "$f")
[ "$count" -gt 0 ] && echo "$count $f"
done | sort -rn
Обфусцированный веб-шелл обычно выглядит как одна длинная нечитаемая строка, иногда с несколькими уровнями вложенного base64_decode или gzinflate, что для настоящей картинки или документа абсолютно неестественно.
Что делать, если файл нашли
Порядок действий имеет значение — не удаляйте файл первым делом, если хотите разобраться в масштабе заражения.
- Сохраните копию файла в изолированную директорию вне веб-корня, чтобы разобраться, что он делал и через какую форму попал на сервер.
- Проверьте логи веб-сервера на обращения к файлу —
grep "имя_файла" /var/log/nginx/access.logпокажет, кто и как часто к нему обращался. - Поищите похожие файлы тем же способом — почти всегда рядом с одним шеллом лежит ещё один-два про запас.
- Проверьте cron и systemd-таймеры пользователя веб-сервера на закрепление —
crontab -u www-data -l. - Удалите файл только после того, как разобрались, откуда он взялся, — иначе через день-два появится новый с другим именем через ту же дыру.
Если ситуация вышла за рамки одного файла — есть подозрение, что через шелл читали конфиги или сервер уже используется для атак на других, — стоит следовать более широкому плану, он разобран в статье что делать при взломе сервера.
Правильная настройка веб-сервера: запрет исполнения в uploads
Поиск и удаление отдельных файлов — лечение симптома. Настоящая защита — сделать так, чтобы веб-сервер физически отказывался исполнять PHP из директорий, куда пишут пользователи, независимо от того, что туда попало. Тогда даже успешно загруженный шелл останется безобидным текстом.
Для nginx правильный подход — явный блок с более высоким приоритетом, чем общий обработчик .php, через модификатор ^~ (останавливает дальнейший поиск по regex, если префикс совпал):
# Общий обработчик PHP
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Явный запрет исполнения PHP в загрузках — должен идти после общего блока
location ^~ /wp-content/uploads/ {
location ~ \.php$ {
deny all;
return 403;
}
}
Если директорий с пользовательским контентом несколько, лучше завести отдельный явный блок на каждую — читается понятнее и меньше рискует ошибкой в regex, чем один общий:
location ^~ /storage/app/public/ {
location ~ \.php$ { deny all; return 403; }
}
Для Apache аналогичный результат даёт блок <Directory> в конфиге виртуального хоста — так правило не зависит от того, разрешён ли .htaccess и не удалит ли его случайно деплой-скрипт:
<Directory "/var/www/example.com/wp-content/uploads">
<FilesMatch "\.(php|phtml|php[0-9]|pht|phar)$">
Require all denied
</FilesMatch>
</Directory>
После правки проверьте синтаксис и перечитайте конфиг без разрыва активных соединений:
nginx -t && systemctl reload nginx
apachectl configtest && systemctl reload apache2
Сразу проверьте, что правило работает — положите тестовый файл и попробуйте открыть его по прямой ссылке:
echo '<?php echo "test"; ?>' > /var/www/example.com/uploads/test.php
curl -I https://example.com/uploads/test.php
Если сервер настроен правильно, вы получите 403 Forbidden, а не 200 OK с выводом test. Не забудьте удалить тестовый файл после проверки. Если на сервере несколько сайтов, эту проверку стоит гонять при каждом новом сайте — удобно свериться с чек-листом безопасности нового сервера, где собраны и другие базовые пункты harden'а.
Права на файлы и профилактика
Запрет исполнения PHP на уровне веб-сервера — главный барьер, но не единственный. Несколько дополнительных мер снижают риск, даже если основной барьер по какой-то причине окажется неполным.
Ограничьте права на запись и исполнение. Процесс веб-сервера должен писать в uploads, но не должен иметь права на исполнение за её пределами; уберите бит исполнения у самих загруженных файлов:
find /var/www/example.com/uploads -type f -exec chmod 644 {} \;
find /var/www/example.com/uploads -type d -exec chmod 755 {} \;
Это не заменяет запрет на уровне веб-сервера (php-fpm всё равно может выполнить файл с правами 644, если веб-сервер его направит), но убирает прямое исполнение через другой возможный вектор — например, забытый mod_cgi.
Проверяйте содержимое файла на уровне приложения, а не только расширение или Content-Type. finfo_file() читает magic bytes независимо от того, что написано в имени файла:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $uploaded_file_path);
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!in_array($mime, $allowed, true)) {
// отклонить загрузку
}
Переименовывайте загруженные файлы на сервере — генерируйте случайное имя вместо присланного пользователем, а расширение сопоставляйте с определённым по содержимому MIME-типом из явного маппинга. Это убирает трюки с двойными расширениями.
Автоматизируйте проверку. Разовая находка не защищает от новой формы загрузки в следующем обновлении CMS. Простой cron-скрипт с уведомлением снимает вопрос «а не пропустили ли мы что-то»:
#!/bin/bash
# /usr/local/bin/check-uploads-php.sh
FOUND=$(find /var/www -path "*/uploads/*" \( -name "*.php" -o -name "*.phtml" -o -name "*.pht" \) 2>/dev/null)
if [ -n "$FOUND" ]; then
echo "$FOUND" | mail -s "ALERT: PHP в uploads на $(hostname)" admin@example.com
fi
0 * * * * /usr/local/bin/check-uploads-php.sh
На посещаемом сайте с активной формой загрузки имеет смысл проверять каждый час, а не раз в сутки. Если веб-шелл используется не только для загрузки, но и для чего-то более активного, симптомы будут шире одного файла в uploads — их разбор в статье как проверить сервер на майнер и вирусы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Antivirus или ClamAV поймает веб-шелл автоматически?
Не гарантированно. Сигнатурный антивирус ищет известные образцы, а обфусцированный веб-шелл с eval(base64_decode(...)) — синтаксически легитимный PHP-код, просто использованный во вред. Некоторые распространённые шеллы попадают в базы сигнатур, кастомные или слегка изменённые — нет. Полагаться стоит на контроль за тем, что вообще может исполняться в определённых директориях, а не только на антивирус.
Если запретить исполнение PHP в uploads на уровне nginx, можно ли не трогать код формы загрузки?
Запрет на уровне веб-сервера закрывает главный риск — исполнение кода. Но дырявая форма всё равно оставляет возможность залить произвольный файл на диск, что само по себе занимает место и может использоваться иначе (например, для фишинговой HTML-страницы без исполнения PHP). Правильно закрывать обе стороны, а не одну.
Как быть, если приложению реально нужно исполнять пользовательские скрипты — это платформа для разработчиков?
Такие случаи — отдельная архитектурная задача: исполнение пользовательского кода стоит выносить в изолированную среду (контейнер, sandbox, процесс с урезанными правами), а не запускать в контексте основного веб-приложения с доступом к его файлам и базе данных. Принцип «uploads не исполняется» здесь не отменяется, исполнение просто переносится в контролируемое место.
Нужно ли то же самое для не-PHP стека — Node.js, Python?
Логика та же, реализация другая. В Node.js обычно нет прямого аналога «сервер исполняет любой файл с нужным расширением», потому что маршруты определяются явно в коде. Но риск остаётся, если приложение само eval()-ит содержимое загруженных файлов или если статика отдаётся тем же процессом, что исполняет код по неймспейсу путей.
Как часто проверять сервер, если сайт небольшой и малопосещаемый?
Автоматическое сканирование ботами не зависит от посещаемости людьми — уязвимые формы находят массовым перебором независимо от того, сколько у вас живых пользователей. Регулярная еженедельная проверка через cron не требует ресурсов и снимает вопрос практически бесплатно.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →