21 октября 2026 года мир PHP вздрогнул от анонса двух критических RCE-уязвимостей (CVSS 9.2), получивших общее имя PHP2Shell (CVE-2026-109618 и CVE-2026-109619). Опасность этих брешей в том, что они срабатывают на уровне ядра интерпретатора еще до того, как управление дойдет до первой строчки вашего index.php. Достаточно, чтобы сервер просто принимал HTTP-запросы.
Разработчики PHP оперативно выпустили патчи для поддерживаемых веток (8.2.35, 8.3.36, 8.4.27, 8.5.12) и заявили: «Единственный 100% способ защиты — обновление, а сценарий наивысшего риска — это Apache с mod_php».
Звучит отлично, если у вас свежий проект. А что делать, если в продакшене крутится суровый высоконагруженный энтерпрайз-легаси на PHP 5.6 в связке с Apache 2.4 (mpm_prefork + mod_php), переписать который за ночь невозможно, а официальных патчей для ветки 5.6 не выйдет никогда?
Под катом — практический разбор того, как устроены обе уязвимости, почему именно mod_php оказался под главным ударом и как я выстроил эшелонированную оборону на уровне Varnish → Apache → PHP → Docker cgroups, не сломав легаси-монолит и проверив каждый рубеж боем (с разбором подводных камней HTTP keep-alive).
Введение в анатомию PHP2Shell: что именно ломается?
Обе уязвимости относятся к классу CWE-824 (обращение к неинициализированному указателю) и эксплуатируют механизм аварийного выхода из запроса через longjmp() внутри Zend Engine:
-
CVE-2026-109618 RCE (Remote Code Execution, или удалённое выполнение кода) через Out-Of-Memory (нехватка оперативной памяти) / OOM :
Когда входящий запрос превышает лимит
memory_limit, внутренний менеджер памяти Zend вызывает OOM-обработчик и прерывает выполнение черезlongjmp(). Если исчерпание памяти произошло прямо во время разбора тела запроса (например,multipart/form-data), внутренние структуры парсера остаются инициализированными лишь наполовину. Процедура очистки (shutdown) принимает «мусорные» неинициализированные указатели за валидные адреса и вызывает на нихefree(). Поскольку куча (heap) в этот момент уже может быть размечена данными атакующего (Heap Feng Shui), это приводит к выполнению произвольного кода. -
CVE-2026-109619 (RCE через устаревшее глобальное состояние):
Когда запрос падает с фатальной ошибкой или по таймауту (
max_execution_time), PHP также выпрыгивает из контекста черезlongjmp(). При определенных условиях (в частности, при активном механизме отслеживания загрузки сессийsession.upload_progress) глобальное состояние рабочего процесса сохраняет указатель на память, которая была освобождена в конце аварийного запроса (Use-After-Free / Stale Pointer). Когда следующий HTTP-запрос попадает в этот же воркер, PHP обращается к «грязному» указателю еще на этапе инициализации запроса (RINIT).
Исходная архитектура и почему мы в красной зоне
Древний легаси проект, который я администрирую и дорабатываю, крутится в Docker Compose на базе AlmaLinux. Схема прохождения трафика выглядит так:
-
Периметр: TLS-терминатор Hitch (
172.19.0.101:443) принимает HTTPS и по протоколу PROXY v2 отдает трафик в Varnish 6.0 LTS (172.19.0.102). -
Роутинг в Varnish: Часть трафика уходит на бэкенды с Node.js, а статика и легаси-приложения маршрутизируются на два отдельных контейнера с Apache (
apache_1 172.19.0.105иapache_2 172.19.0.109). -
Внутри PHP-контейнеров: Образы на базе AlmaLinux 8 + репозиторий Remi (
php56-php). Apache работает в режимеmpm_preforkсmod_php.
Старая конфигурация была идеальной мишенью для PHP2Shell:
-
В
mpm_tuning.confу Apache стоял стандартныйMaxConnectionsPerChild 1000. Это значит, что один и тот же форк Apache обрабатывает 1000 запросов подряд, разделяя адресное пространство сmod_php. Если 1-й запрос оставит «грязное» глобальное состояние (CVE-2026-109619), у атакующего есть еще 999 попыток проэксплуатировать этот процесс. -
В конфигах PHP стоял
memory_limit = 512M— то есть внутренний обработчик OOM Zend Engine был взведен и готов к срабатыванию (CVE-2026-109618). -
Директива
session.upload_progress.enabledв PHP 5.6 по умолчанию включена (On).
Поскольку пересобрать PHP 5.6 с бэкпортом патчей из PHP 8.4 на бою быстро нельзя, я решил закрыть все векторы эксплуатации архитектурно — на четырех уровнях.
Рубеж 1: Выключаем триггеры в PHP (и не наступаем на грабли с порядком загрузки .ini)
Первым делом нужно отключить оба спусковых крючка внутри самого PHP:
-
Выключить
session.upload_progress.enabled = 0(закрывает основной вектор CVE-2026-109619). -
Выставить
memory_limit = -1. Звучит контринтуитивно, но именно это рекомендуют сами авторы PHP в качестве митигации CVE-2026-109618! Еслиmemory_limitравен-1, внутренний OOM-обработчик Zend Engine сlongjmp()никогда не вызывается. А чтобы один запрос не положил весь хост-сервер по памяти, контроль за памятью мы переносим на уровень ядра Linux (cgroups в Docker) и лимитов тела запроса в Varnish/Apache.
Я вынес все настройки безопасности в отдельный файл etc/php.d/cve-2026.ini:
; === Защита от PHP2Shell (CVE-2026-109618 / CVE-2026-109619) ===; 1. Отключаем отслеживание прогресса загрузки в сессиях (блокирует вектор CVE-2026-109619)session.upload_progress.enabled = 0; 2. Отключаем внутренний OOM-обработчик Zend Engine (блокирует вектор CVE-2026-109618); Контроль памяти передается на уровень лимитов контейнера Docker (cgroups)memory_limit = -1; 3. Ограничиваем размер входящих POST-данных и загружаемых файловpost_max_size = 32Mupload_max_filesize = 32M; 4. Защита парсера входящих данных от переполнения и медленных атакmax_input_vars = 1000max_file_uploads = 10max_input_time = 30max_execution_time = 30
Тонкость с алфавитным порядком в php.d
В наших контейнерах уже монтировались основные конфиги проектов: /etc/opt/remi/php56/php.d/php-apache_1.ini и php-apache_2.ini, внутри которых было жестко прописано memory_limit = 512M.
PHP парсит директорию php.d строго в алфавитном порядке (ASCII). Если бы я примонтировал патч как /etc/opt/remi/php56/php.d/cve-2026.ini, он бы прочитался раньше файла на букву p, и настройка memory_limit = -1 тут же перезаписалась бы обратно на 512M! Кроме того, я не хотел убирать лимиты памяти у CLI-демонов, контейнеры которых собираются из того же образа.
Решение простое — монтируем файл в контейнеры веб-серверов префиксом zz-:
services: apache_1: volumes: - ./etc/php.d/00-msmtp.ini:/etc/opt/remi/php56/php.d/00-msmtp.ini:ro - ./etc/php.d/php-apache_1.ini:/etc/opt/remi/php56/php.d/php-apache_1.ini:ro # Патч конфигурации против CVE-2026-109618 / CVE-2026-109619 (загружается последним) - ./etc/php.d/cve-2026.ini:/etc/opt/remi/php56/php.d/zz-cve-2026.ini:ro
Проверяем внутри запущенного контейнера:
docker compose exec apache_1 php -i | grep -E "memory_limit|upload_progress.enabled|post_max_size|zz-cve-2026"/etc/opt/remi/php56/php.d/zz-cve-2026.inimemory_limit => -1 => -1post_max_size => 32M => 32Msession.upload_progress.enabled => Off => Off
Рубеж 2: Изоляция памяти в Docker и «одноразовые» воркеры в Apache
Раз мы поставили memory_limit = -1 в PHP, мы обязаны зажать контейнеры веб-серверов на уровне docker-compose.yml. Теперь, если кто-то чудом вызовет утечку памяти, то процесс Apache пристрелит OOM-killer ядра Linux (через SIGKILL), не вызывая никаких деструкторов PHP:
apache_1: deploy: resources: limits: memory: 4G cpus: '2.0'
Далее переходим к Apache (mpm_prefork) и проблеме «грязного» состояния памяти между запросами (CVE-2026-109619). Самый надежный способ гарантировать, что ни один запрос никогда не увидит память предыдущего запроса в mod_php — убивать дочерний процесс Apache сразу после завершения соединения. В mpm_tuning.conf меняем MaxConnectionsPerChild с 1000 на 1:
<IfModule mpm_prefork_module> StartServers 16 MinSpareServers 16 MaxSpareServers 64 ServerLimit 1024 MaxRequestWorkers 1024 MaxConnectionsPerChild 1</IfModule>
Да, это добавляет небольшой оверхед на fork() в ядре ОС, но на современном Linux с Copy-on-Write и прогретым пулом MinSpareServers 16 / MaxSpareServers 64 нагрузка на CPU выросла в пределах статистической погрешности, зато вектор переиспользования испорченной памяти воркера уничтожен физически.
Там же, в конфигах виртуальных хостов Apache, добавляем жесткое ограничение на размер тела запроса LimitRequestBody 33554432 (32 МБ) в каждый блок <Directory>:
<Directory /var/www/apache_1/htdocs> Options FollowSymLinks Indexes AllowOverride All Require all granted LimitRequestBody 33554432</Directory>
Зачем это нужно, если впереди стоит Varnish? А затем, что в HTTP/1.1 существует Transfer-Encoding: chunked, где заголовок Content-Length отсутствует, и Apache будет считать реальные прочитанные байты из потока.
Рубеж 3: Отсекаем атаки на дальних подступах в Varnish VCL
Зачем вообще пускать подозрительные запросы в контейнеры с Apache, если перед ними стоит Varnish? Поскольку через наш Varnish проходят запросы как к Node.js, так и к PHP-бэкендам (apache_1 и apache_2), я добавил в sub vcl_recv точечное правило только для PHP-бэкендов:
Фрагмент кода
# --- ЗАЩИТА PHP-БЭКЕНДОВ ОТ PHP2Shell (CVE-2026-109618 / CVE-2026-109619) --- if (req.backend_hint == apache_1 || req.backend_hint == apache_1) { # 1. Блокируем попытки манипуляции глобальным состоянием через upload progress if (req.http.Cookie ~ "PHP_SESSION_UPLOAD_PROGRESS" || req.url ~ "PHP_SESSION_UPLOAD_PROGRESS") { return (synth(403, "Forbidden")); } # 2. Отсекаем POST-запросы свыше 32 МБ (33554432 байт) до попадания в Apache/PHP if (req.method == "POST" && std.integer(req.http.Content-Length, 0) > 33554432) { return (synth(413, "Request Entity Too Large")); } }
Казалось бы, всё готово. Но когда я начал тестировать конфигурацию с помощью curl, вскрылись два интереснейших инфраструктурных нюанса.
Тестирование боем и два неочевидных сюрприза
Проверяем фильтрацию PHP_SESSION_UPLOAD_PROGRESS:
# Запрос к PHP-бэкенду:curl -k -I -s --connect-timeout 3 --max-time 5 https://172.19.0.101/ \ -H "Host: host_apache_1.com" \ -H "Cookie: PHP_SESSION_UPLOAD_PROGRESS=test" | head -n 5HTTP/1.1 403 ForbiddenServer: Varnish# Тот же запрос к Node.js-бэкенду:curl -k -I -s --connect-timeout 3 --max-time 5 https://172.19.0.101/en \ -H "Host: host_node.com" \ -H "Cookie: PHP_SESSION_UPLOAD_PROGRESS=test" | head -n 5HTTP/1.1 302 Found
Отлично, статика и PHP прикрыты, Node.js не задет.
Сюрприз №1: Ловушка HTTP Keep-Alive в Varnish при 413 Request Entity Too Large
Пробуем проверить блокировку гигантского POST-запроса (32 МБ + 1 байт = 33554433) через Varnish, не передавая само тело:
curl -k -i -s --connect-timeout 3 --max-time 5 -X POST https://172.19.0.101/ \ -H "Host: host_apache_1.com" \ -H "Content-Length: 33554433" \ -d ""
И… в ответ тишина! Через 5 секунд curl отваливается по --max-time 5 с пустым выводом.
Пробуем по другому:
curl -k -i -s --connect-timeout 3 --max-time 5 -X POST https://172.19.0.101/ \ -H "Host: host_apache_1.com" \ -H "Content-Length: 33554433" \ -H "Connection: close" \ -d "" | head -n 10HTTP/1.1 413 Request Entity Too LargeDate: Wed, 21 Oct 2026 22:02:30 GMTServer: VarnishX-Varnish: 3703085Content-Type: text/html; charset=utf-8Retry-After: 5Content-Length: 296Connection: close
а так — работает.
Что произошло?
По стандарту HTTP/1.1 соединение по умолчанию является keep-alive. Когда curl прислал заголовок Content-Length: 33554433, но передал 0 байт тела (-d ""), Varnish в vcl_recv мгновенно перешел в synth(413). Однако, чтобы сохранить keep-alive соединение чистым для следующего HTTP-запроса, Varnish пытается сначала «слить в /dev/null» (drain) те самые обещанные 33 МБ из клиентского сокета! В итоге Varnish ждет 33 МБ от curl, а curl ждет ответ от Varnish.
Более того, с точки зрения безопасности это означает, что атакующий может подвешивать воркеры Varnish в ожидании тела запроса. Лечится это принудительным закрытием соединения (Connection: close) в sub vcl_synth для кодов 403 и 413:
Фрагмент кода
sub vcl_synth { # Принудительно разрываем соединение при блокировке атак (не вычитываем тело POST-запроса) if (resp.status == 403 || resp.status == 413) { set resp.http.Connection = "close"; return (deliver); } ...}
Перезапускаем Varnish, повторяем тест даже без заголовка Connection: close со стороны клиента — и получаем мгновенный отлуп:
curl -k -i -s --connect-timeout 3 --max-time 5 -X POST https://172.19.0.101/ \ -H "Host: host_apache_1.com" -H "Content-Length: 33554433" -d ""HTTP/1.1 413 Request Entity Too LargeDate: Wed, 21 Oct 2026 22:05:47 GMTServer: VarnishX-Varnish: 1671182Connection: closeContent-Length: 0
Сюрприз №2: Почему прямой запрос к Apache с mod_php возвращает 200 OK вместо 413?
Напоследок я решил проверить, как поведут себя сами контейнеры Apache (172.19.0.105 и 172.19.0.109), если обратиться к ним напрямую в обход Varnish с заголовком Content-Length: 33554433.
curl -i -s --connect-timeout 3 --max-time 5 -X POST http://172.19.0.105/ \ -H "Host: host_apache_1.com" \ -H "Content-Length: 33554433" \ -d "" | head -n 10HTTP/1.1 413 Request Entity Too LargeDate: Wed, 21 Oct 2026 21:54:09 GMTServer: Apache/2.4.37 (AlmaLinux) PHP/5.6.40Connection: closeContent-Type: text/html; charset=iso-8859-1<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>413 Request Entity Too Large</title></head><body>curl -i -s --connect-timeout 3 --max-time 5 -X POST http://172.19.0.109/ \ -H "Host: host_apache_2.com" \ -H "Content-Length: 33554433" \ -d "" | head -n 10HTTP/1.1 200 OKDate: Wed, 21 Oct 2026 21:54:18 GMTServer: Apache/2.4.37 (AlmaLinux) PHP/5.6.40X-Powered-By: PHP/5.6.40Set-Cookie: SID=a7cf1f95-bb77-4ce5-86d9-25ddd2ad2e06; path=/Expires: Thu, 19 Nov 1981 08:52:00 GMTCache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0Pragma: no-cacheLast-Modified: Wed, 21 Oct 2026 21:54:18 +0000Transfer-Encoding: chunked
На первом контейнере (172.19.0.105), где в корне нет index.php, Apache сразу вернул классический HTTP/1.1 413 Request Entity Too Large. А вот второй контейнер (172.19.0.109), где корень сразу обрабатывается скриптом index.php через mod_php, внезапно мгновенно ответил HTTP/1.1 200 OK!
Неужели LimitRequestBody 33554432 в Apache не сработал? Заглядываем в error_log контейнера и видим картину маслом:
[2026-10-21 21:54:18.155757] [-:error] [pid 48] 172.19.0.1:35132 PHP Warning: Unknown: POST Content-Length of 33554433 bytes exceeds the limit of 33554432 bytes in Unknown on line 0
Оказалось, что в Apache 2.4 фильтр LimitRequestBody срабатывает в момент, когда обработчик пытается физически прочитать тело запроса через ap_get_brigade(). Но когда запрос попадает в mod_php, первым просыпается SAPI-инициализатор PHP. Он смотрит на заголовок Content-Length (33554433), сверяет его с нашим post_max_size = 32M (33554432) из zz-cve-2026.ini, видит превышение и вообще отказывается читать входной поток POST-данных!
В результате:
-
Вредоносный парсер
multipart/form-dataдаже не запускается; -
Массивы
$_POSTи$_FILESостаются девственно пустыми; -
index.phpотрабатывает за миллисекунды как пустой запрос и отдает200 OK, после чего воркер Apache сразу завершается благодаряMaxConnectionsPerChild 1. -
А если атакующий попытается обмануть
post_max_size, послав запрос безContent-Length(Transfer-Encoding: chunked),mod_phpначнет читать поток через Apache, и ровно на 32-м мегабайте его прихлопнет ужеLimitRequestBody.
Итоговый чек-лист для тех, кто защищает легаси на mod_php
Если у вас в инфраструктуре остались узлы со старыми версиями PHP, которые нельзя обновить до 8.2.35+ прямо сейчас, вот сводка мер, которая закроет векторы PHP2Shell (CVE-2026-109618 / CVE-2026-109619):
-
На уровне прокси (Varnish / Nginx):
-
Блокируйте запросы, содержащие
PHP_SESSION_UPLOAD_PROGRESSвCookieили query-параметрах (403 Forbidden). -
Отсекайте
POST-запросы сContent-Lengthвыше разумного лимита (413 Request Entity Too Large) и обязательно принудительно выставляйтеConnection: closeна синтетических ответах ошибок, чтобы прокси не ждал выгрузки тела в сокет.
-
-
На уровне Apache (
mpm_prefork):-
Поставьте
MaxConnectionsPerChild 1, чтобы исключить переиспользование памяти воркера между запросами. -
Задайте
LimitRequestBodyв блоках<Directory>на случай chunked-запросов безContent-Length.
-
-
На уровне PHP (
php.d/zz-cve-2026.ini):-
Выключите
session.upload_progress.enabled = 0. -
Отключите внутренний OOM-обработчик Zend через
memory_limit = -1(убедившись поphp -i, что ваш.ini-файл грузится последним и не перезаписывается другими конфигами). -
Зажмите
post_max_size,upload_max_filesize,max_input_varsиmax_input_time.
-
-
На уровне контейнеризации (Docker / systemd):
-
Обязательно задайте жесткий
deploy.resources.limits.memoryдля контейнеров сmemory_limit = -1, чтобы контроль за переполнением памяти лежал на cgroups ядра Linux.
-
ссылка на оригинал статьи https://habr.com/ru/articles/1097990/