Уязвимость 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 на машине, включая контейнеры, обновляет их и ищет признаки взлома.
Что сделать прямо сейчас
- Сделать снимок или резервную копию сервера.
- Запустить скрипт
php2shell-fix.shс ключом--fix: он обновит все PHP, перезапустит обработчики и проверит результат. - Если исправленных пакетов ещё нет, включить временные меры ключом
--mitigate. - Проверить сервер на признаки взлома ключом
--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, сценарий такой:
- Нападающий отправляет быстрый multipart-запрос с полем
PHP_SESSION_UPLOAD_PROGRESSперед файлом. PHP запоминает указатели на счётчики загрузки. - Второй запрос заставляет PHP перечитать сессию, и память под этими указателями освобождается.
- На освободившееся место кладётся подделанная структура вызова функции, и следующее обращение по старому указателю вызывает, например,
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 | в журнале изменений пакета есть |
Ubuntu 18.04 и 20.04 | только через Ubuntu Pro (ESM) |
|
Debian 13 trixie | DSA-6574-1, пакет | версия пакета не ниже указанной |
Debian 12 bookworm | пакет | версия пакета не ниже указанной |
RHEL, AlmaLinux, Rocky | RHSA-2026:87072; на прошлом плановом выпуске Red Hat отставал от PHP примерно на 4 недели |
|
| срок выхода не объявлен | основной номер версии не ниже исправленного |
Remi (rpms.remirepo.net) | в день раскрытия исправлений нет; PHP Foundation советует перейти на пакеты дистрибутива или собрать из исходников | основной номер версии |
Arch Linux |
|
|
cPanel/WHM, EasyApache 4 | по сообщениям хостеров, обновления уже есть: | WHM → MultiPHP Manager |
Plesk Obsidian | по сообщениям хостеров, обновления уже есть | обновить через панель |
CloudLinux alt-php | следить за статусом CloudLinux; для старых веток есть расширенная поддержка | — |
Docker-образы | скачать новый образ, пересобрать свои через |
|
FrankenPHP | PHP встроен в бинарник, apt и dnf его не обновят; нужен новый выпуск или образ |
|
LiteSpeed (lsphp) | обновить пакеты | — |
Как перезапустить PHP после обновления
Способ перезапуска зависит от режима работы PHP.
Режим | Что сделать после обновления |
|---|---|
PHP-FPM за nginx, Caddy или Apache |
|
Apache с mod_php | полный |
LiteSpeed, OpenLiteSpeed |
|
Очереди, Supervisor, Laravel Octane | перезапустить; |
Docker | пересоздать контейнеры из нового образа |
Как защититься, если обновить PHP сейчас нельзя
Временные меры сужают поверхность атаки, но сервер безопасным не делают. PHP прямо указывает, что полной защиты, кроме обновления, нет, а watchTowr считает правки ini ненадёжными. Эти меры рассчитаны на часы, пока не выйдут пакеты.
Отключить отслеживание хода загрузки
Мера закрывает подтверждённый путь CVE-2026-109619. Настройка задаётся в php.ini или в пуле FPM, после чего перезапускаются все обработчики. ini_set() и .user.ini для этого не подходят.
Отдельный ini-файл для каждой установленной версии PHP:
session.upload_progress.enabled = 0Для пула FPM надёжнее вариант, который приложение не сможет переопределить:
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 {}:
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:
#!/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-файлы | каталоги сайтов, особенно | веб-оболочка — первое, что оставляют после удачной атаки |
| недавно изменённые PHP-файлы | типичный код веб-оболочки |
Посторонние процессы от |
| код выполняется от имени пользователя PHP: sh, curl, wget, майнеры |
Новые задания cron и службы systemd |
| так закрепляются на сервере |
Изменённые |
| признак повышения прав |
Исполняемые файлы во временных каталогах |
| сюда обычно кладут загрузчики |
Падения обработчиков PHP-FPM (SIGSEGV) | журнал FPM, | неудачная попытка эксплуатации |
Всплеск ответов 502 на POST | журнал доступа nginx | то же самое, но со стороны веб-сервера |
Ключ --audit проверяет все эти признаки автоматически. Найденный след ещё не означает взлом: файлы меняются при обычных обновлениях сайтов, а 502 возникают и по другим причинам.
Контрольный список
- Составить перечень всех машин и контейнеров с PHP, начиная с открытых в интернет и работающих через mod_php.
- Сделать снимок или резервную копию.
- Выполнить
php2shell-fix.sh --fixна каждой машине. - Убедиться, что процессов со старым кодом не осталось.
- Пересобрать или обновить контейнеры с PHP, FrankenPHP и LiteSpeed.
- Если пакетов ещё нет — включить
--mitigateи повторять--fix. - Если сайты закрыты Cloudflare — пускать на сервер только адреса Cloudflare.
- Выполнить
php2shell-fix.sh --auditи разобрать каждую находку. - После обновления снять временные меры:
--unmitigateи блокировку multipart на веб-сервере. - Перевести проекты с PHP 8.1 и старше на 8.3+, а с 8.2 — до 31.12.2026.
- Включить автоматические обновления безопасности:
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 позволяют закрыть инфраструктуру быстро и без ручного обхода каждого сервера.