Когда на сервере размещено несколько 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-пользователе он остаётся частью той же зоны риска.

Сравнение общего PHP-FPM пользователя и отдельных pools для нескольких сайтов
Разные Unix-пользователи позволяют файловой системе ограничивать доступ PHP одного проекта к соседним каталогам.

Что сделать до очистки: локализовать инцидент и сохранить доказательства

Самая частая ошибка — сразу удалить всё подозрительное. После этого сайт может выглядеть чистым, но одновременно исчезают данные о времени изменения файлов, расположении persistence и возможном направлении распространения.

Разумная последовательность другая.

Сначала фиксируют текущее состояние: подозрительные файлы, их владельцев, права, временные метки, неизвестные процессы, пользователей WordPress, cron-задачи, активные административные инструменты и релевантные журналы. Подозрительный объект лучше переместить в недоступное для веб-сервера хранилище или сохранить копию для анализа, а не безвозвратно стирать его.

Перед любым изменением конфигурации нужен резервный путь. В частности, резервное копирование сервера полезно здесь не как формальная процедура, а как возможность вернуть рабочую конфигурацию и сохранить состояние, существовавшее до hardening.

Затем ограничивают дальнейшее выполнение: выключают ненужный staging, закрывают забытые административные интерфейсы, убирают запись туда, где она не требуется, и изолируют очевидно скомпрометированный проект.

Это не то же самое, что установить точную причину атаки. Containment можно начать до завершения расследования.

Последовательность действий после компрометации WordPress-сайта
Секреты ротируют после локализации persistence, а не вместо неё.

Где искать 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 применяется без грубого завершения уже обслуживаемых запросов.

Матрица доступа PHP к каталогам WordPress после изоляции сайтов
Writable-зона должна быть минимальной и принадлежать только PHP-пользователю соответствующего проекта.

В каком порядке ротировать доступы после очистки

Менять все пароли первым действием не всегда правильно. Если неизвестный код продолжает выполняться, новые секреты могут оказаться доступны тому же процессу.

Практический порядок:

  1. ограничить дальнейшее выполнение и межсайтовый доступ;
  2. найти и убрать подтверждённые persistence-механизмы;
  3. проверить администраторов, Application Passwords и сессии;
  4. проверить целостность кода;
  5. разделить системные права и PHP-FPM;
  6. после этого ротировать секреты.

В ротацию могут входить:

  • 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;
  • сохранены резервная копия новой конфигурации и понятный способ отката.

Главная цель изоляции — не обещать, что сайт больше никогда нельзя взломать. Она значительно практичнее: компрометация одного приложения не должна автоматически превращаться в компрометацию всех сайтов, работающих на том же сервере.