ctrllife.ru/ctrllife

Уязвимость PHP2Shell: как закрыть CVE-2026-109618 и CVE-2026-109619 на своём сервере

Уязвимость PHP2Shell: как закрыть CVE-2026-109618 и CVE-2026-109619 на своём сервере
Содержание11 разделов

21 октября 2026 года PHP выпустил версии 8.5.12, 8.4.27, 8.3.36 и 8.2.35. Они закрывают две уязвимости с оценкой 9.2 по CVSS v4.0, которые позволяют выполнить код на сервере без авторизации. Уязвимости уже эксплуатируются, готовый инструмент для атаки получил название PHP2Shell.

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

Что сделать прямо сейчас

  1. Сделать снимок или резервную копию сервера.
  2. Запустить скрипт php2shell-fix.sh с ключом --fix: он обновит все PHP, перезапустит обработчики и проверит результат.
  3. Если исправленных пакетов ещё нет, включить временные меры ключом --mitigate.
  4. Проверить сервер на признаки взлома ключом --audit.

Под угрозой любой PHP, который обслуживает веб-запросы: PHP-FPM, Apache с mod_php, php-cgi, FrankenPHP, LiteSpeed. Уязвимое приложение не требуется: атака срабатывает на внутренней обработке запроса, ещё до запуска кода сайта.

Как развивались события вокруг PHP2Shell

Выпуск планировался по обычной схеме с эмбарго, но подробности утекли раньше. Инструмент для атаки появился в сети до выхода исправлений, и раскрытие пришлось сдвинуть с 22 на 21 октября. Весь цикл уложился в двое суток.

Дата (2026)

Событие

20 октября

PHP заранее объявляет критический выпуск на 21 октября 14:00 UTC, без номеров CVE

20 октября

Подробности утекают. watchTowr подтверждает, что уязвимости настоящие, и воспроизводит обе

21 октября, 07:28 UTC

Upwind публикует технический разбор: место ошибки, ход атаки, временные меры

21 октября

Британский NCSC просит организации готовиться к немедленному обновлению, а системы, которые закрыть нельзя, отключать от сети

21 октября, 14:00 UTC

PHP выпускает 8.5.12, 8.4.27, 8.3.36 и 8.2.35, публикует уведомление и номера CVE

21 октября, 14:19 UTC

watchTowr сообщает о попытках эксплуатации обеих уязвимостей в реальной сети

21 октября, вечер

Хостеры рассылают клиентам срочные уведомления; провайдеры VPS отдельно предупреждают, что доступа к серверам у них нет и обновлять придётся самим

Как устроены уязвимости CVE-2026-109618 и CVE-2026-109619

Обе ошибки относятся к классу use-after-free («использование после освобождения»). PHP обращается к участку памяти, который уже отдан под другие данные, а нападающий может подложить туда свои.

CVE-2026-109618: порча памяти при её нехватке

Ошибка нехватки памяти может прервать PHP, пока внутренние данные запроса заполнены лишь частично. Затем уборка после запроса принимает недоделанные данные за настоящие. Нехватку памяти нападающий вызывает сам, присылая большой или сложно устроенный запрос. Бюллетень: GHSA-w8hw-w9fx-j79w.

CVE-2026-109619: устаревшее глобальное состояние

Состояние, принадлежащее одному запросу, переживает освобождение своей памяти. Следующий запрос, попавший на тот же обработчик, успевает им воспользоваться до штатного сброса. Бюллетень: GHSA-6wmf-4rv5-p3rp.

Как проходит атака через session upload progress

Подтверждённый путь атаки идёт через отслеживание хода загрузки сессий (session.upload_progress). По техническому разбору Upwind, сценарий такой:

  1. Нападающий отправляет быстрый multipart-запрос с полем PHP_SESSION_UPLOAD_PROGRESS перед файлом. PHP запоминает указатели на счётчики загрузки.
  2. Второй запрос заставляет PHP перечитать сессию, и память под этими указателями освобождается.
  3. На освободившееся место кладётся подделанная структура вызова функции, и следующее обращение по старому указателю вызывает, например, system().

Для атаки нужно смещение, свойственное конкретной сборке PHP. При неверном смещении или проигранной гонке обработчик падает, и nginx перед PHP-FPM отвечает 502. Всплеск ошибок 502 и падений обработчиков означает неудачные попытки взлома, а удачная попытка выглядит как обычный ответ 200.

Специально включать для атаки ничего не нужно: отслеживание загрузки активно в стандартном php.ini. Код выполняется от имени пользователя, под которым работает PHP.

Какие версии PHP уязвимы

Уязвим любой необновлённый PHP, через который проходят веб-запросы. Режим запуска не спасает: затронуты PHP-FPM, Apache с mod_php, php-cgi, FrankenPHP и LiteSpeed. Сильнее всего рискует mod_php: для него показана рабочая атака, которая срабатывает ещё до запуска кода приложения.

PHP только в консоли (cron-задачи, composer, artisan) напрямую не атакуется, так как запросы из сети до него не доходят. Но если на той же машине стоит PHP-FPM той же ветки, обновлять всё равно необходимо.

Ветка

Уязвимы

Исправлено в

Что делать

8.5

до 8.5.11 включительно

8.5.12

обновить

8.4

до 8.4.26 включительно

8.4.27

обновить

8.3

до 8.3.35 включительно

8.3.36

обновить

8.2

до 8.2.34 включительно

8.2.35

обновить и спланировать переход: поддержка заканчивается 31.12.2026

8.1, 8.0, 7.4

все

исправлений от PHP нет

исправление от дистрибутива или LTS-поставщика, сборка из исходников с переносом исправлений либо переход на 8.3+

7.3 и старше, 5.x

по данным watchTowr, вероятно, все

не будет

изолировать от интернета и переводить на поддерживаемую ветку

Номер версии в дистрибутиве может не совпадать с таблицей: Ubuntu, RHEL и производные переносят исправление в свою сборку, не меняя основного номера.

Где взять исправление для своего дистрибутива

Исправление берётся из того же источника, откуда установлен PHP. Дистрибутивы и LTS-поставщики получили патчи заранее, поэтому в основных системах пакеты вышли в день раскрытия.

Источник PHP

Статус на 21.10.2026

Как проверить, что исправление стоит

Ubuntu 22.04–26.10, пакеты дистрибутива

вышел USN-8948-1; номер версии не меняется: 24.04 остаётся на 8.3.6, 26.04 на 8.5.4

в журнале изменений пакета есть CVE-2026-10961[89]

Ubuntu 18.04 и 20.04

только через Ubuntu Pro (ESM)

pro fix CVE-2026-109618

Debian 13 trixie

DSA-6574-1, пакет 8.4.27-1~deb13u1

версия пакета не ниже указанной

Debian 12 bookworm

пакет 8.2.35-1~deb12u1

версия пакета не ниже указанной

RHEL, AlmaLinux, Rocky

RHSA-2026:87072; на прошлом плановом выпуске Red Hat отставал от PHP примерно на 4 недели

dnf updateinfo list --security | grep php

ppa:ondrej/php, packages.sury.org

срок выхода не объявлен

основной номер версии не ниже исправленного

Remi (rpms.remirepo.net)

в день раскрытия исправлений нет; PHP Foundation советует перейти на пакеты дистрибутива или собрать из исходников

основной номер версии

Arch Linux

php 8.5.12 и php-legacy 8.3.36

pacman -Q php php-legacy

cPanel/WHM, EasyApache 4

по сообщениям хостеров, обновления уже есть: /scripts/upcp

WHM → MultiPHP Manager

Plesk Obsidian

по сообщениям хостеров, обновления уже есть

обновить через панель

CloudLinux alt-php

следить за статусом CloudLinux; для старых веток есть расширенная поддержка

—

Docker-образы php:

скачать новый образ, пересобрать свои через docker build --pull, пересоздать контейнеры

docker exec <контейнер> php -v

FrankenPHP

PHP встроен в бинарник, apt и dnf его не обновят; нужен новый выпуск или образ

frankenphp version

LiteSpeed (lsphp)

обновить пакеты lsphpXY из репозитория LiteSpeed

—

Как перезапустить PHP после обновления

Способ перезапуска зависит от режима работы PHP.

Режим

Что сделать после обновления

PHP-FPM за nginx, Caddy или Apache

systemctl restart phpX.Y-fpm (на RHEL php-fpm); веб-сервер трогать не нужно

Apache с mod_php

полный systemctl restart apache2 (httpd); reload и graceful не помогают, Apache продолжает работать со старой библиотекой

LiteSpeed, OpenLiteSpeed

pkill lsphp: перезапуск самого LiteSpeed не перезапускает процессы PHP

Очереди, Supervisor, Laravel Octane

перезапустить; octane:reload перезагружает код, но не сам PHP

Docker

пересоздать контейнеры из нового образа

Как защититься, если обновить PHP сейчас нельзя

Временные меры сужают поверхность атаки, но сервер безопасным не делают. PHP прямо указывает, что полной защиты, кроме обновления, нет, а watchTowr считает правки ini ненадёжными. Эти меры рассчитаны на часы, пока не выйдут пакеты.

Отключить отслеживание хода загрузки

Мера закрывает подтверждённый путь CVE-2026-109619. Настройка задаётся в php.ini или в пуле FPM, после чего перезапускаются все обработчики. ini_set() и .user.ini для этого не подходят.

Отдельный ini-файл для каждой установленной версии PHP:

/etc/php/8.4/fpm/conf.d/99-php2shell.ini
session.upload_progress.enabled = 0

Для пула FPM надёжнее вариант, который приложение не сможет переопределить:

/etc/php/8.4/fpm/pool.d/www.conf
php_admin_flag[session.upload_progress.enabled] = off

На PHP 8.5 дополнительно задаётся fatal_error_backtraces = 0: эта настройка закрывает второй подтверждённый путь атаки.

Ограничить размер запросов

Против CVE-2026-109618 помогает ограничение тела запроса. Сайтам без загрузки файлов достаточно post_max_size = 8M, сайтам с загрузками — 32M, при этом upload_max_filesize должен быть меньше post_max_size.

PHP также предлагает memory_limit = -1 через php_admin_value, но на боевом сервере такая настройка рискованна, поэтому скрипт ниже её не применяет.

Закрыть multipart на веб-сервере

Самая грубая и самая надёжная временная мера для сайтов, которые не принимают файлы: опасный запрос просто не доходит до PHP.

Карта для nginx в контексте http {}:

/etc/nginx/conf.d/php2shell-block-multipart.conf
map $http_content_type $php2shell_block_multipart {
    default       0;
    "~*multipart" 1;
}

Проверка в каждом блоке server {}, который передаёт запросы в PHP:

if ($php2shell_block_multipart) { return 403; }

Аналог для Apache 2.4:

<If "req('Content-Type') =~ /multipart/i">
    Require all denied
</If>

Защита через Cloudflare

По заявлению Cloudflare, правила против известных векторов атаки включены на всех тарифах, включая бесплатный. Работают они только при условии, что сервер принимает трафик исключительно с адресов Cloudflare. Если до сервера можно достучаться напрямую по IP, защиты нет.

Что не помогает

  • file_uploads = Off: PHP всё равно разбирает тело запроса.
  • Защитные плагины WordPress, проверка CSRF, режим обслуживания Laravel: все они срабатывают уже после разбора запроса.

После обновления блокировку multipart нужно снять, а session.upload_progress.enabled = 0 можно оставить навсегда: этой функцией почти никто не пользуется.

Скрипт php2shell-fix.sh: проверка и обновление всех PHP на сервере

Скрипт проверяет, обновляет и временно защищает PHP на любом Linux-сервере. Без ключей он ничего не меняет и только показывает состояние.

Что умеет скрипт

  • Находит все PHP на машине: пакеты дистрибутива, ondrej/sury, Remi, cPanel (ea-php), CloudLinux (alt-php), Plesk, LiteSpeed, FrankenPHP, сборки из исходников, Apache с mod_php и PHP внутри контейнеров Docker.
  • Распознаёт исправленные версии, включая перенос патча в Ubuntu и RHEL: ищет CVE в журнале изменений пакета, а не опирается на php -v.
  • Обновляет только пакеты PHP и корректно перезапускает обработчики: службы PHP-FPM, полный перезапуск Apache при mod_php, pkill lsphp для LiteSpeed, сигнал USR2 мастеру FPM там, где нет systemd.
  • Находит процессы, которые после обновления держат старый код в памяти.
  • Включает и снимает временные меры, предварительно проверив конфигурацию FPM через php-fpm -t.
  • Ищет признаки взлома: свежие и подозрительные PHP-файлы, PHP в каталогах загрузок, посторонние процессы и соединения веб-пользователей, cron, systemd, ключи SSH, исполняемые файлы в /tmp, падения обработчиков и всплески 502.
  • Пишет журнал в /var/log/php2shell-<дата>.log.

Контейнеры скрипт не пересоздаёт, а выводит готовую команду для каждого (для mailcow это ./update.sh).

Скрипт протестирован на Ubuntu 24.04 с PHP 8.3 из репозитория дистрибутива: определение исправленного пакета, имитация уязвимых сборок 8.4.20 и 8.1.2, процессы со старым кодом, включение и снятие временных мер, поиск следов (тестовая веб-оболочка, процесс от www-data, ответы 502 в журнале nginx). shellcheck проходит без предупреждений.

Как запустить проверку и обновление

Запуск от root на каждом сервере, типовые сценарии:

# 1. Посмотреть состояние, ничего не меняя
bash php2shell-fix.sh

# 2. Обновить PHP, перезапустить обработчики и проверить результат
bash php2shell-fix.sh --fix

# 3. Пакетов ещё нет — временные меры, затем повторять --fix
bash php2shell-fix.sh --mitigate

# 4. Поиск признаков взлома за 7 суток, с дополнительным каталогом сайтов
bash php2shell-fix.sh --audit --days 7 --webroot /opt/sites

# 5. Всё сразу без вопросов, например из Ansible
bash php2shell-fix.sh --fix --audit --yes

# 6. После обновления снять временные меры
bash php2shell-fix.sh --unmitigate

Проверка нескольких серверов по коду выхода:

for h in <host1> <host2>; do ssh root@$h 'bash -s' < php2shell-fix.sh || echo "$h требует внимания"; done

Коды выхода: 0 — уязвимых PHP нет; 1 — найдены уязвимые PHP или процессы со старым кодом; 2 — ошибка запуска.

Полный код скрипта

Версия 1.0 от 21.10.2026:

php2shell-fix.sh
#!/usr/bin/env bash
# php2shell-fix.sh — проверка, обновление и временная защита PHP
# от CVE-2026-109618 и CVE-2026-109619 («PHP2Shell», 21.10.2026).
# Справка: bash php2shell-fix.sh --help. Запускать от root.
# Коды выхода: 0 — уязвимых PHP нет, 1 — найдены уязвимые или старые процессы, 2 — ошибка.

set -uo pipefail

SCRIPT_VERSION="1.0 (21.10.2026)"
CVE_RE='CVE-2026-10961[89]'
MITIGATION_FILE='99-php2shell-mitigation.ini'
declare -A FIXED=([8.5]=8.5.12 [8.4]=8.4.27 [8.3]=8.3.36 [8.2]=8.2.35)

DO_FIX=0; DO_MITIGATE=0; DO_UNMITIGATE=0; DO_AUDIT=0
ASSUME_YES=0; AUDIT_DAYS=3; CHECK_DOCKER=1; USE_COLOR=1
EXTRA_WEBROOTS=()

HOST_FOUND=0; HOST_VULN=0; STALE=0; DOCKER_VULN=0; AUDIT_HITS=0; MITIGATED=0
DOCKER_LIST=()

usage() {
  cat <<'EOF'
php2shell-fix.sh — защита PHP от CVE-2026-109618 и CVE-2026-109619

  bash php2shell-fix.sh                  только проверка, ничего не меняет
  bash php2shell-fix.sh --fix            обновить PHP, перезапустить, проверить
  bash php2shell-fix.sh --mitigate       временные меры, если пакетов ещё нет
  bash php2shell-fix.sh --unmitigate     снять временные меры
  bash php2shell-fix.sh --audit          поиск признаков взлома

  --yes, -y        не задавать вопросов
  --days N         глубина поиска для --audit, суток (по умолчанию 3)
  --webroot DIR    дополнительный каталог сайтов для --audit (можно повторять)
  --no-docker      не проверять контейнеры
  --no-color       без цвета
  --help, -h       эта справка

  Пример: bash php2shell-fix.sh --fix --audit --yes
EOF
}

while [ $# -gt 0 ]; do
  case "$1" in
    --fix) DO_FIX=1 ;;
    --mitigate) DO_MITIGATE=1 ;;
    --unmitigate) DO_UNMITIGATE=1 ;;
    --audit) DO_AUDIT=1 ;;
    --yes|-y) ASSUME_YES=1 ;;
    --days) shift; AUDIT_DAYS="${1:-}" ;;
    --webroot) shift; EXTRA_WEBROOTS+=("${1:-}") ;;
    --no-docker) CHECK_DOCKER=0 ;;
    --no-color) USE_COLOR=0 ;;
    -h|--help) usage; exit 0 ;;
    *) echo "Неизвестный ключ: $1"; usage; exit 2 ;;
  esac
  shift
done

if ! [[ "$AUDIT_DAYS" =~ ^[0-9]+$ ]] || [ "$AUDIT_DAYS" -lt 1 ]; then
  echo "--days ждёт целое число суток больше нуля"; exit 2
fi
if [ "$DO_MITIGATE" -eq 1 ] && [ "$DO_UNMITIGATE" -eq 1 ]; then
  echo "--mitigate и --unmitigate вместе не имеют смысла"; exit 2
fi
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
  echo "Запустите от root: sudo bash $0 $*"; exit 2
fi

if [ "$USE_COLOR" -eq 1 ] && [ -t 1 ]; then
  R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; B=$'\e[1m'; N=$'\e[0m'
else
  R=''; G=''; Y=''; B=''; N=''
fi

LOG="/var/log/php2shell-$(date +%F-%H%M%S).log"
if touch "$LOG" 2>/dev/null; then
  exec > >(tee >(sed -u 's/\x1b\[[0-9;]*m//g' >> "$LOG")) 2>&1
else
  LOG=""
fi

# --- помощники

hdr()  { printf '\n%s=== %s ===%s\n' "$B" "$*" "$N"; }
sub()  { printf '\n  %s%s%s\n' "$B" "$*" "$N"; }
ok()   { printf '  %s[OK]%s %s\n' "$G" "$N" "$*"; }
bad()  { printf '  %s[УЯЗВИМ]%s %s\n' "$R" "$N" "$*"; }
warn() { printf '  %s[!]%s %s\n' "$Y" "$N" "$*"; }
hit()  { printf '  %s[?]%s %s\n' "$Y" "$N" "$*"; AUDIT_HITS=$((AUDIT_HITS + 1)); }
info() { printf '  %s\n' "$*"; }
old()  { printf '  %s[СТАРЫЙ КОД]%s %s\n' "$R" "$N" "$*"; }
have() { command -v "$1" >/dev/null 2>&1; }

confirm() {
  [ "$ASSUME_YES" -eq 1 ] && return 0
  [ -r /dev/tty ] || return 1
  local a
  read -r -p "  $* [y/N] " a < /dev/tty || return 1
  [[ "$a" =~ ^([YyДд]|yes|да)$ ]]
}

# $1 >= $2 по правилам сравнения версий
ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$2" ]; }

# Достаёт версию вида 8.3.6 из строки версии пакета: 8.3.6-0ubuntu0.24.04.13, 1:8.2.35-1~deb12u1
upstream_of() { printf '%s' "$1" | sed -E 's/^[0-9]+://' | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' | head -1; }

PM=none
if have dpkg-query && have apt-get; then PM=apt
elif have dnf; then PM=dnf
elif have yum; then PM=yum
elif have pacman; then PM=pacman
elif have apk; then PM=apk
fi

OS_NAME=$( (. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}") )
OS_NAME=${OS_NAME:-неизвестная система}

# --- поиск PHP

find_php_binaries() {
  local c p exe
  {
    for c in /usr/bin/php /usr/bin/php[0-9]* /usr/bin/php-cgi* /usr/bin/php-fpm* \
             /usr/local/bin/php /usr/local/bin/php[0-9]* \
             /usr/sbin/php-fpm* /usr/local/sbin/php-fpm* \
             /opt/remi/php*/root/usr/bin/php /opt/remi/php*/root/usr/sbin/php-fpm \
             /opt/cpanel/ea-php*/root/usr/bin/php /opt/cpanel/ea-php*/root/usr/sbin/php-fpm \
             /opt/alt/php*/usr/bin/php /opt/alt/php*/usr/sbin/php-fpm \
             /opt/plesk/php/*/bin/php /opt/plesk/php/*/sbin/php-fpm \
             /usr/local/lsws/lsphp*/bin/php /usr/local/lsws/lsphp*/bin/lsphp \
             /usr/bin/frankenphp /usr/local/bin/frankenphp; do
      [ -f "$c" ] && [ -x "$c" ] && readlink -f "$c"
    done
    # бинарники работающих процессов в нестандартных местах
    for p in /proc/[0-9]*; do
      exe=$(readlink "$p/exe" 2>/dev/null) || continue
      case "$exe" in *" (deleted)") continue ;; esac
      case "${exe##*/}" in php*|lsphp*|frankenphp) echo "$exe" ;; esac
    done
  } | grep -vE '/php(ize|-config|dbg)' | sort -u
}

php_version() {
  local b="$1" out=""
  case "${b##*/}" in
    frankenphp) out=$("$b" version 2>/dev/null) ;;
    *) out=$("$b" -n -v 2>/dev/null) || out=$("$b" -v 2>/dev/null) ;;
  esac
  printf '%s' "$out" | grep -oE 'PHP [0-9]+\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}'
}

owner_pkg() {
  local f="$1"
  case "$PM" in
    apt) dpkg -S "$f" 2>/dev/null | head -1 | sed -E 's/: .*//; s/:[a-z0-9_]+$//' ;;
    dnf|yum) rpm -qf "$f" 2>/dev/null | grep -v 'not owned' | head -1 ;;
    pacman) pacman -Qoq "$f" 2>/dev/null | head -1 ;;
    apk) apk info --who-owns "$f" 2>/dev/null | awk '{print $NF}' | head -1 ;;
  esac
}

pkg_version() {
  case "$PM" in
    apt) dpkg-query -W -f='${Version}' "$1" 2>/dev/null ;;
    dnf|yum) rpm -q --qf '%{VERSION}-%{RELEASE}' "$1" 2>/dev/null ;;
    pacman) pacman -Q "$1" 2>/dev/null | awk '{print $2}' ;;
    apk) apk info -e -v "$1" 2>/dev/null ;;
  esac
}

# Есть ли в журнале изменений пакета упоминание этих CVE ($1 — пакет, $2 — ветка, например 8.3)
backport_mentioned() {
  local pkg="$1" br="$2" f
  case "$PM" in
    apt)
      for f in "/usr/share/doc/$pkg/changelog.Debian.gz" \
               "/usr/share/doc/php$br-common/changelog.Debian.gz" \
               "/usr/share/doc/php$br-cli/changelog.Debian.gz"; do
        [ -f "$f" ] && zgrep -qE "$CVE_RE" "$f" 2>/dev/null && return 0
      done
      return 1 ;;
    dnf|yum)
      rpm -q --changelog "$pkg" 2>/dev/null | grep -qE "$CVE_RE" ;;
    *) return 1 ;;
  esac
}

# Оценка одного PHP: $1 — версия, $2 — что это (путь), $3 — пакет (необязательно)
evaluate() {
  local v="$1" what="$2" pkg="${3:-}" br pv
  br="${v%.*}"
  pv=""
  [ -n "$pkg" ] && pv=$(pkg_version "$pkg")
  if [ -n "${FIXED[$br]:-}" ] && ver_ge "$v" "${FIXED[$br]}"; then
    ok "$what — PHP $v (исправлено начиная с ${FIXED[$br]})"
    return 0
  fi
  if [ -n "$pkg" ] && backport_mentioned "$pkg" "$br"; then
    ok "$what — PHP $v, пакет $pkg $pv: дистрибутив перенёс исправление"
    return 0
  fi
  if [ -n "${FIXED[$br]:-}" ]; then
    bad "$what — PHP $v${pkg:+, пакет $pkg $pv}: нужна ${FIXED[$br]} или пакет с переносом исправления"
  else
    bad "$what — PHP $v${pkg:+, пакет $pkg $pv}: ветка $br без поддержки, нужен перенос исправления или переход на 8.3+"
  fi
  return 1
}

check_host() {
  local title="${1:-PHP на этой машине}" bins b v n=0 vuln=0 lib pkg pv
  hdr "$title"
  info "Система: $OS_NAME, менеджер пакетов: $PM"
  mapfile -t bins < <(find_php_binaries)
  for b in "${bins[@]}"; do
    v=$(php_version "$b")
    if [ -z "$v" ]; then warn "$b — не удалось узнать версию"; continue; fi
    n=$((n + 1))
    evaluate "$v" "$b" "$(owner_pkg "$b")" || vuln=$((vuln + 1))
  done
  # Apache mod_php: версию берём из пакета модуля
  for lib in /usr/lib/apache2/modules/libphp*.so /usr/lib64/httpd/modules/libphp*.so \
             /usr/lib/httpd/modules/libphp*.so /etc/httpd/modules/libphp*.so; do
    [ -f "$lib" ] || continue
    pkg=$(owner_pkg "$(readlink -f "$lib")")
    pv=""; [ -n "$pkg" ] && pv=$(pkg_version "$pkg")
    v=$(upstream_of "$pv")
    if [ -z "$v" ]; then warn "$lib (mod_php) — версию определить не удалось, проверьте пакет вручную"; continue; fi
    n=$((n + 1))
    evaluate "$v" "$lib (mod_php — самый опасный режим)" "$pkg" || vuln=$((vuln + 1))
  done
  [ "$n" -eq 0 ] && info "PHP на машине не найден."
  HOST_FOUND=$n; HOST_VULN=$vuln
}

check_stale() {
  local f pid cmd list=() shown=0
  hdr "Процессы со старым кодом PHP в памяти"
  for f in /proc/[0-9]*/maps; do
    grep -qE '/(usr|lib|opt)/[^ ]*php[^ ]* \(deleted\)$' "$f" 2>/dev/null || continue
    pid=${f#/proc/}; pid=${pid%/maps}
    cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | cut -c1-100 | sed 's/[[:space:]]*$//')
    list+=("PID $pid: ${cmd:-?}")
  done
  STALE=${#list[@]}
  if [ "$STALE" -eq 0 ]; then
    ok "все процессы PHP работают на установленном коде"
    return
  fi
  for f in "${list[@]}"; do
    [ "$shown" -ge 20 ] && { info "... и ещё $((STALE - shown))"; break; }
    old "$f"; shown=$((shown + 1))
  done
  warn "Перезапустите их, иначе исправление не действует."
}

check_mitigation_status() {
  local d found=()
  while read -r d; do
    [ -f "$d/$MITIGATION_FILE" ] && found+=("$d/$MITIGATION_FILE")
  done < <(ini_scan_dirs)
  MITIGATED=${#found[@]}
  if [ "$MITIGATED" -gt 0 ]; then
    hdr "Временные меры"
    info "Включены в ${MITIGATED} местах (снять после обновления: --unmitigate):"
    for d in "${found[@]}"; do info "  $d"; done
  fi
}

# --- Docker

check_docker() {
  [ "$CHECK_DOCKER" -eq 1 ] || return 0
  have docker || return 0
  docker info >/dev/null 2>&1 || return 0
  hdr "PHP в контейнерах Docker"
  local id name image v br ncont=0
  # shellcheck disable=SC2016
  local probe='for b in php php-fpm /usr/local/sbin/php-fpm /usr/sbin/php-fpm*; do
                 if command -v "$b" >/dev/null 2>&1; then "$b" -v 2>/dev/null | head -1; break; fi
               done'
  local bp_probe='zgrep -qE "CVE-2026-10961[89]" /usr/share/doc/php*/changelog.Debian.gz 2>/dev/null ||
                  { command -v rpm >/dev/null 2>&1 && rpm -qa --changelog "php*" 2>/dev/null | grep -qE "CVE-2026-10961[89]"; }'
  while read -r id name image; do
    [ -z "$id" ] && continue
    v=$(timeout 15 docker exec "$id" sh -c "$probe" 2>/dev/null | grep -oE 'PHP [0-9]+\.[0-9]+\.[0-9]+' | awk '{print $2}')
    [ -z "$v" ] && continue
    ncont=$((ncont + 1))
    br="${v%.*}"
    if [ -n "${FIXED[$br]:-}" ] && ver_ge "$v" "${FIXED[$br]}"; then
      ok "$name ($image) — PHP $v"
    elif timeout 15 docker exec "$id" sh -c "$bp_probe" >/dev/null 2>&1; then
      ok "$name ($image) — PHP $v, исправление перенесено в пакет"
    else
      bad "$name ($image) — PHP $v"
      DOCKER_VULN=$((DOCKER_VULN + 1))
      DOCKER_LIST+=("$id")
    fi
  done < <(docker ps --format '{{.ID}} {{.Names}} {{.Image}}' 2>/dev/null)
  [ "$ncont" -eq 0 ] && info "Работающих контейнеров с PHP нет."
  [ "$DOCKER_VULN" -gt 0 ] && docker_advice
}

docker_advice() {
  local id name dir svc
  sub "Как обновить контейнеры (скрипт их сам не пересоздаёт):"
  for id in "${DOCKER_LIST[@]}"; do
    name=$(docker inspect -f '{{.Name}}' "$id" 2>/dev/null | sed 's|^/||')
    dir=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)
    svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$id" 2>/dev/null)
    if [ -n "$dir" ] && [[ "$dir" == *mailcow* ]]; then
      info "$name: cd $dir && ./update.sh"
    elif [ -n "$dir" ] && [ -n "$svc" ]; then
      info "$name: cd $dir && docker compose pull $svc && docker compose build --pull $svc; docker compose up -d $svc"
    else
      info "$name: docker pull <образ> (или docker build --pull), затем пересоздать контейнер"
    fi
  done
  info "Проверка после обновления: docker exec <контейнер> php -v"
}

# --- обновление

php_packages() {
  case "$PM" in
    apt) dpkg-query -W -f='${binary:Package} ${db:Status-Abbrev}\n' 2>/dev/null \
           | awk '$2 ~ /^ii/ {print $1}' | sed -E 's/:[a-z0-9_]+$//' \
           | grep -E '^(php|libapache2-mod-php|plesk-php|lsphp)' | sort -u ;;
    dnf|yum) rpm -qa --qf '%{NAME}\n' 2>/dev/null \
           | grep -E '^(php|ea-php|alt-php|plesk-php|lsphp)' | sort -u ;;
    pacman) pacman -Qq 2>/dev/null | grep -E '^php' ;;
    apk) apk info 2>/dev/null | grep -E '^php' ;;
  esac
}

fix_host() {
  local pkgs rc=0
  hdr "Обновление пакетов PHP"
  mapfile -t pkgs < <(php_packages)
  if [ "${#pkgs[@]}" -eq 0 ]; then
    info "Пакетов PHP нет: через менеджер пакетов обновлять нечего."
    return 0
  fi
  info "Пакетов PHP: ${#pkgs[@]} (обновляются только они, новые не ставятся)"
  [ -x /usr/local/cpanel/scripts/upcp ] && info "cPanel: пакеты ea-php обновляются здесь же; при желании также /scripts/upcp"
  [ -d /opt/plesk ] && info "Plesk: пакеты plesk-php обновляются здесь же; также можно через панель"
  if [ "$ASSUME_YES" -eq 0 ]; then
    warn "Перед обновлением сделайте снимок или резервную копию."
    confirm "Обновить пакеты PHP сейчас?" || { warn "Обновление пропущено."; return 0; }
  fi
  case "$PM" in
    apt)
      apt-get update -q || warn "apt-get update завершился с ошибкой, продолжаю"
      sub "Доступные обновления PHP:"
      apt list --upgradable 2>/dev/null | grep -E '^(php|libapache2-mod-php|plesk-php|lsphp)' | sed 's/^/    /' \
        || info "    нет: либо всё уже обновлено, либо исправленные пакеты ещё не вышли (смотрите проверку ниже)"
      DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade \
        -o Dpkg::Options::=--force-confold "${pkgs[@]}" || rc=$? ;;
    dnf) dnf -y upgrade --refresh "${pkgs[@]}" || rc=$? ;;
    yum) yum -y update "${pkgs[@]}" || rc=$? ;;
    pacman)
      warn "Arch не поддерживает частичное обновление, выполняю полное pacman -Syu"
      if [ "$ASSUME_YES" -eq 1 ]; then pacman -Syu --noconfirm || rc=$?; else pacman -Syu || rc=$?; fi ;;
    apk) { apk update && apk upgrade "${pkgs[@]}"; } || rc=$? ;;
    *) warn "Менеджер пакетов не распознан, обновите PHP вручную."; return 1 ;;
  esac
  [ "$rc" -ne 0 ] && warn "Менеджер пакетов вернул код $rc, смотрите вывод выше."
  return 0
}

fpm_binaries() { find_php_binaries | grep -E '/php-fpm[^/]*$'; }

test_fpm_configs() {
  local b out bad_cfg=0
  while read -r b; do
    [ -z "$b" ] && continue
    if out=$("$b" -t 2>&1); then
      ok "$b -t: конфигурация в порядке"
    else
      bad_cfg=1
      warn "$b -t: ошибка конфигурации"
      printf '%s\n' "$out" | tail -3 | sed 's/^/      /'
    fi
  done < <(fpm_binaries)
  return "$bad_cfg"
}

restart_php() {
  local u any=0 apache_unit="" s
  hdr "Перезапуск обработчиков PHP"
  if have systemctl && [ -d /run/systemd/system ]; then
    while read -r u; do
      [ -z "$u" ] && continue
      any=1
      if systemctl restart "$u"; then ok "перезапущена служба $u"; else bad "не удалось перезапустить $u"; fi
    done < <(systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null \
               | awk '{print $1}' | grep -E 'php.*fpm|fpm.*php|frankenphp')
    for s in apache2 httpd; do
      systemctl is-active --quiet "$s" 2>/dev/null && apache_unit="$s"
    done
    if [ -n "$apache_unit" ] && grep -lq 'libphp' /proc/[0-9]*/maps 2>/dev/null; then
      any=1
      # для mod_php нужен полный перезапуск: reload и graceful оставляют старую библиотеку
      if systemctl restart "$apache_unit"; then ok "полностью перезапущен $apache_unit (mod_php)"; else bad "не удалось перезапустить $apache_unit"; fi
    fi
  elif have rc-service; then
    for s in /etc/init.d/php-fpm* /etc/init.d/php*-fpm; do
      [ -x "$s" ] || continue
      any=1
      rc-service "${s##*/}" restart && ok "перезапущена служба ${s##*/}"
    done
  else
    # без менеджера служб: USR2 заставляет мастер PHP-FPM перезапустить себя и все обработчики
    for s in $(pgrep -f '^php-fpm: master process' 2>/dev/null); do
      any=1
      kill -USR2 "$s" 2>/dev/null && ok "мастер PHP-FPM (PID $s) перезапущен сигналом USR2"
    done
    warn "Нет systemd и OpenRC: веб-сервер с mod_php перезапустите вручную."
  fi
  if pgrep -f '(^|/)lsphp' >/dev/null 2>&1; then
    any=1
    pkill -f '(^|/)lsphp' && ok "процессы lsphp остановлены, LiteSpeed запустит их заново"
  fi
  if have frankenphp; then
    warn "FrankenPHP: PHP встроен в бинарник. Нужен новый выпуск FrankenPHP: $(frankenphp version 2>/dev/null | head -1)"
  fi
  [ "$any" -eq 0 ] && info "Работающих служб PHP не найдено."
  info "Не забудьте очереди, Supervisor и Laravel Octane: их тоже нужно перезапустить."
}

# --- временные меры

ini_scan_dirs() {
  local bins b d
  mapfile -t bins < <(find_php_binaries)
  {
    for b in "${bins[@]}"; do
      case "${b##*/}" in frankenphp) continue ;; esac
      d=$("$b" -i 2>/dev/null | sed -n 's/^Scan this dir for additional .ini files => //p' | head -1)
      [ -n "$d" ] && [ "$d" != "(none)" ] && printf '%s\n' "$d"
    done
    for d in /etc/php/*/apache2/conf.d /etc/php/*/cgi/conf.d /etc/php/*/fpm/conf.d; do
      [ -d "$d" ] && printf '%s\n' "$d"
    done
  } | tr ':' '\n' | sed '/^$/d' | sort -u
}

mitigate() {
  local dirs d f b val
  hdr "Временные меры (не заменяют обновление)"
  mapfile -t dirs < <(ini_scan_dirs)
  if [ "${#dirs[@]}" -eq 0 ]; then
    warn "Каталоги дополнительных ini не найдены. Пропишите session.upload_progress.enabled = 0 в php.ini вручную."
    return 1
  fi
  for d in "${dirs[@]}"; do
    [ -d "$d" ] || continue
    f="$d/$MITIGATION_FILE"
    cat > "$f" <<'EOF'
; php2shell-fix.sh: временная мера против CVE-2026-109618 / CVE-2026-109619.
; Не заменяет обновление PHP. Снять: php2shell-fix.sh --unmitigate
session.upload_progress.enabled = 0
; PHP 8.5: закрывает подтверждённый путь через трассировку фатальных ошибок.
; Более старые версии PHP неизвестную директиву просто игнорируют.
fatal_error_backtraces = 0
EOF
    ok "записан $f"
  done
  sub "Проверка конфигурации PHP-FPM перед перезапуском"
  if ! test_fpm_configs; then
    warn "Ошибки конфигурации FPM: перезапуск отменён, исправьте и перезапустите вручную."
    return 1
  fi
  restart_php
  sub "Проверка, что настройка подхватилась"
  while read -r b; do
    [ -z "$b" ] && continue
    val=$("$b" -i 2>/dev/null | sed -n 's/^session.upload_progress.enabled => \([^ ]*\).*/\1/p' | head -1)
    if [ "$val" = "Off" ] || [ "$val" = "0" ]; then ok "$b: session.upload_progress.enabled = Off"
    else warn "$b: session.upload_progress.enabled = ${val:-не определено}"; fi
  done < <(fpm_binaries)
  sub "Что ещё можно сделать вручную"
  info "1. В пулах FPM: php_admin_flag[session.upload_progress.enabled] = off."
  info "2. Сайтам без загрузки файлов: post_max_size = 8M, с загрузками: 32M."
  info "3. Сайтам без загрузок закрыть multipart на веб-сервере (ответ 403)."
  info "4. За Cloudflare: пускать на сервер только адреса Cloudflare."
}

unmitigate() {
  local d n=0
  hdr "Снятие временных мер"
  while read -r d; do
    if [ -f "$d/$MITIGATION_FILE" ]; then
      rm -f "$d/$MITIGATION_FILE" && ok "удалён $d/$MITIGATION_FILE" && n=$((n + 1))
    fi
  done < <(ini_scan_dirs)
  if [ "$n" -eq 0 ]; then info "Файлов временных мер не найдено."; return 0; fi
  test_fpm_configs && restart_php
  info "session.upload_progress.enabled = 0 можно оставить навсегда."
}

# --- признаки взлома

audit() {
  local roots=() d u pid out cnt f
  local -a webusers suspects
  hdr "Поиск признаков взлома за последние $AUDIT_DAYS сут."
  info "Находка — повод присмотреться, а не доказательство взлома."

  for d in /var/www /srv/www /srv/http /usr/share/nginx/html /home/*/htdocs /home/*/public_html \
           /home/*/www /var/www/vhosts ${EXTRA_WEBROOTS[@]+"${EXTRA_WEBROOTS[@]}"}; do
    [ -n "$d" ] && [ -d "$d" ] && roots+=("$d")
  done

  sub "1. Изменённые PHP-файлы и .htaccess/.user.ini в каталогах сайтов"
  if [ "${#roots[@]}" -eq 0 ]; then
    info "Каталоги сайтов не найдены. Укажите свой: --webroot /путь"
  else
    info "Каталоги: ${roots[*]}"
    out=$(find "${roots[@]}" -xdev -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \
            -o -name '.user.ini' -o -name '.htaccess' \) -mtime "-$AUDIT_DAYS" \
            -not -path '*/cache/*' -not -path '*/node_modules/*' \
            -printf '%TY-%Tm-%Td %TH:%TM  %-12u %p\n' 2>/dev/null | sort -r)
    cnt=$(printf '%s' "$out" | grep -c . || true)
    if [ "$cnt" -gt 0 ]; then
      hit "$cnt файлов (первые 50; сверьте со своими обновлениями):"
      printf '%s\n' "$out" | head -50 | sed 's/^/      /'
    else ok "не найдено"; fi

    sub "2. PHP-файлы в каталогах загрузок"
    out=$(find "${roots[@]}" -xdev -type f \( -path '*/uploads/*' -o -path '*/upload/*' -o -path '*/files/*' \) \
            \( -name '*.php*' -o -name '*.phtml' -o -name '*.phar' \) 2>/dev/null)
    cnt=$(printf '%s' "$out" | grep -c . || true)
    if [ "$cnt" -gt 0 ]; then
      hit "$cnt файлов (заглушки index.php — норма):"
      printf '%s\n' "$out" | head -30 | sed 's/^/      /'
    else ok "не найдено"; fi

    sub "3. Подозрительный код в недавно изменённых файлах"
    local susp_re='eval[[:space:]]*\([[:space:]]*(base64_decode|gzinflate|gzuncompress|str_rot13)|assert[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE)|(system|shell_exec|passthru|popen|proc_open)[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST|COOKIE|SERVER)|\$_(GET|POST|REQUEST|COOKIE)\[[^]]*\][[:space:]]*\([[:space:]]*\$_'
    out=$(find "${roots[@]}" -xdev -type f \( -name '*.php*' -o -name '*.phtml' \) -mtime "-$AUDIT_DAYS" -print0 2>/dev/null \
            | xargs -0 -r grep -lE "$susp_re" 2>/dev/null)
    cnt=$(printf '%s' "$out" | grep -c . || true)
    if [ "$cnt" -gt 0 ]; then
      hit "$cnt файлов с признаками веб-оболочки:"
      printf '%s\n' "$out" | head -30 | sed 's/^/      /'
    else ok "не найдено"; fi
  fi

  mapfile -t webusers < <(
    { ps -eo user:32=,comm= 2>/dev/null | awk '$2 ~ /^(php|lsphp|httpd|apache2|nginx|frankenphp)/ {print $1}'
      printf '%s\n' www-data nginx apache http nobody lsadm; } \
    | grep -vx root | sort -u | while read -r u; do id -u "$u" >/dev/null 2>&1 && echo "$u"; done)

  sub "4. Посторонние процессы пользователей веб-сервера (${webusers[*]:-нет})"
  mapfile -t suspects < <(ps -eo user:32=,pid=,etime=,comm=,args= 2>/dev/null \
    | awk -v users=" ${webusers[*]:-} " 'index(users, " " $1 " ") > 0 && $4 !~ /^(php|lsphp|nginx|httpd|apache2|frankenphp|sshd|sftp-server)/' \
    | awk '{$1 = $1; print}')
  if [ "${#suspects[@]}" -gt 0 ]; then
    hit "${#suspects[@]} процессов (оболочек, curl, wget, майнеров здесь быть не должно):"
    printf '%s\n' "${suspects[@]}" | head -40 | cut -c1-160 | sed 's/^/      /'
    sub "   Их сетевые соединения"
    out=""
    for f in "${suspects[@]}"; do
      pid=$(awk '{print $2}' <<<"$f")
      out+=$(ss -tunpH 2>/dev/null | grep "pid=$pid,")
    done
    if [ -n "$out" ]; then printf '%s\n' "$out" | sed 's/^/      /'; else info "    соединений нет"; fi
  else ok "не найдено"; fi

  sub "5. Задания cron пользователей веб-сервера и недавно изменённые файлы cron"
  cnt=0
  for u in "${webusers[@]}"; do
    out=$(crontab -l -u "$u" 2>/dev/null | grep -vE '^[[:space:]]*(#|$)')
    if [ -n "$out" ]; then cnt=1; hit "crontab пользователя $u:"; printf '%s\n' "$out" | sed 's/^/      /'; fi
  done
  out=$(find /etc/cron* /var/spool/cron -type f -mtime "-$AUDIT_DAYS" 2>/dev/null)
  if [ -n "$out" ]; then cnt=1; hit "изменены за $AUDIT_DAYS сут.:"; printf '%s\n' "$out" | sed 's/^/      /'; fi
  [ "$cnt" -eq 0 ] && ok "не найдено"

  sub "6. Новые службы systemd, ключи SSH, учётные записи"
  cnt=0
  out=$(find /etc/systemd/system /usr/local/lib/systemd /home/*/.config/systemd /root/.config/systemd \
          -type f -mtime "-$AUDIT_DAYS" 2>/dev/null)
  if [ -n "$out" ]; then cnt=1; hit "файлы systemd изменены:"; printf '%s\n' "$out" | sed 's/^/      /'; fi
  out=$(find /root/.ssh /home/*/.ssh /var/www -maxdepth 3 -name 'authorized_keys*' -mtime "-$AUDIT_DAYS" 2>/dev/null)
  if [ -n "$out" ]; then cnt=1; hit "изменены authorized_keys:"; printf '%s\n' "$out" | sed 's/^/      /'; fi
  out=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd)
  if [ -n "$out" ]; then cnt=1; hit "посторонние учётные записи с UID 0: $out"; fi
  if [ -n "$(find /etc/passwd /etc/shadow /etc/sudoers /etc/sudoers.d -mtime "-$AUDIT_DAYS" 2>/dev/null)" ]; then
    cnt=1; hit "за $AUDIT_DAYS сут. менялись /etc/passwd, /etc/shadow или sudoers"
  fi
  [ "$cnt" -eq 0 ] && ok "не найдено"

  sub "7. Исполняемые файлы во временных каталогах"
  out=$(find /tmp /var/tmp /dev/shm -xdev -type f -perm -u+x -mtime "-$AUDIT_DAYS" 2>/dev/null | head -30)
  if [ -n "$out" ]; then hit "найдены:"; printf '%s\n' "$out" | sed 's/^/      /'; else ok "не найдено"; fi

  sub "8. Падения обработчиков PHP (неудачные попытки атаки дают SIGSEGV)"
  cnt=0
  if have journalctl; then
    cnt=$(journalctl --since "$AUDIT_DAYS days ago" --no-pager -q 2>/dev/null \
            | grep -ciE '(php|fpm|lsphp).*(signal (11|6|7)|SIGSEGV|SIGBUS|SIGABRT)|segfault.*php' || true)
  fi
  for f in /var/log/php*fpm*.log /var/log/php-fpm/*.log /var/log/php*/*.log /opt/cpanel/ea-php*/root/usr/var/log/php-fpm/error.log; do
    [ -f "$f" ] || continue
    cnt=$((cnt + $(grep -ciE 'exited on signal (11|6|7)|SIGSEGV|SIGBUS' "$f" 2>/dev/null || true)))
  done
  if [ "$cnt" -gt 0 ]; then hit "$cnt записей о падениях обработчиков PHP"; else ok "не найдено"; fi

  sub "9. Ответы 502 в журналах веб-сервера (текущие файлы)"
  cnt=0
  for f in /var/log/nginx/*access*.log /var/log/apache2/*access*.log /var/log/httpd/*access_log \
           /home/*/logs/nginx/access.log /home/*/logs/*access*.log /var/www/vhosts/*/logs/*access*log \
           /usr/local/lsws/logs/access.log /var/log/caddy/*.log; do
    [ -f "$f" ] || continue
    local total post
    total=$(grep -cE '" 502 |"status":502' "$f" 2>/dev/null || true)
    [ "${total:-0}" -eq 0 ] && continue
    post=$(grep -cE '"POST [^"]*" 502 ' "$f" 2>/dev/null || true)
    cnt=1
    hit "$f: 502 — $total, из них на POST — $post"
  done
  [ "$cnt" -eq 0 ] && ok "не найдено"
  info "Всплеск 502 на POST — типичный след неудачной попытки PHP2Shell."
}

# --- итог

summary() {
  hdr "Итог"
  info "PHP на машине: $HOST_FOUND, уязвимых: $HOST_VULN"
  info "Процессов со старым кодом в памяти: $STALE"
  [ "$CHECK_DOCKER" -eq 1 ] && have docker && info "Уязвимых контейнеров: $DOCKER_VULN"
  [ "$MITIGATED" -gt 0 ] && info "Временные меры включены: да ($MITIGATED)"
  [ "$DO_AUDIT" -eq 1 ] && info "Находок проверки на взлом: $AUDIT_HITS"
  [ -n "$LOG" ] && info "Журнал: $LOG"

  if [ "$HOST_VULN" -gt 0 ] || [ "$DOCKER_VULN" -gt 0 ] || [ "$STALE" -gt 0 ]; then
    printf '\n'
    [ "$HOST_VULN" -gt 0 ] && [ "$DO_FIX" -eq 0 ] && warn "Следующий шаг: bash $0 --fix"
    if [ "$HOST_VULN" -gt 0 ] && [ "$DO_FIX" -eq 1 ] && [ "$MITIGATED" -eq 0 ]; then
      warn "Исправленных пакетов пока нет: bash $0 --mitigate, затем повторяйте --fix."
    fi
    [ "$STALE" -gt 0 ] && warn "Перезапустите процессы со старым кодом (bash $0 --fix выполнит перезапуск)."
    [ "$DO_AUDIT" -eq 0 ] && warn "Проверьте сервер на взлом: bash $0 --audit"
    return 1
  fi
  printf '\n'
  ok "Уязвимых PHP не найдено."
  [ "$MITIGATED" -gt 0 ] && info "Временные меры можно снять: bash $0 --unmitigate"
  [ "$DO_AUDIT" -eq 0 ] && info "Если сервер был уязвим после 20.10.2026, всё равно выполните: bash $0 --audit"
  return 0
}

# --- запуск

printf '%sphp2shell-fix.sh %s%s — CVE-2026-109618 / CVE-2026-109619\n' "$B" "$SCRIPT_VERSION" "$N"
printf 'Исправленные версии: 8.5.12, 8.4.27, 8.3.36, 8.2.35. Узел: %s\n' "$(hostname 2>/dev/null)"

[ "$DO_UNMITIGATE" -eq 1 ] && unmitigate
if [ "$DO_FIX" -eq 1 ]; then
  check_host "PHP до обновления"
  fix_host
  restart_php
fi
if [ "$DO_MITIGATE" -eq 1 ]; then
  mitigate
fi
if [ "$DO_FIX" -eq 1 ] || [ "$DO_MITIGATE" -eq 1 ] || [ "$DO_UNMITIGATE" -eq 1 ]; then
  check_host "PHP после изменений"
else
  check_host
fi
check_stale
check_mitigation_status
check_docker
[ "$DO_AUDIT" -eq 1 ] && audit
summary
status=$?
sleep 0.2
exit "$status"

Как понять, что сервер уже взломали

Проверять нужно каждый сервер, где PHP оставался уязвимым после 20 октября: подробности атаки к тому моменту уже распространялись в сети. Обновление закрывает дыру, но не удаляет то, что успели оставить.

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

Признак

Где искать

Почему важно

Новые или изменённые PHP-файлы

каталоги сайтов, особенно uploads

веб-оболочка — первое, что оставляют после удачной атаки

eval(base64_decode(...)), system($_GET[...])

недавно изменённые PHP-файлы

типичный код веб-оболочки

Посторонние процессы от www-data, nginx или пользователя сайта

ps -eo user,pid,args

код выполняется от имени пользователя PHP: sh, curl, wget, майнеры

Новые задания cron и службы systemd

crontab -l -u www-data, /etc/cron.d, /etc/systemd/system

так закрепляются на сервере

Изменённые authorized_keys, новые учётные записи с UID 0

/root/.ssh, /home/<user>/.ssh, /etc/passwd

признак повышения прав

Исполняемые файлы во временных каталогах

/tmp, /var/tmp, /dev/shm

сюда обычно кладут загрузчики

Падения обработчиков PHP-FPM (SIGSEGV)

журнал FPM, journalctl

неудачная попытка эксплуатации

Всплеск ответов 502 на POST

журнал доступа nginx

то же самое, но со стороны веб-сервера

Ключ --audit проверяет все эти признаки автоматически. Найденный след ещё не означает взлом: файлы меняются при обычных обновлениях сайтов, а 502 возникают и по другим причинам.

Контрольный список

  1. Составить перечень всех машин и контейнеров с PHP, начиная с открытых в интернет и работающих через mod_php.
  2. Сделать снимок или резервную копию.
  3. Выполнить php2shell-fix.sh --fix на каждой машине.
  4. Убедиться, что процессов со старым кодом не осталось.
  5. Пересобрать или обновить контейнеры с PHP, FrankenPHP и LiteSpeed.
  6. Если пакетов ещё нет — включить --mitigate и повторять --fix.
  7. Если сайты закрыты Cloudflare — пускать на сервер только адреса Cloudflare.
  8. Выполнить php2shell-fix.sh --audit и разобрать каждую находку.
  9. После обновления снять временные меры: --unmitigate и блокировку multipart на веб-сервере.
  10. Перевести проекты с PHP 8.1 и старше на 8.3+, а с 8.2 — до 31.12.2026.
  11. Включить автоматические обновления безопасности: unattended-upgrades или dnf-automatic.

Частые вопросы

Почему после обновления php -v показывает старую версию?

Ubuntu, RHEL и их производные переносят исправление в свою сборку, не меняя основного номера версии. Например, Ubuntu 24.04 остаётся на 8.3.6 даже после установки USN-8948-1.

Проверять нужно версию пакета и журнал изменений: наличие строки CVE-2026-109618 или CVE-2026-109619 подтверждает, что патч установлен.

Защищает ли Cloudflare от PHP2Shell без обновления PHP?

Только частично. Правила против известных векторов атаки включены на всех тарифах, но работают, лишь когда сервер принимает трафик исключительно с адресов Cloudflare.

Если сервер доступен напрямую по IP, защиты нет. Кроме того, новые варианты эксплойта могут обходить правила, поэтому обновление обязательно в любом случае.

Достаточно ли отключить file_uploads?

Нет. При file_uploads = Off PHP всё равно разбирает тело запроса, и уязвимый код выполняется. Защитные плагины CMS и проверка CSRF тоже не помогают: они срабатывают уже после разбора запроса.

Что делать с PHP 7.4 и 8.1?

Официальных исправлений для этих веток нет. Варианты: пакет от дистрибутива с перенесённым патчем (например, через Ubuntu Pro), исправление от LTS-поставщика, сборка из исходников с переносом исправлений или переход на 8.3+.

Ветки 7.3 и старше исправлений не получат: такие системы рекомендуется изолировать от интернета и переводить на поддерживаемую версию.

Нужно ли обновлять PHP, который используется только в консоли?

Консольный PHP (cron, composer, artisan) напрямую не атакуется, так как сетевые запросы до него не доходят. Но если на той же машине работает PHP-FPM той же ветки, обновлять нужно все пакеты PHP сразу.

Заключение

PHP2Shell — редкий случай, когда уязвимость в самом интерпретаторе даёт удалённое выполнение кода на стандартной конфигурации без уязвимого приложения, а рабочий эксплойт появился раньше исправлений. Единственная полноценная защита — обновление до 8.5.12, 8.4.27, 8.3.36, 8.2.35 или пакета дистрибутива с перенесённым патчем, перезапуск всех обработчиков и обязательная проверка на признаки взлома; временные меры и скрипт php2shell-fix.sh позволяют закрыть инфраструктуру быстро и без ручного обхода каждого сервера.

Оцените запись