Когда на сервере размещено несколько WordPress-проектов, задача после взлома состоит не только в том, чтобы удалить найденный web shell или восстановить изменённый файл. Нужно ответить на более важный вопрос: какие ещё каталоги, учётные записи и процессы были доступны тому же коду.
Если все сайты обслуживаются одним системным пользователем и общим PHP-FPM pool, компрометированный PHP-процесс получает те файловые возможности, которые разрешены этой Unix-учётной записи. Поэтому изоляция сайтов на сервере начинается с определения границ доступа, а заканчивается проверкой, что один проект больше не может читать или изменять файлы другого.
PHP-FPM специально поддерживает несколько pools с отдельными параметрами, включая Unix-пользователя, группу и собственный listen-сокет. Само наличие разных pool ещё не создаёт полноценную защиту: файловые владельцы и права должны соответствовать этой архитектуре.
Почему один заражённый сайт может затронуть соседние проекты
Представим сервер с пятью независимыми WordPress-сайтами. У каждого свой домен и база данных, но PHP всех проектов работает от www-data, а каталоги сайтов доступны этому же пользователю на запись.
Если в одном проекте появляется возможность выполнить посторонний PHP-код, проблема уже не ограничивается URL этого сайта. Такой процесс действует с правами www-data. Если этим правам доступен соседний wp-config.php, каталог плагинов или другой writable-каталог, граница между проектами существует только логически.
Это важное различие:
- initial access — как злоумышленник впервые получил возможность выполнять действия в системе;
- lateral movement — как уже полученный доступ позволил перейти от одного проекта к другому.
После инцидента первоначальный вектор иногда удаётся доказать по журналам, иногда нет. Но для containment это не причина ждать: межсайтовые разрешения можно и нужно закрывать независимо от того, удалось ли восстановить первый HTTP-запрос или точную уязвимость.
Общий системный пользователь и общий PHP-FPM pool
В PHP-FPM директивы user и group определяют Unix-идентичность процессов конкретного pool, а listen позволяет каждому pool использовать собственный Unix-сокет. PHP также документирует права listen.owner, listen.group и listen.mode для таких сокетов.
Архитектура вида:
site-a ─┐
site-b ─┼─> php-fpm: www-data ─> каталоги всех сайтов
site-c ─┘
создаёт общий уровень доверия.
Более жёсткая схема:
site-a -> pool site_a -> user site_a -> /var/www/site-a
site-b -> pool site_b -> user site_b -> /var/www/site-b
site-c -> pool site_c -> user site_c -> /var/www/site-c
не гарантирует безопасность сама по себе, но позволяет файловой системе реально запрещать процессу site_a запись в site-b.
Writable-каталоги, test-сайты и забытые точки входа
Вторая проблема — слишком широкая запись. WordPress прямо рекомендует ограничивать права и отмечает, что возможность записи веб-сервером повышает риск, особенно в общей среде. Ядро, wp-admin, wp-includes и файлы плагинов обычно не должны постоянно быть writable веб-процессом.
Отдельно проверяют:
- старые test/staging-копии;
- временные каталоги;
- забытые резервные копии внутри document root;
- файловые менеджеры и административные PHP-интерфейсы;
- каталоги uploads/cache с неожиданными
.php-файлами; - старые виртуальные хосты, которыми никто больше не пользуется.
Такой test-сайт может быть не связан с основным проектом на уровне WordPress, но при общем Unix-пользователе он остаётся частью той же зоны риска.

Что сделать до очистки: локализовать инцидент и сохранить доказательства
Самая частая ошибка — сразу удалить всё подозрительное. После этого сайт может выглядеть чистым, но одновременно исчезают данные о времени изменения файлов, расположении persistence и возможном направлении распространения.
Разумная последовательность другая.
Сначала фиксируют текущее состояние: подозрительные файлы, их владельцев, права, временные метки, неизвестные процессы, пользователей WordPress, cron-задачи, активные административные инструменты и релевантные журналы. Подозрительный объект лучше переместить в недоступное для веб-сервера хранилище или сохранить копию для анализа, а не безвозвратно стирать его.
Перед любым изменением конфигурации нужен резервный путь. В частности, резервное копирование сервера полезно здесь не как формальная процедура, а как возможность вернуть рабочую конфигурацию и сохранить состояние, существовавшее до hardening.
Затем ограничивают дальнейшее выполнение: выключают ненужный staging, закрывают забытые административные интерфейсы, убирают запись туда, где она не требуется, и изолируют очевидно скомпрометированный проект.
Это не то же самое, что установить точную причину атаки. Containment можно начать до завершения расследования.

Где искать persistence, если видимый вредоносный файл уже удалён
Удаление одного web shell не доказывает, что доступ закрыт. Persistence может находиться в другом слое и снова восстановить точку входа.
Если задача состоит именно в очистке вирус на сайте, это отдельный уровень диагностики. При межсайтовом инциденте дополнительно проверяют механизмы, которые сохраняют доступ после удаления первоначального файла.
Пользователи WordPress, сессии и Application Passwords
Сначала сверяют администраторов WordPress с ожидаемым списком. Важен не только логин, но и роль, дата создания и наличие учётных записей, происхождение которых никто не может объяснить.
Отдельно проверяют Application Passwords. WordPress описывает их как отдельные отзывные credentials, привязанные к пользователю и предназначенные для программного доступа. Их можно просматривать и отзывать независимо от основного пароля пользователя.
Например, сначала можно только посмотреть список:
wp user application-password list USER_ID
Неизвестный credential отзывают после проверки, что он не используется легитимной интеграцией.
То же относится к активным сессиям. WP-CLI умеет перечислять их и завершать; после инцидента старые session tokens не стоит считать доверенными только потому, что основной пароль уже изменён.
MU-плагины, cron, временные каталоги и административные инструменты
wp-content/mu-plugins требует отдельной проверки. Must-use plugins загружаются автоматически и не отключаются обычной кнопкой деактивации; WordPress также предупреждает, что для них нет стандартного механизма уведомления об обновлениях как для обычных плагинов.
Поэтому при аудите полезно сопоставить каждый MU-файл с известным назначением.
Кроме WordPress проверяют системные и пользовательские cron-задачи, timers/services, временные каталоги и инструменты администрирования. Здесь важно искать не «подозрительное имя файла», а неизвестный механизм запуска: команда, которой не должно быть, необычный исполнитель, неизвестный PHP-файл или сервис.
Как выстроить изоляцию сайтов на сервере через PHP-FPM и права
Правильная модель строится на принципе: PHP каждого сайта получает только тот файловый доступ, который нужен именно этому проекту.
Отдельный Unix-пользователь и pool для каждого сайта
Для условного site-a pool может выглядеть так:
[site_a]
user = site_a
group = site_a
listen = /run/php/php-fpm-site-a.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 5
Значения pm и pm.max_children нельзя механически копировать на любой сервер: они зависят от нагрузки и доступной памяти. Существенная для изоляции часть — отдельные user, group и socket. PHP-FPM официально поддерживает несколько pools с разными UID/GID и отдельными точками прослушивания.
В конфигурации Nginx соответствующий virtual host направляет PHP именно в socket своего сайта:
fastcgi_pass unix:/run/php/php-fpm-site-a.sock;
При этом каталог site-b не должен быть читаемым или writable пользователем site_a только ради удобства администрирования.
Что должно быть read-only, а что действительно нужно оставить writable
Универсального числа вроде «поставить везде 755/644 — и готово» недостаточно. Важнее владельцы и реальные потребности приложения.
Рабочая модель:
| Зона | Доступ PHP |
|---|---|
| ядро WordPress | чтение, запись только во время контролируемого обновления |
wp-admin, wp-includes | обычно без постоянной записи PHP |
| плагины и темы | по возможности без постоянной записи |
wp-config.php | максимально ограниченный доступ |
| uploads | запись только PHP-пользователю этого сайта |
| runtime-cache | запись только там, где она действительно нужна |
| соседний сайт | ни записи, ни лишнего чтения |
WordPress отдельно рекомендует максимально ограничивать writable-доступ и усиливать защиту wp-config.php.
Именно поэтому права файлов на сайте нужно рассматривать вместе с владельцами процессов. chmod без понимания, от какого пользователя работает PHP, легко создаёт ложное ощущение защиты.
Почему PHP не должен выполняться из uploads и cache
Uploads нужен для загружаемых файлов, но обычной медиатеке не требуется выполнение PHP. Если туда удалось записать .php и веб-сервер затем передаёт этот файл PHP-FPM, writable-каталог превращается ещё и в executable-зону.
Для Nginx можно создать отдельное правило, расположенное с учётом порядка location относительно общего PHP-handler:
location ~* ^/wp-content/(?:uploads|cache)/.*\.php$ {
return 403;
}
Nginx проверяет regex location по порядку и использует первое совпадение, поэтому правило нужно оценивать вместе со всей конфигурацией virtual host, а не вставлять вслепую.
Перед применением изменения сохраняют копию текущего конфига, затем обязательно проверяют:
sudo nginx -t
И только при успешном тесте выполняют reload. Ключ -t проверяет синтаксис и возможность открыть упомянутые в конфигурации файлы; reload применяется без грубого завершения уже обслуживаемых запросов.

В каком порядке ротировать доступы после очистки
Менять все пароли первым действием не всегда правильно. Если неизвестный код продолжает выполняться, новые секреты могут оказаться доступны тому же процессу.
Практический порядок:
- ограничить дальнейшее выполнение и межсайтовый доступ;
- найти и убрать подтверждённые persistence-механизмы;
- проверить администраторов, Application Passwords и сессии;
- проверить целостность кода;
- разделить системные права и PHP-FPM;
- после этого ротировать секреты.
В ротацию могут входить:
- WordPress-пароли;
- Application Passwords;
- authentication keys и salts;
- пароль пользователя БД;
- SSH/SFTP/FTP credentials;
- API-токены;
- SMTP и другие внешние интеграции.
WP-CLI предоставляет wp config shuffle-salts; изменение WordPress security keys делает существующие cookie недействительными, поэтому пользователей потребуется авторизовать заново.
Ротацию БД нужно делать согласованно: новый пароль сначала подготавливается и проверяется как часть контролируемого изменения, затем обновляется конфигурация приложения. Случайное изменение только одной стороны приведёт к недоступности сайта.
Что закрыть после инцидента: тестовые хосты, лишние сервисы и опасные интерфейсы
Production может быть обновлён и хорошо защищён, а старый test-сайт — годами работать на той же машине с устаревшим кодом.
После инцидента имеет смысл составить реальный inventory:
- активные virtual hosts;
- test/staging/old-домены;
- PHP-FPM pools;
- открытые listening sockets и TCP-порты;
- панели и веб-интерфейсы администрирования;
- системные пользователи;
- cron и timers;
- резервные копии, случайно оставленные внутри web root.
Неиспользуемый сервис лучше отключить, чем «защитить на всякий случай». Для административного интерфейса, который действительно нужен, ограничивают доступ и способ аутентификации.
WAF, антивирус, обновлённые плагины и фильтрация запросов здесь полезны, но решают другую задачу. Они не заменяют файловую границу между проектами: если два PHP-процесса фактически работают с одинаковыми системными правами, сетевой фильтр не превращает их в изолированные Unix-контуры.
Как доказать, что межсайтовый доступ действительно закрыт
Изоляция считается законченной не тогда, когда конфигурация «выглядит правильно», а когда ограничения можно проверить.
Допустим, есть пользователи site_a и site_b.
Проверку записи можно выполнять без создания файла:
sudo -u site_a test -w /var/www/site-b \
&& echo "UNEXPECTED: writable" \
|| echo "OK: not writable"
Отдельно проверяют чувствительный файл соседнего проекта:
sudo -u site_a test -r /var/www/site-b/wp-config.php \
&& echo "CHECK: readable" \
|| echo "OK: not readable"
Ожидаемый результат зависит от выбранной модели групп и деплоя, но случайная межсайтовая запись недопустима.
Затем нужно проверить целостность WordPress. Для ядра WP-CLI предоставляет:
wp core verify-checksums
Команда сравнивает файлы установленной версии с checksums WordPress.org. Опция --include-root дополнительно помогает обнаружить неожиданные файлы в корне. Для плагинов из WordPress.org доступна отдельная wp plugin verify-checksums --all; отсутствие checksum для premium/custom-плагина само по себе не доказывает заражение.
Checksums тоже не являются окончательным ответом: они не проверяют содержимое БД, неизвестные cron-задачи, произвольные MU-плагины и все пользовательские файлы.
После hardening полезно включить мониторинг сервера так, чтобы изменения процессов, места на диске, ошибок PHP и других рабочих показателей становились заметны раньше, чем проблема превратится в повторный инцидент.
Практический сценарий: пять WordPress-проектов на одном сервере
Исходная ситуация: на машине находятся пять независимых проектов. PHP всех сайтов работает через один pool. На одном сайте обнаружен неизвестный PHP-файл в writable-каталоге.
Плохой сценарий — удалить файл, обновить один плагин и считать вопрос закрытым.
Рабочий сценарий выглядит иначе.
Сначала подозрительный объект сохраняют для анализа и ограничивают выполнение PHP в его зоне. Проверяют журналы, системного владельца файла, WordPress-пользователей, активные Application Passwords, MU-плагины и задания запуска.
Затем оценивают соседние проекты. Если общий PHP-user имеет запись в их каталоги, это рассматривается как нарушение границы независимо от того, найден ли там вредоносный код.
После очистки создают отдельные Unix-пользователи и PHP-FPM pools. Каждый virtual host переводят на собственный socket. Владельцев каталогов приводят к модели, где PHP одного проекта не пишет в другой. Writable оставляют только необходимые runtime-зоны.
На Nginx блокируют выполнение PHP в uploads/cache и до reload проверяют конфигурацию через nginx -t.
Затем проверяют ядро и доступные плагины по checksums, сверяют пользователей, завершают недоверенные сессии и отзывают неизвестные Application Passwords.
Только после локализации persistence ротируют WordPress salts, пароли БД и остальные credentials.
Финальная проверка выполняется не через браузер одного сайта, а на трёх уровнях:
- каждый проект штатно открывается и выполняет PHP через свой pool;
- межсайтовые read/write-тесты дают ожидаемый запрет;
- повторный аудит не показывает неизвестных пользователей, механизмов запуска или изменённых файлов.
При этом вывод должен оставаться аккуратным: такая процедура доказывает, что обнаруженные пути повторного доступа закрыты и архитектура стала строже, но не позволяет задним числом назвать первоначальную уязвимость без соответствующих логов и других доказательств.
Ошибки, из-за которых заражение возвращается
Удалить один web shell и остановиться. Найденный файл может быть только одним из элементов persistence.
Сразу сменить все секреты. Если посторонний код ещё выполняется, ротация не устраняет механизм доступа.
Оставить все сайты под www-data. Обновление WordPress и плагинов уменьшает часть рисков, но не создаёт межсайтовую системную границу.
Сделать chmod 777, чтобы «всё заработало». Это расширяет запись вместо устранения причины проблем с владельцами и группами.
Удалять все неизвестные файлы пакетно. Так легко уничтожить доказательства и легитимные кастомные компоненты.
Полагаться только на reboot. Перезапуск может завершить текущий процесс, но файл, cron, MU-плагин или украденный credential останутся.
Забыть test/staging. Неиспользуемый проект с доступом к общей файловой зоне способен свести hardening production к минимуму.
Что проверить перед возвратом сервера в обычную эксплуатацию
Сервер можно считать подготовленным к штатной работе, когда есть проверяемый baseline:
- известны все WordPress-администраторы и системные пользователи;
- неизвестные Application Passwords и недоверенные сессии отозваны;
- ядро проходит checksum-проверку, а отличия плагинов разобраны;
- список MU-плагинов объясним;
- cron, timers и активные сервисы соответствуют ожидаемой конфигурации;
- каждый сайт обслуживается предназначенным для него PHP-FPM pool;
- соседние проекты не writable друг для друга;
- чувствительные конфиги не доступны посторонним PHP-users;
- в uploads/cache не выполняется PHP;
- ненужные test/staging hosts и административные интерфейсы отключены;
- секреты ротированы уже после устранения persistence;
- сохранены резервная копия новой конфигурации и понятный способ отката.
Главная цель изоляции — не обещать, что сайт больше никогда нельзя взломать. Она значительно практичнее: компрометация одного приложения не должна автоматически превращаться в компрометацию всех сайтов, работающих на том же сервере.
Материал подготовлен Максимом Вагизовым для vagizov.com . При цитировании обязательна активная ссылка на источник.
Подробнее об авторских правах