PHP2Shell на ispmanager и CloudLinux: что пошло не по инструкции

21 октября 2026 года PHP выпустил 8.5.12, 8.4.27, 8.3.36 и 8.2.35 с исправлением двух критических уязвимостей, CVE-2026-109618 и CVE-2026-109619 (CVSS v4.0 9.2). Обе позволяют выполнить код на сервере без авторизации, и уязвим не какой-то плагин, а сам интерпретатор: любой сайт на PHP, от WordPress до самописного кода.

На обычном сервере история короткая: обновить пакет и перезапустить PHP. На хостинге с ispmanager и CloudLinux всё оказалось интереснее. За ночь и утро мы закрыли уязвимости на всех серверах и по пути наступили почти на все грабли, которые не описаны ни в бюллетене PHP, ни в документации панели.

Это не пересказ бюллетеня, а список того, что пошло не по инструкции, и как мы это решили. Если вы администрируете сервер с ispmanager, особенно на CloudLinux, проверьте у себя каждый пункт: часть проблем не видна, пока не посмотришь в нужное место.

Временные меры до выхода патчей

Пока пакетов нет, полностью закрыть уязвимости нельзя, можно только отрезать известные пути атаки. Публичные эксплоиты, появившиеся ещё до релиза, использовали обработчик прогресса загрузки файлов, который срабатывает на любой запрос multipart/form-data, даже если на сайте нет ни одной формы загрузки.

Мы сделали две вещи:

  1. Отключили session.upload_progress.enabled во всех версиях PHP. Это рекомендация самого PHP, функцию почти никто не использует, сайты от её отключения не страдают. Важно: значение должно задаваться в php.ini или в конфигурации пула, а не через ini_set() или .user.ini, иначе оно применится слишком поздно.
  2. Заблокировали multipart на уровне nginx для всех сайтов. В ispmanager каждый vhost подключает /etc/nginx/vhosts-includes/*.conf, поэтому одного файла хватает на весь сервер:
if ($http_content_type ~* "multipart") {
    return 403;
}

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

Два места, которые легко пропустить:

  • Дефолтный server в /etc/nginx/nginx.conf. Он ловит запросы по IP и с неизвестным Host и в ispmanager проксирует их в Apache, но vhosts-includes не подключает. Без отдельного include туда атака по IP проходит мимо блока.
  • Apache на внешнем адресе. Если Apache слушает не 127.0.0.1, а внешний IP, запрос можно отправить прямо ему, минуя nginx. Проверка: ss -ltnp | grep httpd.

Ловушка CageFS: настройка из панели не доходит до сайтов

Днём мы отключили session.upload_progress.enabled штатно: «Настройки web-сервера → PHP → версия → настройки». Панель показала, что значение сохранено. Вечером проверка изнутри клетки CageFS показала, что у сайтов оно по-прежнему включено.

Причина в том, как устроен alt-php на CloudLinux:

  • ispmanager записал значение в /opt/alt/phpXX/etc/php.d.all/session.ini, то есть в ini расширения session;
  • расширение session в alt-php встроено и через PHP Selector не подключается, поэтому этот ini внутри клетки никто не читает;
  • внутри клетки PHP читает основной /opt/alt/phpXX/etc/php.ini и пользовательский alt_php.ini из /opt/alt/phpXX/link/conf, а это совсем другой каталог, чем видит root.

Отсюда главный урок: проверять PHP нужно от имени пользователя внутри клетки, а не от root:

su -s /bin/bash -c "/opt/alt/php83/usr/bin/php -r 'var_dump(ini_get(\"session.upload_progress.enabled\"));'" ЛОГИН_КЛИЕНТА

Что сработало:

  1. Дописать директиву в конец php.ini каждой версии, который действительно загружается (путь даёт php --ini, строка Loaded Configuration File).
  2. Выполнить cagefsctl --rebuild-alt-php-ini, чтобы пересобрать пользовательские alt_php.ini.
  3. Выполнить cagefsctl --update: у нативного PHP внутри клетки своя копия /etc/php.ini в скелете CageFS, и без обновления скелета правка до сайтов не доходит.
  4. Не забыть персональные php.ini сайтов в режиме CGI: ispmanager держит их в /var/www/ПОЛЬЗОВАТЕЛЬ/data/php-bin*/php.ini.
  5. Перезапустить httpd и FPM.

И ещё одна деталь: в ту же ночь в 03:37 что-то пересобрало php.ini всех alt-php из шаблона. Если правка живёт только в php.ini, её стоит подстраховать задачей cron, которая проверяет и восстанавливает строку, пока патчи не вышли. Мы так и сделали, и cron несколько раз действительно её возвращал, например после обновления пакетов alt-php.

Блок multipart ломает вход в панель

Через пару часов после блокировки клиент написал, что не может войти в панель: запрос входа получает 403, а интерфейс бесконечно показывает индикатор загрузки. IP клиента никто не блокировал.

Панель ispmanager работает на своём веб-сервере ihttpd на порту 1500, и при входе напрямую на этот порт nginx действительно ни при чём. Но если включён модуль nginx_proxy, панель доступна и через 443 по адресу https://ХОСТ/ispmgr, а форма входа отправляется как multipart/form-data. Наш блок отвечал 403 ещё в nginx, а фронтенд панели такую ошибку показать не умеет.

Модуль подключается только в vhost хоста панели (/etc/nginx/modules-includes/nginx_proxy/), и все его пути уходят в ihttpd, а не в PHP. Поэтому исключение безопасно делать по связке хоста и пути:

set $cve_mp 0;
if ($http_content_type ~* "multipart") { set $cve_mp 1; }
set $cve_hu "$host:$uri";
if ($cve_hu ~* "^panel\.example\.com:/(ispmgr|mancgi|manimg|long|shell|notify)(/|$)") { set $cve_mp 0; }
if ($cve_mp = 1) { return 403; }

Исключение только по пути без хоста было бы ошибкой: запрос на клиентский-сайт/ispmgr ушёл бы в Apache, а там WordPress перепишет его на index.php, и PHP разберёт multipart. На хосте панели этот путь гарантированно уходит в ihttpd.

Список путей лучше брать из самого модуля (grep location /etc/nginx/modules-includes/nginx_proxy/*.conf), а не по памяти. И проверять сразу после перезагрузки nginx не стоит: первые запросы может ещё обслужить старый воркер со старой конфигурацией.

Забытые Roundcube и phpMyAdmin

Когда вышли патчи alt-php, мы обновили пакеты и сняли блок. И только потом вспомнили, что веб-почта и phpMyAdmin в ispmanager работают не на той версии PHP, которую вы выбрали для сайтов. Обновление alt-php их не касается.

Оба приложения подключаются через vhosts-includes, то есть доступны на каждом домене сервера (/roundcube, /webmail, /phpmyadmin или путь со случайным префиксом). Какой PHP их выполняет, видно по fastcgi_pass в этих файлах:

grep -E 'location|fastcgi_pass' /etc/nginx/vhosts-includes/*roundcube*.conf /etc/nginx/vhosts-includes/*phpmyadmin*.conf

У нас нашлось сразу несколько вариантов на разных серверах:

ПриложениеЧерез nginxЧерез Apache
Roundcube /roundcubeнативный PHP-FPM (php-fpm.apache.sock) или isp-php74isp-php74 через свой сокет
Roundcube /webmailнативный PHP-FPMmod_php
phpMyAdminнативный PHP-FPMmod_php

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

  • запрос по IP или с неизвестным Host уходит через дефолтный server nginx прямо в Apache, где Alias /webmail и /phpmyadmin глобальные;
  • в конфигурации nginx для /webmail и /phpmyadmin стоят error_page 404 @apache и error_page 502 = @apache. Адрес с PATH_INFO вроде /webmail/index.php/x не совпадает с location ~ \.php$, получает 404 и уходит в Apache, где его выполнит mod_php.

На одном из серверов нативный PHP был 5.4 из CentOS 7, для которого исправлений не будет никогда. Пока мы переводили эти приложения на alt-php, блок multipart оставался только на их путях, а сайты уже работали без ограничений:

if ($uri !~* "^/(roundcube|webmail|phpmyadmin)(/|$)") { set $cve_mp 0; }

Клиенты в это время не могли прикреплять вложения в веб-почте, и они это заметили почти сразу.

Почему на CloudLinux номер версии не меняется

CloudLinux выпустил исправления для всех веток alt-php сразу, от 5.4 до 8.5, но не в виде 8.3.36 или 8.4.27, а как новые сборки прежних версий: alt-php83-8.3.35-6, alt-php84-8.4.26-5, alt-php85-8.5.11-5 и так далее. Патч вносится в установленную версию, растёт только номер релиза пакета.

Поэтому скрипты проверки, которые сравнивают PHP_VERSION с 8.3.36, будут показывать «уязвимо» и после обновления. А клиенты, заглянувшие в панель, увидят те же цифры и спросят, почему ничего не обновлено. Надёжных признаков два.

Changelog пакета. Для старых веток CloudLinux дополнительно вырезал обработчик прогресса загрузки целиком:

for p in $(rpm -qa --qf '%{NAME}\n' 'alt-php[0-9]*' | grep -E '^alt-php(5[4-9]|[78][0-9])$' | sort -V); do
  printf '%-10s %-16s ' $p "$(rpm -q --qf '%{VERSION}-%{RELEASE}' $p)"
  rpm -q --changelog $p | head -10 | grep -c 'CVE-2026-10961[89]'
done

Build Date в phpinfo(). У исправленных сборок там 21 октября 2026, у предыдущих более ранние даты. Это то, что можно сообщить клиентам: проверить может любой, без доступа к серверу.

С нативным PHP та же история с другой стороны. На RHEL 9 и его пересборках исправленная версия 8.0 называется php-8.0.30-8.el9_8.1 (бюллетень Red Hat RHSA-2026:87151), а в changelog номера CVE оказались приклеены к последнему пункту списка с совсем другим описанием. Без бюллетеня по changelog это легко принять за обычное обновление.

У пакетов ispmanager (isp-phpXX) changelog вообще шаблонный, с одной записью 2019 года. Признаки исправления там косвенные: номер сборки (у нас 1781 для 8.2-8.5, 1782 для 7.4, 1786 для 7.1) и Build Date 22 октября.

Подводные камни самого обновления

dnf --best --allowerasing на сервере с ispmanager. На CloudLinux 9 dnf update вываливал длинный список конфликтов: в репозитории есть нативный PHP 8.3.36 из модульного потока, и dnf считает его лучшим кандидатом для установленного 8.0. Но модуль Apache от ispmanager (isp-httpd-mod_php) жёстко требует PHP 8.0.x, а от него зависят coremanager-pkg-php и ispmanager-pkg-php, то есть сама панель. Подсказка dnf добавить --best --allowerasing здесь опасна: она удалит пакеты панели. Конфликт безвреден, в конце dnf пишет Nothing to do. Чтобы убрать шум до выхода сборки 8.0: dnf update --exclude='php,php-*'.

Модуль Apache для PHP у ispmanager свой. Он грузится из modules/isp-libphp8.so и принадлежит пакету isp-httpd-mod_php, а не системному php. Исправления этих CVE находятся в ядре PHP, то есть внутри самого модуля, и обновление пакета php его не меняет. Проверка:

grep -rhE 'LoadModule\s+php' /etc/httpd/conf.modules.d/ /etc/httpd/conf.d/
rpm -qf /etc/httpd/modules/isp-libphp8.so
strings /etc/httpd/modules/isp-libphp8.so | grep -m1 -E '[A-Z][a-z]{2} [ 0-9][0-9] 20[0-9]{2}'

Если модуль собран раньше 21 октября, все сайты в режиме «Модуль Apache» остаются уязвимыми. Найти их: grep -lE 'SetHandler\s+application/x-httpd-php\s*$' /etc/httpd/conf/vhosts/*/*. Мы перевели такие сайты на alt-php, а там, где это невозможно (интерфейс, которому нужен доступ вне CageFS), закрыли доступ по IP.

Выключенный PHP хуже включённого. Часть бывших mod_php-сайтов оказалась статикой, и мы выключили им PHP. На одном из них рядом с заглушкой лежал 61 файл старого сайта, и при выключенном PHP их исходники, включая конфиг с паролем от базы, отдавались как текст. Перед выключением PHP проверяйте: find ДОКРУТ -name '*.php*' | wc -l.

Обрыв SSH посреди транзакции. Обновление 160 пакетов alt-php шло несколько минут, и связь оборвалась. Транзакция, к счастью, завершилась, но проверять нужно: dnf history list, dnf check (на CentOS 7: package-cleanup --problems, package-cleanup --dupes). Длинные обновления лучше запускать в tmux.

Тихие пересборки ispmanager. Исправленные isp-php выходили в течение суток по одному: сначала 8.2-8.5, потом 7.4, потом 7.1. Для CentOS 7 сборка 7.4 на момент написания так и не появилась. Имеет смысл несколько раз в день проверять dnf check-update 'isp-php*'.

Как перевести Roundcube и phpMyAdmin на alt-php

На сервере с нативным PHP 5.4 мы перевели служебный пул FPM apache на alt-php56, сохранив тот же сокет. Тогда конфигурацию nginx менять не нужно, а откат сводится к запуску старого сервиса. Выбор 5.6 вместо 7.4 простой: Roundcube 1.4 и старый phpMyAdmin работали на 5.4, 5.6 ближе всего, и CloudLinux выпустил для неё бэкпорт.

Шаги:

  1. Свой каталог ini. Вне CageFS alt-php от root подключает только минимум расширений, остальные лежат в php.d.all. Мы собрали один extensions.ini из нужных модулей (session, json, dom, xml*, mbstring, intl, gd, mcrypt, zip, pdo, nd_mysql*). Ini-файлы CloudLinux сами подтягивают зависимости, поэтому дубликаты строк extension= убираем.
  2. Конфиг FPM с пулом apache на /var/run/php-fpm.apache.sock, теми же user, group, listen.owner и явным session.save_path, потому что в нативном php.ini он не был задан.
  3. Проверка до переключения: PHP_INI_SCAN_DIR=… php -m без ошибок и php-fpm -t -y … с test is successful.
  4. systemd-юнит с Environment=PHP_INI_SCAN_DIR=… и ExecStart=… php-fpm --nodaemonize -y ….
  5. Переключение: переименовать /etc/php-fpm.d/apache.conf, перезапустить нативный FPM (у него остаётся пул www), запустить новый. Простой несколько секунд.
  6. Apache тоже на этот сокет. В roundcube-webmail.conf и phpmyadmin-httpd.conf заменить SetHandler application/x-httpd-php на SetHandler "proxy:unix:/var/run/php-fpm.apache.sock|fcgi://localhost/", в roundcube-isp.conf заменить сокет isp-php74. Проверить, что SetHandler стоит внутри <FilesMatch "\.php$">, иначе в FPM уйдёт и статика.

Удаление дубликатов расширений из шага 1:

awk '/^[[:space:]]*(zend_)?extension[[:space:]]*=/ { k=$0; gsub(/[[:space:]]/,"",k); if (seen[k]++) next } { print }' $FILES > conf.d/extensions.ini

Проверка, кто реально выполняет PHP на каждом пути, включая прямой запрос в Apache и PATH_INFO:

echo '<?php echo PHP_VERSION, " ", php_sapi_name();' > /usr/share/roundcubemail/_v.php
curl -s -H 'Host: nonexistent.invalid' http://127.0.0.1:8080/webmail/_v.php
curl -sk https://ДОМЕН/webmail/_v.php/x
rm -f /usr/share/roundcubemail/_v.php

Везде должно быть 5.6.40 fpm-fcgi. После этого блок nginx можно снимать.

Два минуса решения: на пути через Apache пропадают ограничения open_basedir из конфигов для mod_php, а roundcube-isp.conf помечен ispmanager как перезаписываемый при обновлении пакета. После обновлений панели стоит проверять, что сокеты не вернулись и что /etc/php-fpm.d/apache.conf не появился заново.

Чек-лист для сервера с ispmanager

Если у вас сервер с ispmanager и вы ещё не уверены, что закрыли PHP2Shell полностью, пройдитесь по списку:

  • Все используемые ветки alt-php обновлены, в changelog есть CVE-2026-109618/109619 или Build Date не раньше 21 октября 2026.
  • Пакеты isp-php* обновлены, сервисы php-fpm* перезапущены (после смены unit-файла сначала systemctl daemon-reload).
  • Нативный PHP ОС обновлён или ничего не обслуживает (проверьте пулы в /etc/php-fpm.d/ и кто подключается к их сокетам).
  • Модуль Apache isp-libphp8.so собран после 21 октября, или ни один сайт не работает в режиме «Модуль Apache».
  • Roundcube и phpMyAdmin работают на исправленном PHP и через nginx, и через Apache.
  • Значения PHP проверены от имени пользователя внутри CageFS, а не от root.
  • Скелет CageFS обновлён (cagefsctl --update).
  • Временные блоки в nginx сняты, включая строку в дефолтном server в nginx.conf.
  • На сайтах с выключенным PHP нет .php-файлов.
  • Клиентам объяснено, почему номер версии в панели не изменился.

Главный вывод этой ночи простой: на хостинге с панелью «обновить PHP» означает не один пакет, а пять-шесть разных сборок PHP от трёх поставщиков (апстрим через CloudLinux, ispmanager, дистрибутив), и часть из них обслуживает не сайты, а служебные приложения панели. Полезно заранее знать, какой PHP за каким путём стоит, до того как он понадобится ночью.

Источники