Сервер рассылал фишинг: веб-шелл нашли в папке загрузок
В конце августа 2026 года к нам пришла заявка на разбор классического, но от этого не менее болезненного инцидента: сайт на VPS вдруг начал рассылать фишинговые письма от имени компании, IP улетел в чёрные списки, а легитимная почта с домена перестала доходить до части получателей. Разбираем по шагам, что случилось, какие версии отпали и что реально было причиной — чтобы вы могли проверить свой сервер до того, как это случится с вами.
Содержание
Что сломалось
Утром служба поддержки клиента получила сразу несколько писем от разных получателей с одним и тем же вопросом: «Вы правда просите меня перейти по ссылке и ввести пароль от почты?». Параллельно администратор увидел в панели хостинга алерт о резком росте исходящего трафика на 25-м порту — том самом, что обычно используется для SMTP. Сайт при этом продолжал открываться нормально, ошибок 500 не было, тесты доступности (uptime-мониторинг) не срабатывали — с точки зрения обычных проверок «всё зелёное».
Дальше события развивались быстро. Через несколько часов домен появился в списке рассылки Spamhaus, и часть корпоративной почты клиента (которая шла с того же IP через тот же почтовый релей) начала отбиваться у получателей на Gmail и Outlook с пометкой «подозрительный отправитель». То есть боевая инфраструктура и рассылка фишинга оказались физически на одном сервере и с одного IP — типичная ситуация для небольших проектов, где сайт, CRM и почта живут на одной машине ради экономии.
Первая реакция была разумной, но не совсем точной: решили, что взломали почтовый сервер напрямую — либо подобрали пароль к SMTP-аккаунту, либо кто-то украл учётные данные. Эта гипотеза продержалась около часа, пока не стали смотреть логи.
Что показали логи и метрики
Начали с самого дешёвого способа проверки — очереди исходящей почты:
postqueue -p | tail -30
mailq | grep -c '^[A-Z0-9]'
Очередь была забита тысячами писем с случайными адресами получателей в доменах вроде mail.ru, yandex.ru, gmail.com — то есть массовая рассылка, а не единичные подозрительные письма. Дальше посмотрели, кто вообще инициирует отправку — с этим помог лог самого MTA:
journalctl -u postfix --since "-6 hours" | grep -i "from=" | head -50
В логах постоянно фигурировал один и тот же локальный отправитель — не почтовый клиент, не CRM, а php-fpm-процесс, вызывающий локальный sendmail. Это уже сузило круг поиска: письма формировались не через SMTP-авторизацию снаружи, а изнутри сервера, через PHP-скрипт, который дергал системный mail().
Следующий шаг — посмотреть, какие файлы на сервере обращались к sendmail в последнее время. Здесь очень выручил аудит через auditd, который на этом сервере, к счастью, был включён ещё при первичной настройке:
ausearch -x /usr/sbin/sendmail --start recent | grep exe
Вывод показал конкретный PHP-файл, вызывающий бинарник рассылки, с путём внутри wp-content/uploads/2026/08/. Это уже прямая улика — в папке для загруженных изображений и документов оказался исполняемый PHP-скрипт.
Параллельно с этим в логах nginx нашли характерный паттерн — регулярные POST-запросы к этому же файлу с интервалом в несколько секунд, круглосуточно:
grep "uploads/2026/08" /var/log/nginx/access.log | grep POST | wc -l
Счётчик показал больше 40 тысяч обращений за три дня — то есть кто-то извне регулярно «дергал» этот файл, чтобы он продолжал слать письма новыми порциями. Загрузка CPU от php-fpm воркеров была стабильно выше обычного, но не настолько заметно, чтобы автоматический мониторинг нагрузки поднял тревогу — рост был плавным, а не скачкообразным.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые не подтвердились
Прежде чем найти реальную причину, отработали несколько версий, каждую из которых стоило проверить, но ни одна не оказалась источником проблемы.
Утечка пароля от SMTP. Проверили журналы аутентификации почтового сервиса — journalctl -u postfix | grep sasl_login_failed и grep "authentication failed" /var/log/mail.log. Не нашли ни одной успешной авторизации с незнакомых IP и ни одного всплеска неудачных попыток входа. Гипотезу отбросили — компрометации пароля не было, письма формировались локально, а не через внешний SMTP-логин.
Открытый релей на почтовом сервере. Проверили конфигурацию Postfix на предмет smtpd_relay_restrictions и mynetworks — релей был закрыт правильно, сторонние IP не могли использовать сервер как ретранслятор напрямую через SMTP. Это тоже сняло вопрос — проблема была не в настройке почтового сервиса.
Скомпрометированный админ-доступ к сайту. Проверили wp-content/debug.log, историю входов в админку CMS и список активных сессий. Ни одного входа с незнакомого IP, пароль администратора не менялся, двухфакторная аутентификация (там, где она была включена) не показывала попыток обхода. То есть злоумышленник не заходил через панель управления сайтом — не подбирал пароль и не пользовался украденной сессией.
Уязвимость в стороннем плагине через автообновление. Проверили список установленных плагинов и даты их последнего обновления — версии были актуальными, в списке CVE для установленных версий ничего критичного за последние недели не значилось. Версия оказалась близкой к истине (реальная дыра была смежной по духу — в форме загрузки), но конкретно уязвимость через сам механизм обновления плагинов не подтвердилась.
Каждая из этих проверок заняла от 15 до 40 минут, но именно последовательное исключение версий и привело к правильному следу — файлу в папке загрузок, который логи уже подсветили как главного подозреваемого.
Как нашли веб-шелл
Открыли сам файл — и сразу стало ясно, с чем имеем дело. Внутри был PHP-скрипт с обфусцированным (закодированным через base64_decode и eval) телом — классический паттерн простого веб-шелла с функцией массовой рассылки писем. Часть кода отвечала за приём параметров через GET-запрос (список получателей, тему письма, текст со ссылкой на фишинговую страницу), другая часть — за собственно вызов mail().
Чтобы понять масштаб, поискали похожие файлы по всей файловой системе — по характерным сигнатурам и по недавнему времени изменения:
find /var/www -type f -name "*.php" -newermt "2026-08-01" -not -path "*/wp-includes/*" -not -path "*/wp-admin/*"
grep -rl "eval(base64_decode" /var/www/*/wp-content/uploads/ 2>/dev/null
Нашли ещё два похожих файла в той же папке загрузок с разными именами — вероятно, копии на случай, если одну из них найдут и удалят. Проверили дату создания через stat — все три файла появились в течение одного часа три недели назад, то есть заражение произошло задолго до того, как кто-то заметил проблему, а активная рассылка началась позже, когда злоумышленник, видимо, продал или начал использовать доступ.
Отдельно проверили crontab и systemd-таймеры на предмет закрепления — оказалось, что закрепления через cron не было: атакующий просто периодически обращался к файлу напрямую по URL, о чём и говорили те самые 40 тысяч POST-запросов в логах nginx. Не самый скрытный способ, но рабочий, пока никто не смотрит в логи.
Реальная причина
Корень проблемы — в форме загрузки изображений на сайте (стандартная форма аватара пользователя в одном из плагинов), которая проверяла только расширение файла по MIME-типу, присланному самим браузером в заголовке запроса, а не по реальному содержимому файла. Заголовок Content-Type полностью контролируется тем, кто отправляет запрос, — доверять ему при проверке загружаемых файлов нельзя в принципе.
Но одной дырявой формы обычно недостаточно, чтобы дело дошло до исполнения кода, — нужен ещё один фактор: сама папка uploads в конфиге nginx не была исключена из обработки PHP. Стандартный location ~ \.php$ в конфигурации веб-сервера обрабатывал абсолютно любой .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-файлам сайта без исключений. В сочетании с формой, которая пропускала файлы с двойным расширением вроде avatar.php.jpg (некоторые проверки смотрят только на последний фрагмент после точки и ошибочно считают его расширением, а веб-сервер запускает файл по любому вхождению .php в пути), получилась связка «загрузить произвольный код» + «выполнить произвольный код» — ровно то, что нужно для веб-шелла.
Отдельно стоит сказать про права на файлы: папка uploads была доступна на запись процессу www-data, что абсолютно нормально для загрузки картинок, но в сочетании с возможностью исполнения PHP превращает любую дыру в загрузке файлов в полноценное выполнение кода на сервере.
Что изменили после инцидента
Первым делом остановили дальнейшую рассылку и убрали немедленную угрозу, затем закрыли саму дыру, и только после этого занялись репутацией IP и восстановлением доверия к домену.
Немедленные действия:
- Удалили найденные веб-шеллы, сохранив копии для разбора в изолированную директорию.
- Сбросили все пароли администраторов сайта и API-ключи сторонних интеграций — на случай, если через тот же веб-шелл читались файлы конфигурации.
- Проверили
wp-config.php(и аналогичные файлы конфигурации) на предмет изменений даты модификации.
Закрыли исполнение PHP в папках загрузок — на уровне nginx, отдельным блоком с более высоким приоритетом, чем общий обработчик PHP:
location ^~ /wp-content/uploads/ {
location ~ \.php$ {
deny all;
return 403;
}
}
Такой блок гарантирует, что даже если в папку загрузок каким-то образом попадёт исполняемый файл, веб-сервер просто откажется его выполнять и отдаст 403 вместо запуска через php-fpm.
Добавили проверку содержимого файла, а не только расширения — на уровне приложения через finfo_file() (проверка магических байт файла) вместо доверия заголовку Content-Type от браузера, плюс ограничили набор разрешённых расширений явным белым списком без двойных точек.
Настроили мониторинг исходящей почты как метрику, а не просто как «фоновый сервис»: длина очереди Postfix и число исходящих SMTP-соединений в минуту теперь идут в общий мониторинг, с алертом при отклонении от привычного профиля. До инцидента считалось, что за почтой «и так следит postfix», но на деле никто не смотрел на объём отправки как на индикатор — а зря, это один из самых ранних и надёжных сигналов компрометации.
Занялись репутацией IP и домена:
- Подали запросы на исключение из чёрных списков (Spamhaus и аналогичные) — это заняло от нескольких часов до пары дней в зависимости от списка.
- Настроили SPF, DKIM и DMARC заново, чтобы явно подтвердить, какие сервера имеют право слать почту от имени домена, — это снижает шанс, что скомпрометированный сервер сможет незаметно продолжить рассылку от имени домена в будущем.
- Пересмотрели вопрос совместного использования IP: если сайт, почта и внутренние сервисы стоят на одном адресе, репутационный ущерб от одного взломанного компонента бьёт по всем остальным — это подробно разобрано в статье про общую репутацию IP при нескольких продуктах на одном сервере.
Добавили базовые практики harden'а сервера, которые должны были стоять с самого начала: fail2ban для nginx с правилами против массовых POST-запросов к одному URL, регулярный аудит контрольных сумм файлов в веб-директории и чек-лист безопасности при разворачивании новых сайтов — по нему же теперь проверяют каждый новый проект перед выкладкой в прод, детали — в чек-листе безопасности нового сервера.
Отдельно стоит сказать про сам процесс расследования — то, что удалось быстро найти файл и восстановить хронологию, было возможно только потому, что логи хранились достаточно долго и в удобном для поиска виде. Если у вас логи ротируются раз в сутки и хранятся неделю, а не месяц, восстановить картину заражения трёхнедельной давности будет намного сложнее — общий подход к тому, как читать логи и находить причину сбоя, разобран в отдельной статье про чтение логов и поиск причины сбоя.
Как убедиться, что у вас всё в порядке
Проверка своего сервера на аналогичную дыру занимает 20-30 минут и не требует специальных инструментов.
- Проверьте конфиг nginx (или Apache) на предмет исполнения PHP в директориях, куда пользователи могут что-то загружать:
nginx -T | grep -A3 "location.*uploads". - Найдите все
.php-файлы в папках загрузок вручную:find /var/www -path "*/uploads/*" -name "*.php"— в норме таких файлов там быть не должно вообще, ни одного. - Проверьте очередь исходящей почты на аномальный объём:
mailq | grep -c '^[A-Z0-9]'— если там тысячи писем, а вы не запускали массовую рассылку, время разбираться. - Проверьте, не проверяется ли тип загружаемого файла только по расширению или заголовку
Content-Typeв коде вашего приложения или CMS-плагинов. - Убедитесь, что IP не в чёрных списках — есть бесплатные онлайн-чекеры по нескольким спам-базам сразу, стоит проверять периодически, а не только при жалобах.
Если вы разворачиваете сайт или веб-приложение с нуля, разумнее сразу настроить сервер так, чтобы папки загрузок физически не могли исполнять код, а не чинить это постфактум после инцидента — это на порядок дешевле по времени и нервам.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как злоумышленник вообще нашёл эту форму загрузки, если сайт не был особо популярным?
Такие уязвимости обычно находят не вручную, а массовым автоматическим сканированием — боты перебирают тысячи сайтов подряд в поисках известных паттернов уязвимых плагинов и форм, независимо от посещаемости конкретного сайта. Популярность сайта здесь не защита.
Почему антивирус на сервере не поймал веб-шелл?
Классический антивирус ищет файлы по сигнатурам известных вредоносов, а обфусцированный PHP-скрипт с eval(base64_decode(...)) — это по сути легитимный синтаксис языка, который просто используется во вред; распознать такое сигнатурный анализ часто не может. Здесь помогает не антивирус, а контроль за тем, какие файлы вообще могут появляться и исполняться в определённых директориях.
Достаточно ли просто удалить найденный файл, чтобы закрыть инцидент?
Нет — если не устранить саму возможность загрузить и исполнить PHP в этой директории, через день-два появится новый файл с другим именем через ту же дыру. Удаление файла останавливает симптом, а не причину.
Сколько времени обычно занимает выход IP из чёрных списков после устранения причины?
По опыту это от нескольких часов до нескольких дней в зависимости от конкретного списка — какие-то снимают блокировку автоматически после повторной проверки, для других нужно подавать заявку вручную и ждать модерации. Момент устранения причины и момент возврата репутации — это две разные точки на временной шкале, и вторая всегда наступает позже.
Стоит ли держать почту, сайт и внутренние сервисы на одном сервере ради экономии?
Технически можно, но при инциденте с одним компонентом репутационный удар получают все остальные, потому что смотрят на IP, а не на конкретный сервис. Если почта критична для бизнеса, разумнее вынести её на отдельный сервер или отдельный IP, даже если это чуть дороже.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →