PHP2Shell (CVE-2026-109618/109619): как найти признаки взлома в логах nginx и Apache
На 21 октября 2026 исправления PHP для CVE-2026-109618 и CVE-2026-109619 вышли, но эксплойт публичен, поэтому после обновления стоит проверить логи. Ищите POST с multipart/form-data и кодом 500, записи Allowed memory size exhausted, раздутые куки PHPSESSID и падения воркеров (signal 11, AH00052, segfault). Это косвенные признаки, не доказательство: чистый лог не гарантирует чистый сервер, а Content-Type веб-сервер по умолчанию не пишет.
Что искать в логах на 21 октября 2026 и что логи не покажут
На 21 октября 2026 картина такова: PHP опубликовал исправления для CVE-2026-109618 и CVE-2026-109619 (оба с оценкой CVSS v4.0 9.2), патч, разборы и эксплойт-инструмент PHP2Shell публичны, а Patchstack по своей телеметрии в 13:57 UTC писал о зондировании без подтверждённых взломов, но ждёт роста. Первое, что мы делаем у клиента, — обновляем PHP и перезапускаем воркеры; порядок описан в статье PHP2Shell: что делать за первый час. Эта статья о втором шаге: как задним числом понять, не прошёл ли по серверу кто-то до патча. Данные меняются каждый день, поэтому команды ниже стоит прогонять заново после каждой новой волны публикаций.
Исходная логика эксплуатации по описаниям Sansec, NCSC и Patchstack такова: запрос multipart/form-data доводит воркер до фатальной ошибки, обычно до исчерпания memory_limit; следующий запрос на том же воркере работает с освобождённой памятью. Отсюда три наблюдаемых следа, которые перечисляет Patchstack: необычные multipart-запросы, за которыми идёт ошибка memory_limit или повторяющиеся HTTP 500; аномально большие или битые куки PHPSESSID в ответах (по описанию Sansec, в Set-Cookie может вернуться содержимое освобождённой памяти воркера); необъяснимые перезапуски воркеров PHP. Для связки с ядром ОС добавляем то, что видно в журналах: в php-fpm строка child ... exited on signal 11 (SIGSEGV), в Apache AH00052 child pid ... exit signal Segmentation fault, в журнале ядра запись segfault.
Таблица ниже связывает признак с местом, где его искать. Все пути типовые для Debian и Ubuntu; на RHEL-семействе каталоги другие (/var/log/httpd, /var/log/php-fpm), а точный путь лога PHP-FPM определяет параметр error_log в php-fpm.conf.
| Признак | Где смотреть | Что искать |
|---|---|---|
| Multipart-запрос и затем ошибка 500 | access-лог nginx или Apache | POST с кодом 500, в расширенном формате ещё Content-Type multipart/form-data |
| Нехватка памяти | error-лог PHP-FPM, nginx, Apache | Allowed memory size of ... bytes exhausted |
| Раздутая кука PHPSESSID | access-лог с флагом, заведённым нами | длинное или необычное значение PHPSESSID в Set-Cookie |
| Падение воркера PHP-FPM | journalctl -u php8.3-fpm | child ... exited on signal 11 (SIGSEGV) |
| Падение процесса Apache | error-лог Apache | AH00052 child pid ... exit signal Segmentation fault |
| Падение на уровне ядра | journalctl -k, dmesg | segfault, привязанный к php-fpm или apache2 |
Теперь о границах метода, и это важно сказать честно. Patchstack прямо называет эти признаки не доказательством ни в ту, ни в другую сторону. Segfault в PHP случается и по безобидным причинам, например из-за сломанного расширения, а HTTP 500 с memory_limit даёт и кривой плагин. Обратное хуже: чистый лог не значит чистый сервер. Логи могли быть ротированы, а при полном доступе злоумышленник мог их подчистить. Если до 21 октября нужных полей в логе не было, восстановить их нельзя.
Отдельное предупреждение, о которое спотыкаются чаще всего: веб-сервер по умолчанию не пишет Content-Type в access-лог. Предопределённый формат combined в nginx содержит адрес клиента, пользователя, время, строку запроса, статус, размер ответа, Referer и User-Agent, и всё. В Apache формат по умолчанию вообще Common Log Format, ещё беднее. Значит, вопрос «были ли multipart-запросы» по обычному логу сводится к косвенному «были ли POST с кодом 500». Для будущего это исправляется за десять минут, как описано в следующем разделе, но на прошлое не действует.
Мини-кейс для наглядности. Допустим, у вымышленного страхового брокера «Надёжный Берег» (25 рабочих мест) на одном виртуальном сервере живут корпоративный сайт на WordPress и клиентский портал, а наружу смотрит nginx с PHP-FPM. Логи ведёт стандартный combined-формат, ротация недельная. Ревизию мы строим так: сначала проверяем версию PHP и факт перезапуска, потом по логам смотрим POST с кодом 500 и записи о нехватке памяти, потом падения воркеров, затем файлы, процессы и ключи. Находки каждого слоя записываем отдельно, потому что одно совпадение ничего не доказывает, а сочетание нескольких в одно время — повод остановиться и звать специалистов по инцидентам.
Включаем Content-Type и флаг подозрительной куки в access-логе
Задача раздела: чтобы при следующей волне эксплуатации в логе были именно те поля, по которым ищется признак. Нужны три вещи: Content-Type запроса, длина тела запроса и время обработки. В nginx это переменные $http_content_type, $content_length и $request_time; в Apache это %{Content-Type}i, %{Content-Length}i и %D (микросекунды, это подтверждено в документации mod_log_config). Заголовки запроса в обоих серверах берутся через i, заголовки ответа через o в Apache и префикс sent_http_ в nginx.
Про Set-Cookie нужна осторожность. Если логировать заголовок целиком, то по описанию Sansec в лог попадёт содержимое освобождённой памяти воркера, то есть возможные пароли, токены и ключи. Получится, что мы сами сохраним утёкшие секреты в файл, который читают многие и который уходит в агрегаторы. Поэтому в nginx мы пишем не сам заголовок, а только флаг: 1, если значение PHPSESSID длиннее 100 символов, и 2, если в нём есть символы вне обычного набора букв, цифр, запятой и дефиса. Порог 100 мы подбираем под session.sid_length конкретного сервера, поэтому проверьте это значение у себя. Если сессии названы иначе, чем PHPSESSID (параметр session.name), поправьте имя в регулярных выражениях.
# /etc/nginx/conf.d/itf-php2shell-log.conf (контекст http)
map $sent_http_set_cookie $itf_sess {
default 0;
"~*PHPSESSID=[^;]{100,}" 1; # слишком длинное значение
"~*PHPSESSID=[A-Za-z0-9,-]*[^A-Za-z0-9,;-]" 2; # символы вне обычного набора
}
log_format itf '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'ct="$http_content_type" cl=$content_length rt=$request_time sess=$itf_sess';
# в server{} каждого сайта вместо прежней строки access_log:
# access_log /var/log/nginx/site.access.log itf;sudo nginx -t && sudo systemctl reload nginxЧтобы не ломать готовые разборщики логов, лучше завести отдельный формат и отдельный файл только на время расследования. Подключайте формат тем сайтам, где есть PHP. Статические сайты и прокси без PHP эта проверка не касается. Если перед PHP стоит ещё один прокси, например балансировщик, Set-Cookie видит тот сервер, который отдаёт ответ клиенту, и флаг надо считать именно на нём.
В Apache прямого способа записать только флаг по длине куки мы не проверяли, поэтому не будем выдавать догадку за рецепт. Если Apache стоит за nginx, считайте флаг на nginx. Если Apache смотрит наружу сам, начните с Content-Type, длины запроса и времени: этого достаточно для связки «multipart и код 500». Логировать Set-Cookie целиком (%{Set-Cookie}o) только на время короткого расследования и с закрытыми правами на файл мы бы не стали без согласования с владельцем: именно этот лог может содержать утёкшие данные.
# /etc/apache2/conf-available/itf-log.conf
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" ct=\"%{Content-Type}i\" cl=%{Content-Length}i rt_us=%D" itf
# в VirtualHost: CustomLog ${APACHE_LOG_DIR}/site.access.log itfsudo a2enconf itf-log && sudo apachectl configtest && sudo systemctl reload apache2Для изменения формата лога достаточно reload: это не относится к самому PHP, где для mod_php, по фактам из разбора уязвимости, нужен именно restart, а reload и graceful недостаточны. Не путайте эти два случая: логирование мы включаем мягко, патч PHP применяем жёстко. После включения проверьте, что в файле появились поля ct= и sess=, например командой tail -n 5 на свежий лог и одним тестовым POST через curl.
Последний штрих касается блокировки multipart на веб-сервере как временной меры (nginx отвечает 403 по условию на $http_content_type). Если вы её включите, в логе появятся 403 вместо 500, и фильтры ниже по коду 500 их не увидят. Тогда ищите по ct="multipart/form-data с кодом 403: это ровно те запросы, которые блокировка отсекла, и по ним видно, кто и с какого адреса пробует. Про сами временные меры и их цену для форм и загрузок мы писали в общем обзоре серии: что делать за первый час.
Access-лог: POST с кодом 500, multipart и топ IP
Команды ниже работают и с обычным combined-логом, и с расширенным форматом из прошлого раздела. Они читают и обычные файлы, и архивы ротации: zcat -f разворачивает gzip, а обычные текстовые файлы отдаёт как есть. Ничего не пишется на диск. Для начала посмотрим, сколько POST и PUT закончились кодом 500. Регулярное выражение опирается на строку «запрос» в кавычках и следом за ней статус, поэтому не зависит от числа пробелов в User-Agent.
sudo zcat -f /var/log/nginx/access.log* | grep -cE '"(POST|PUT) [^"]*" 500 'Топ адресов, с которых пришли такие запросы, и топ URL, на которые они шли. Тут следите за двумя паттернами: один адрес, который бьёт в один и тот же скрипт (типичное зондирование), и один скрипт, который получает 500 от множества адресов (его нашли по списку целей). В обоих случаях сверяйте по времени с обновлением PHP: ошибки после патча уже не про эту уязвимость, но могут показывать, что вас по-прежнему щупают.
sudo zcat -f /var/log/nginx/access.log* | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
sudo zcat -f /var/log/nginx/access.log* | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print $7}' | sort | uniq -c | sort -rn | head -20Если вы уже включили расширенный формат, добавляется самый точный фильтр: multipart-запросы с кодом 5xx. Строка ct= содержит значение заголовка вместе с boundary, поэтому ищем по началу значения, без пробелов в шаблоне.
sudo zcat -f /var/log/nginx/site.access.log* | grep -E 'ct="multipart/form-data' | grep -E '" 5[0-9][0-9] ' | awk '{print $1}' | sort | uniq -c | sort -rn | head -20Раздутые куки ищем по флагу sess=, который мы завели. Значения 1 и 2 в логе должны быть редкостью, поэтому здесь любая строка заслуживает чтения. Сохраните найденное в отдельный файл вне веб-каталога, прежде чем логи уйдут в ротацию: это улика.
sudo zcat -f /var/log/nginx/site.access.log* | grep -E ' sess=[12]( |$)' | head -50Всплески по времени помогают сопоставить access-лог с падениями воркеров из следующего раздела. Группировка по минутам показывает, в какие минуты было больше всего POST с кодом 500: у атаки обычно есть узкое окно, а у сломанного плагина ошибки размазаны по дню. Поле времени в combined находится в четвёртой колонке в виде [21/Oct/2026:14:01:02, и substr отрезает скобку и секунды.
sudo zcat -f /var/log/nginx/access.log* | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print substr($4,2,17)}' | sort | uniq -c | sort -rn | head -20Для Apache логика та же, меняются только пути: /var/log/apache2/access.log* на Debian и Ubuntu, /var/log/httpd/access_log* на RHEL-семействе. Если логи сайтов вынесены в отдельные файлы виртуальных хостов, подставьте их шаблоны. Когда сайт стоит за CDN или балансировщиком, первая колонка покажет адрес прокси, а не клиента: тогда нужно писать в формат заголовок X-Forwarded-For или настроить модуль real IP, иначе топ IP бесполезен.
Как читать результат. Единичные 500 на POST у живого сайта нормальны. Тревожно сочетание: раньше таких запросов было мало, с 20–21 октября пошли серии; запросы идут на разные скрипты и с одного адреса; нет Referer и стандартного User-Agent; тело большое, а время обработки (rt) нетипичное. Ничего из этого по отдельности вердикта не даёт. Результаты выкладываем в таблицу с датой и адресом, чтобы потом сопоставить с процессами и файлами.
Журналы PHP-FPM, Apache и ядра: нехватка памяти и падения воркеров
Вторая группа признаков живёт не в access-логе, а в журналах процессов. Начнём с нехватки памяти: сообщение «Allowed memory size of N bytes exhausted» попадает либо в лог PHP-FPM, либо в error-лог nginx (там оно приходит как «FastCGI sent in stderr»), либо в error-лог Apache при mod_php. Ищем во всех сразу и сразу смотрим последние строки, потому что нам важны даты.
sudo zgrep -hE 'Allowed memory size of [0-9]+ bytes exhausted' /var/log/nginx/error.log* /var/log/apache2/error.log* /var/log/php*-fpm.log* 2>/dev/null | tail -20Само по себе это сообщение указывает на тяжёлый запрос или слишком скромный memory_limit. Нам нужна корреляция: те же минуты, что и всплески POST с кодом 500, и в записи нет осмысленного скрипта приложения. Если в сообщении указан ваш рабочий скрипт, то версия «плохой плагин» остаётся основной. Если множество записей с одного адреса идёт без видимой причины, это повод читать дальше.
Падения воркеров. В PHP-FPM, когда воркер завершается по сигналу, мастер-процесс пишет строку вида «child ... exited on signal 11 (SIGSEGV)». Смотрим журнал юнита; имя юнита зависит от версии, для PHP 8.3 это php8.3-fpm. Дату начала задаём с запасом, например неделей раньше.
sudo journalctl -u php8.3-fpm --since "2026-10-14" --no-pager | grep -E 'exited on signal|SIGSEGV|SIGBUS|SIGABRT'
sudo systemctl list-units 'php*fpm*.service' --no-legend --plainВ Apache аналог лежит в error-логе: AH00052 с текстом «child pid ... exit signal Segmentation fault». Сюда же попадают падения дочерних процессов с mod_php, где PHP живёт внутри процесса Apache. Поэтому для mod_php по этой строке нельзя отделить падение PHP от падения другого модуля, и это тоже стоит записать в отчёт.
sudo zgrep -hE 'AH00052|Segmentation fault' /var/log/apache2/error.log* /var/log/httpd/error_log* 2>/dev/null | tail -20Наконец, ядро. Сегфолт процесса оставляет запись, привязанную к имени процесса и к библиотеке. Читаем журнал ядра за интересующий период и вместе с ним следы OOM-killer: если сервер перегружен запросами с огромной памятью, ядро могло убивать воркеры по нехватке оперативной памяти. Это тоже важно: по Sansec, временная мера memory_limit = -1 даёт запросу возможность съесть всю память сервера, и тогда в логах появляются oom-kill, а не только ошибки PHP.
sudo journalctl -k --since "2026-10-14" --no-pager | grep -E 'segfault|oom-kill|Out of memory'
sudo dmesg -T 2>/dev/null | grep -E 'segfault|oom-kill' | tail -20Оговорка про dmesg: буфер ядра кольцевой и после перезагрузки сервера обнуляется, а journalctl -k хранит историю только при постоянном журнале (каталог /var/log/journal). Если журнал хранился в памяти, до последней перезагрузки данных нет. Это ограничение метода, и его честно пишем в отчёт.
Пример чтения. Допустим, видно: одиннадцать записей Allowed memory size подряд за четыре минуты от одного адреса, сразу после них exited on signal 11 в php-fpm, а в access-логе у тех же минут POST с multipart и кодом 500. Это согласуется с описанием из публичных разборов и является основанием перейти к следующему разделу и искать следы выполнения команд. Но это не вердикт: сама эксплуатация могла не удаться, а могла и удаться, и разницу видно только по следам на диске и в процессах.
Файлы, процессы, cron и ключи SSH: следы после возможного RCE
Если эксплуатация удалась, результатом по описаниям Sansec и NCSC может стать выполнение кода от имени пользователя PHP. Типичные следы такого доступа лежат не в логах, а на диске и в системе. Идём по порядку. Сначала свежие и подозрительные файлы. Мы ищем PHP-подобные файлы и конфигурацию (.htaccess, .user.ini), у которых изменилось время содержимого или время inode. Время содержимого (mtime) злоумышленник может подделать командой touch, а время изменения inode (ctime) подделать нельзя, поэтому проверяем оба.
sudo find /var/www -xdev -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' -o -name '.user.ini' -o -name '.htaccess' \) \( -newermt '2026-10-21' -o -newerct '2026-10-21' \) -printf '%TY-%Tm-%Td %TH:%TM %u %m %p\n' | sortЗдесь надо учитывать: после ваших собственных обновлений CMS и плагинов список будет большим. Поэтому сравниваем с журналом изменений: если файл изменён вне окна вашего обновления, это кандидат. Отдельно ищем PHP в каталогах, куда PHP-код обычно не кладут: загрузки, кэш, временные папки. Один файл-«заглушка» index.php там бывает нормален, но сам скрипт с непонятным именем нет.
sudo find /var/www -xdev -type f -name '*.php' \( -path '*/uploads/*' -o -path '*/cache/*' -o -path '*/tmp/*' -o -path '*/images/*' \) -printf '%TY-%Tm-%Td %TH:%TM %u %p\n' | sort
sudo find /tmp /var/tmp /dev/shm -xdev -type f \( -perm -u+x -o -name '*.php' -o -name '*.sh' \) -printf '%TY-%Tm-%Td %TH:%TM %u %m %p\n'Далее процессы. Под пользователем веб-сервера (www-data на Debian и Ubuntu, apache или nginx на других системах) в норме работают только php-fpm, nginx, apache2 или httpd. Всё остальное, особенно sh, bash, python, perl или процесс с удалённым исполняемым файлом, нужно разобрать. Фильтр в примере отсекает ожидаемые имена, и на чистом сервере вывод пуст.
ps -eo user:20,pid,ppid,etime,args | awk '$1 ~ /^(www-data|apache|nginx)$/ && $5 !~ /php-fpm|nginx|apache2|httpd|lsphp/'
for p in $(pgrep -u www-data); do e=$(sudo readlink /proc/$p/exe); case "$e" in *deleted*) echo "PID $p $e";; esac; doneСеть. Нас интересуют соединения, которыми владеют оболочки и скриптовые интерпретаторы, и исходящие соединения php-fpm или Apache на внешние адреса. Вторая группа часто легитимна: платёжные API, SMTP, обновления. Поэтому вывод разбираем вручную, а не по принципу «любое подключение вовне плохо». Нужен root, иначе ss не покажет процессы чужих пользователей.
sudo ss -tnp | grep -E 'users:\(\("(sh|bash|dash|nc|ncat|socat|python[0-9.]*|perl|php[0-9.]*|curl|wget)"'
sudo ss -tnp | awk '$1=="ESTAB" && /php-fpm|apache2|httpd/'Закрепление в системе. Проверяем cron, таймеры systemd и ключи SSH: именно сюда после RCE добавляют постоянный доступ. Для cron смотрим системные каталоги и crontab пользователя веб-сервера. Для SSH смотрим файлы authorized_keys у root, у обычных пользователей и у веб-пользователя (домашний каталог www-data на Debian это /var/www), сверяем дату изменения и отпечатки с теми, что вы выдавали сами.
sudo find /etc/cron* /var/spool/cron /etc/systemd/system -xdev -type f \( -newermt '2026-10-21' -o -newerct '2026-10-21' \) -printf '%TY-%Tm-%Td %TH:%TM %p\n'
sudo crontab -l -u www-data
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys /var/www/.ssh/authorized_keys; do [ -f "$f" ] && { stat -c '%y %n' "$f"; ssh-keygen -lf "$f"; }; doneДля WordPress добавляется проверка ядра. Команда wp core verify-checksums скачивает с WordPress.org md5-суммы файлов вашей версии и сравнивает их с установленными. Как сказано в документации WP-CLI, она запускается до загрузки WordPress, чтобы не исполнять код сайта, и каждый несовпавший файл выводит предупреждением. Если проверка падает без видимых причин, укажите --locale и --version, которые показаны в админке в разделе «Обновления». Параметр --include-root дополнительно предупреждает о посторонних файлах в корне установки. Команда делает сетевой запрос и может записать кэш WP-CLI в домашний каталог, поэтому в нашем скрипте она включается только флагом.
cd /var/www/site && sudo -u www-data wp core verify-checksums --include-rootОграничение: проверка покрывает только файлы ядра WordPress. Плагины, темы и каталог uploads она не сверяет, а подмену в них стоит искать списком свежих файлов выше. Для других CMS (Joomla, OpenCart, Bitrix) встроенного аналога мы не заявляем; ориентируйтесь на сравнение с чистым дистрибутивом нужной версии и на статьи серии о WordPress, Joomla и OpenCart. Если инвентаризации ваших сайтов нет, начните с нашего подхода из статьи инвентаризация IT-активов: без списка PHP-приложений проверять нечего.
Скрипт-ревизор для разовой проверки
Все команды выше мы собрали в один bash-скрипт. Он только читает: не пишет на диск, не меняет конфигурацию, не останавливает и не перезапускает ничего, не удаляет файлы. Единственное исключение — флаг -c: он запускает wp core verify-checksums, а эта команда обращается к WordPress.org. Запускаем от root, потому что без этого нет доступа к журналам, /proc и crontab чужих пользователей; без root скрипт честно предупредит о неполноте. Скрипт проверен на синтаксис (bash -n) и пробный запуск на тестовой машине без веб-сервера, но на вашем окружении его стоит сначала прогнать на копии или в нерабочее время: нагрузка у find и zcat на больших логах заметна.
Параметры: -s задаёт дату начала для журнала systemd, -n дату, новее которой ищем файлы, -r корни сайтов через двоеточие, -d регулярное выражение дат для access-лога (например, '1[4-9]/Oct/2026|2[01]/Oct/2026'), -c включает проверку WordPress. Вывод перенаправляем в файл вне веб-каталога и сразу сохраняем копию отчёта.
#!/usr/bin/env bash
# php2shell-audit.sh: разовая read-only проверка признаков PHP2Shell (CVE-2026-109618/109619).
# Ничего не пишет на диск, не меняет конфигурацию, не останавливает процессы.
# Запуск: sudo bash php2shell-audit.sh -r /var/www -s 2026-10-14 -n "2026-10-21" > audit.txt
set -u
export LC_ALL=C
SINCE="2026-10-14" # журнал systemd смотрим с этой даты
NEWER="2026-10-21" # файлы новее этой даты (mtime и ctime)
ROOTS="/var/www" # корни сайтов через двоеточие
DAYRE="" # regexp дат в access-логе, например '1[4-9]/Oct/2026|2[01]/Oct/2026'
WPCHK=0 # 1 = запускать wp core verify-checksums (сетевой запрос к wordpress.org)
while getopts "s:n:r:d:ch" o; do
case "$o" in
s) SINCE="$OPTARG" ;;
n) NEWER="$OPTARG" ;;
r) ROOTS="$OPTARG" ;;
d) DAYRE="$OPTARG" ;;
c) WPCHK=1 ;;
*) echo "Использование: $0 [-s ДАТА_ЖУРНАЛА] [-n ДАТА_ФАЙЛОВ] [-r /корни:через:двоеточие] [-d REGEXP_ДАТ] [-c]"; exit 0 ;;
esac
done
hdr() { printf '\n===== %s =====\n' "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }
[ "$(id -u)" -eq 0 ] || echo "ВНИМАНИЕ: запуск не от root, часть проверок (журналы, /proc, crontab) будет неполной."
IFS=: read -r -a ROOT_ARR <<< "$ROOTS"
EXISTING=()
for r in "${ROOT_ARR[@]}"; do [ -d "$r" ] && EXISTING+=("$r"); done
echo "php2shell-audit, дата запуска: $(date '+%F %T %z'), хост: $(hostname)"
echo "Параметры: логи/журнал с $SINCE, файлы новее $NEWER, корни: ${EXISTING[*]:-нет}"
hdr "1. Версии PHP и процессы на старых библиотеках"
have php && php -v | head -1
if have apt; then apt list '?installed ?name(^php[0-9.]+$)' 2>/dev/null | grep -v '^Listing' ; fi
if have rpm && ! have apt; then rpm -qa 'php*' | sort | head -30; fi
echo "-- процессы, держащие удалённые (заменённые обновлением) файлы из /usr, /lib, /opt:"
grep -lE ' /(usr|lib|opt).*\(deleted\)$' /proc/[0-9]*/maps 2>/dev/null | sed -E 's#/proc/([0-9]+)/maps#PID \1#' | head -20
echo "(пусто = перезапуск после обновления выполнен; непусто = PHP мог остаться старым)"
hdr "2. Access-логи nginx/Apache"
ACC=()
for f in /var/log/nginx/access.log* /var/log/nginx/*/access.log* /var/log/apache2/access.log* /var/log/apache2/*access*.log* /var/log/httpd/access_log* /var/log/httpd/*access*log*; do
[ -f "$f" ] && ACC+=("$f")
done
if [ "${#ACC[@]}" -eq 0 ]; then
echo "access-логи в стандартных каталогах не найдены; укажите свои пути в массиве ACC в начале раздела."
else
printf 'Файлов access-лога: %s\n' "${#ACC[@]}"
logs() { zcat -f -- "${ACC[@]}" 2>/dev/null | grep -E -- "${DAYRE:-.}"; }
echo "-- Content-Type в логе (ct=...) настроен: $(logs | grep -c ' ct="' ) строк"
echo "-- POST/PUT с кодом 500, всего: $(logs | grep -cE '"(POST|PUT) [^"]*" 500 ')"
echo "-- топ-15 IP по POST/PUT с кодом 500:"
logs | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print $1}' | sort | uniq -c | sort -rn | head -15
echo "-- топ-15 URL по POST/PUT с кодом 500:"
logs | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print $7}' | sort | uniq -c | sort -rn | head -15
echo "-- multipart/form-data с кодом 5xx (нужен формат с ct=):"
logs | grep -E 'ct="multipart/form-data' | grep -E '" 5[0-9][0-9] ' | awk '{print $1}' | sort | uniq -c | sort -rn | head -15
echo "-- строки с флагом аномальной PHPSESSID (sess=1 длинная, sess=2 необычные символы):"
logs | grep -E ' sess=[12]( |$)' | head -20
echo "-- POST/PUT с 500 по минутам (всплески), топ-10:"
logs | grep -E '"(POST|PUT) [^"]*" 500 ' | awk '{print substr($4,2,17)}' | sort | uniq -c | sort -rn | head -10
fi
hdr "3. Журналы PHP, Apache и веб-сервера: нехватка памяти, падения воркеров"
ERR=()
for f in /var/log/nginx/error.log* /var/log/apache2/error.log* /var/log/httpd/error_log* /var/log/php*-fpm.log* /var/log/php-fpm/*; do
[ -f "$f" ] && ERR+=("$f")
done
if [ "${#ERR[@]}" -gt 0 ]; then
printf 'Allowed memory size exhausted: %s строк\n' "$(zgrep -hE 'Allowed memory size of [0-9]+ bytes exhausted' -- "${ERR[@]}" 2>/dev/null | wc -l)"
zgrep -hE 'Allowed memory size of [0-9]+ bytes exhausted' -- "${ERR[@]}" 2>/dev/null | tail -5 | cut -c1-300
printf 'php-fpm signal 11/SIGSEGV и Apache AH00052: %s строк\n' "$(zgrep -hE 'exited on signal (6|7|11)|SIGSEGV|AH00052|Segmentation fault' -- "${ERR[@]}" 2>/dev/null | wc -l)"
zgrep -hE 'exited on signal (6|7|11)|SIGSEGV|AH00052|Segmentation fault' -- "${ERR[@]}" 2>/dev/null | tail -10 | cut -c1-300
else
echo "error-логи в стандартных каталогах не найдены (проверьте error_log в php-fpm.conf)."
fi
if have systemctl && have journalctl; then
for u in $(systemctl list-units 'php*fpm*.service' --no-legend --plain 2>/dev/null | awk '{print $1}'); do
echo "-- journalctl -u $u с $SINCE (сигналы, падения):"
journalctl -u "$u" --since "$SINCE" --no-pager -q 2>/dev/null | grep -E 'exited on signal|SIGSEGV|SIGBUS|SIGABRT|Main process exited|Failed' | tail -15
done
echo "-- ядро (journalctl -k) с $SINCE: segfault и OOM:"
journalctl -k --since "$SINCE" --no-pager -q 2>/dev/null | grep -E 'segfault|oom-kill|Out of memory' | tail -15
fi
hdr "4. Свежие и подозрительные файлы в корнях сайтов"
for r in "${EXISTING[@]}"; do
echo "-- $r: PHP-подобные и конфигурационные файлы, изменённые (mtime) или с изменённым inode (ctime) после $NEWER:"
find "$r" -xdev -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' -o -name '.user.ini' -o -name '.htaccess' \) \
\( -newermt "$NEWER" -o -newerct "$NEWER" \) -printf '%TY-%Tm-%Td %TH:%TM %u %m %p\n' 2>/dev/null | sort | head -100
echo "-- $r: .php в каталогах загрузок и кэша:"
find "$r" -xdev -type f -name '*.php' \( -path '*/uploads/*' -o -path '*/upload/*' -o -path '*/cache/*' -o -path '*/tmp/*' -o -path '*/images/*' \) \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2>/dev/null | sort | head -50
done
echo "-- исполняемые файлы и скрипты во временных каталогах:"
find /tmp /var/tmp /dev/shm -xdev -type f \( -perm -u+x -o -name '*.php' -o -name '*.sh' -o -name '*.so' \) \
-printf '%TY-%Tm-%Td %TH:%TM %u %m %p\n' 2>/dev/null | head -30
hdr "5. Процессы и сеть под пользователем веб-сервера"
ps -eo user:20,pid,ppid,etime,args 2>/dev/null | awk '$1 ~ /^(www-data|apache|nginx|www)$/ && $5 !~ /php-fpm|nginx|apache2|httpd|lsphp/' | cut -c1-200
echo "(пусто = нет посторонних процессов; php-fpm/nginx/apache2/httpd отфильтрованы)"
echo "-- процессы www-data с удалённым исполняемым файлом:"
for p in $(pgrep -u www-data 2>/dev/null); do
e=$(readlink "/proc/$p/exe" 2>/dev/null); case "$e" in *"(deleted)"*) echo "PID $p $e" ;; esac
done
if have ss; then
echo "-- соединения оболочек и скриптовых интерпретаторов:"
ss -tnp 2>/dev/null | grep -E 'users:\(\("(sh|bash|dash|nc|ncat|socat|python[0-9.]*|perl|ruby|php[0-9.]*|curl|wget)"'
echo "-- исходящие ESTAB от php-fpm/apache/httpd на внешние адреса (разбирать вручную: API, SMTP, CDN бывают легитимны):"
ss -tnp 2>/dev/null | awk '$1=="ESTAB" && $5 !~ /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|\[::1\])/ && /php-fpm|apache2|httpd/' | cut -c1-200 | head -20
fi
hdr "6. Cron, таймеры и ключи SSH"
echo "-- cron-файлы, изменённые после $NEWER:"
find /etc/cron* /var/spool/cron /etc/systemd/system -xdev -type f \( -newermt "$NEWER" -o -newerct "$NEWER" \) -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null
echo "-- crontab пользователей веб-сервера:"
for u in www-data apache nginx; do id "$u" >/dev/null 2>&1 && { echo "[$u]"; crontab -l -u "$u" 2>&1 | head -20; }; done
echo "-- authorized_keys (дата изменения и отпечатки):"
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys /var/www/.ssh/authorized_keys; do
[ -f "$f" ] || continue
printf '%s %s\n' "$(stat -c '%y' "$f" | cut -c1-16)" "$f"
have ssh-keygen && ssh-keygen -lf "$f" 2>/dev/null
done
hdr "7. Целостность ядра WordPress (только с флагом -c)"
if [ "$WPCHK" -eq 1 ] && have wp; then
for r in "${EXISTING[@]}"; do
find "$r" -maxdepth 3 -name wp-config.php -print 2>/dev/null | while read -r c; do
d=$(dirname "$c"); own=$(stat -c %U "$d")
echo "-- $d (владелец $own)"
sudo -u "$own" wp core verify-checksums --path="$d" 2>&1 | head -20
done
done
else
echo "пропущено (нужны флаг -c и установленный WP-CLI). Команда: wp core verify-checksums --path=/var/www/site"
fi
hdr "Итог"
echo "Чистый отчёт не доказывает отсутствие взлома: лог мог не содержать Content-Type, быть ротирован или очищен."
echo "При находках: ничего не удаляем и не перезаписываем, снимаем снимок, список секретов отдаём владельцу."Запуск выглядит так. Сначала скачайте или создайте файл, потом просмотрите его глазами (чужие скрипты на сервер без чтения не ставим, и этот тоже), затем запустите.
sudo bash php2shell-audit.sh -r /var/www -s 2026-10-14 -n 2026-10-21 -d '1[4-9]/Oct/2026|2[01]/Oct/2026' > /root/audit-$(hostname)-20261021.txt
less /root/audit-$(hostname)-20261021.txtКак читать отчёт. Раздел 1 показывает версии PHP и процессы, которые держат заменённые обновлением файлы: непустой список после патча означает, что нужный перезапуск не выполнен, и это важнее любых других находок. Разделы 2–3 дают количественные признаки по логам и журналам. Раздел 4 перечисляет свежие файлы. Раздел 5 показывает чужие процессы и подозрительные соединения. Раздел 6 охватывает cron и ключи. Раздел 7 работает только с флагом -c.
Чего скрипт не делает намеренно: не пытается определить вердикт, не ищет «сигнатуры веб-шеллов» и не блокирует адреса. Любая сигнатура устареет за неделю, а автоматическое действие по неточному признаку опаснее самого признака. Скрипт нужен, чтобы за несколько минут собрать факты для решения, которое принимает человек.
Типичные ошибки при запуске. Первая: запуск не от root, в отчёте тогда пусто, и это читают как «всё чисто». Вторая: нужные поля не включались в лог заранее, а пустая выборка по ct= трактуется как отсутствие multipart-запросов. Третья: проверка до перезапуска PHP, и тогда новые находки смешиваются со старыми. Четвёртая: запись отчёта внутрь каталога сайта, откуда его может забрать любой, кто получил доступ к файлам.
Что делать при находках и когда звать инцидент-респонс
Порядок такой, как мы действуем у клиентов. Первое: ничего не стираем. Не чистим логи, не удаляем найденные файлы, не «подчищаем» сервер: это улики, а чистка мешает понять масштаб. Второе: обновляем PHP и перезапускаем воркеры, как описано в статье обновление PHP на Debian и Ubuntu через Sury и PPA, и проверяем, что процессы не держат старые библиотеки. Для панелей с несколькими версиями PHP путь свой: HestiaCP и ISPmanager с несколькими версиями PHP. Если сайт работает в контейнере, смотрите PHP-FPM в Docker, Nextcloud и mailcow. Обновление закрывает дальнейшую эксплуатацию, но не откатывает то, что уже произошло.
Третье: снимок. Сделайте снимок виртуальной машины или диска и сохраните копию логов, отчёта скрипта и списка найденных файлов вне сервера. Если машина физическая, сохраните образы нужных каталогов и логов. Четвёртое: изоляция. Если следы похожи на реальное выполнение команд, отключите сервер от внутренней сети или ограничьте его исходящий трафик, чтобы он не стал плацдармом. Остановку сервиса согласуйте с владельцем бизнеса, потому что это влияет на работу сайта.
Пятое: секреты. По Sansec и Patchstack, через PHP2Shell может утечь содержимое памяти воркера: пароли, токены сессий, API-ключи, приватные ключи TLS. Мы составляем список всего, что хранилось на сервере или проходило через него: пароли баз и почты из конфигов приложений, API-ключи платёжных и почтовых сервисов, ключи SSH, сертификаты TLS, токены интеграций, секреты CMS. Этот список мы передаём владельцу на ротацию. Пароли и ключи живых учёток и систем сами не меняем: смену выполняет владелец или по его прямой команде, потому что ротация без согласования ломает работающие интеграции и входы сотрудников.
Теперь о границе, за которой нужен инцидент-респонс. Мы зовём специалистов или подключаем свою группу разбора, если выполнено хотя бы одно из условий: найден неизвестный файл с признаками веб-шелла или недавно изменённый исполняемый PHP в каталоге загрузок; есть чужие процессы под пользователем веб-сервера или исходящие соединения оболочек; появились незнакомые ключи SSH, задания cron или юниты systemd; несовпадение контрольных сумм ядра CMS без объяснимой причины; на сервере хранились ключи или данные, утечка которых имеет юридические последствия, например персональные данные клиентов страхового брокера. NCSC в этом вопросе советует до откладывания обновления проверять систему на следы компрометации, а при невозможности применить меры рассмотреть отключение системы.
Если признаков нет, это не повод расслабиться. Дальше идёт обычная работа: убедиться, что PHP обновлён на всех серверах и в контейнерах (инвентаризация, о которой мы говорили выше), оставить расширенный формат лога включённым, настроить оповещение о всплесках POST с кодом 500 и о записях segfault в журнале, повторить проверку через неделю после новых публикаций. Патч и перезапуск остаются основной защитой, а логи нужны для проверки, что мы не опоздали.
И последнее, что мы советуем не делать: не ставить плагины-заплатки и сниппеты с форумов (об этом предупреждает Patchstack), не считать защитой режим обслуживания CMS (PHP разбирает запрос раньше кода приложения) и не принимать молчание логов за доказательство чистоты. Если нужна помощь с ревизией, разбором логов или переездом на поддерживаемую версию PHP, наши специалисты по обслуживанию серверов и сайтов проведут проверку и подготовят отчёт для владельца.
Частые вопросы
Достаточно ли логов nginx или Apache, чтобы понять, взломали ли сайт через PHP2Shell?
Нет, логи дают только косвенные признаки. Patchstack называет их не доказательством ни в ту, ни в другую сторону: необычные multipart-запросы, за которыми идут ошибки memory_limit или HTTP 500, раздутые куки PHPSESSID и падения воркеров. Чистый лог не гарантирует чистоту сервера: логи могли быть ротированы или подчищены. Проверяйте ещё файлы, процессы, cron и ключи SSH.
Почему в моём access-логе нет Content-Type запроса?
Веб-сервер по умолчанию его не пишет. Формат combined в nginx содержит адрес, время, запрос, статус, размер, Referer и User-Agent. В Apache формат по умолчанию ещё беднее. Добавьте в log_format nginx переменную $http_content_type или в LogFormat Apache директиву %{Content-Type}i. На прошлые запросы это не подействует, только на новые.
Какая команда покажет падения воркеров PHP-FPM?
Выполните journalctl -u php8.3-fpm --since "2026-10-14" --no-pager | grep -E 'exited on signal|SIGSEGV', подставив имя своего юнита. В логе PHP-FPM ищите child ... exited on signal 11 (SIGSEGV), в Apache AH00052, в журнале ядра segfault. Единичный сегфолт может иметь безобидную причину, значение имеет серия записей в те же минуты, что и POST с кодом 500.
Как проверить целостность ядра WordPress после подозрения на взлом?
Запустите wp core verify-checksums в каталоге сайта от пользователя веб-сервера. Команда по документации WP-CLI скачивает md5-суммы вашей версии с WordPress.org, сравнивает их с файлами и выводит несовпавшие предупреждением. Она проверяет только ядро, а плагины, темы и uploads сверять надо отдельно, например по списку свежих файлов через find с параметром -newermt.
Что делать, если в логах нашли признаки эксплуатации PHP2Shell?
Ничего не удалять и не чистить логи, обновить PHP с перезапуском воркеров, сделать снимок сервера и сохранить копии логов. Список ключей и паролей, которые хранились на сервере, мы передаём владельцу на ротацию; пароли живых систем сами не меняем. При веб-шеллах, чужих процессах, незнакомых ключах SSH или cron подключайте специалистов по инцидентам.
Источники
- Patchstack: признаки эксплуатации и временные меры — Проверено: перечень признаков (необычные multipart-запросы, ошибки memory_limit, повторяющиеся HTTP 500, аномальные PHPSESSID, перезапуски воркеров) и оговорка, что они не доказательство: https://patchstack.com/articles/php-major-vulnerability-alert-10-2026/
- Sansec: исследование PHP2Shell — Проверено: механизм утечки содержимого памяти через Set-Cookie PHPSESSID и временные меры: https://sansec.io/research/php2shell
- NCSC UK: рекомендации по критической уязвимости PHP — Проверено: инвентаризация PHP, обновление, проверка на следы компрометации до откладывания: https://www.ncsc.gov.uk/news/critical-security-issue-affecting-php
- WP-CLI: wp core verify-checksums — Проверено: синтаксис, параметры --version, --locale, --include-root, поведение до загрузки WordPress и сравнение с md5-суммами с WordPress.org: https://developer.wordpress.org/cli/commands/core/verify-checksums/
- nginx: модуль ngx_http_log_module — Проверено: синтаксис log_format, предопределённый формат combined без Content-Type, префикс sent_http_ для заголовков ответа: https://nginx.org/en/docs/http/ngx_http_log_module.html
- Apache HTTP Server: mod_log_config — Проверено: директивы %{Имя}i, %{Имя}o и %D (микросекунды), формат по умолчанию Common Log Format без Content-Type: https://httpd.apache.org/docs/2.4/mod/mod_log_config.html


