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 = 32M upload_max_filesize = 32M ; 4. Защита парсера входящих данных от переполнения и медленных атак max_input_vars = 1000 max_file_uploads = 10 max_input_time = 30 max_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.ini memory_limit => -1 => -1 post_max_size => 32M => 32M session.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 5 HTTP/1.1 403 Forbidden Server: 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 5 HTTP/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 10 HTTP/1.1 413 Request Entity Too Large Date: Wed, 21 Oct 2026 22:02:30 GMT Server: Varnish X-Varnish: 3703085 Content-Type: text/html; charset=utf-8 Retry-After: 5 Content-Length: 296 Connection: 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 Large Date: Wed, 21 Oct 2026 22:05:47 GMT Server: Varnish X-Varnish: 1671182 Connection: close Content-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 10 HTTP/1.1 413 Request Entity Too Large Date: Wed, 21 Oct 2026 21:54:09 GMT Server: Apache/2.4.37 (AlmaLinux) PHP/5.6.40 Connection: close Content-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 10 HTTP/1.1 200 OK Date: Wed, 21 Oct 2026 21:54:18 GMT Server: Apache/2.4.37 (AlmaLinux) PHP/5.6.40 X-Powered-By: PHP/5.6.40 Set-Cookie: SID=a7cf1f95-bb77-4ce5-86d9-25ddd2ad2e06; path=/ Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0 Pragma: no-cache Last-Modified: Wed, 21 Oct 2026 21:54:18 +0000 Transfer-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.
UPD: Коварство Docker MemorySwap, или почему limits.memory недостаточно для мгновенного SIGKILL
После настройки memory_limit = -1 в PHP вся надежда при переполнении памяти (CVE-2026-109618) ложится на то, что cgroups-лимит в Docker мгновенно убьет прожорливый процесс сигналом SIGKILL.
Выше в статье я добавил в docker-compose.yml стандартный блок:
deploy: resources: limits: memory: 4G cpus: '2.0'
Казалось бы, лимит ровно 4 гигабайта. Но давайте заглянем в реальную конфигурацию запущенного контейнера через docker inspect:
docker inspect prj-apache_1-1 --format 'Memory: {{.HostConfig.Memory}} / MemorySwap: {{.HostConfig.MemorySwap}}' Memory: 4294967296 / MemorySwap: 8589934592
Сюрприз: Memory действительно равен 4 ГБ (4294967296 байт),
а вот MemorySwap равен 8 ГБ (8589934592 байт)!
Почему так происходит и чем это грозит?
По умолчанию, если явно не ограничить swap, Docker выдает контейнеру дополнительный объем подкачки, равный лимиту оперативной памяти (MemorySwap = 2 × Memory).
У меня в fstab не был указан swap-раздел и всё срабатывало как я и хотел. Но если на хост-сервере подключен swap-раздел или swap-файл, то при попытке эксплуатации OOM-уязвимости ядро Linux не убьет процесс на отметке в 4 ГБ. Вместо этого оно начнет вытеснять ещё 4 ГБ страниц памяти на диск. Сервер уйдет в тяжелейший I/O-трэшинг (swap storm), отзывчивость системы упадет до нуля, а сам момент срабатывания OOM-killer'а ядра операционной системы (SIGKILL) отложится.
Чтобы лишить контейнер права на свопирование и гарантировать мгновенный SIGKILL ровно на границе 4 ГБ RAM, значение MemorySwap нужно приравнять к Memory. При этом в спецификации Docker Compose внутри секции deploy.resources.limits директива swap не поддерживается - её нужно указывать на верхнем уровне описания сервиса через параметр memswap_limit:
apache_1: restart: always # Приравниваем RAM + Swap к лимиту RAM (запрещаем использование swap) memswap_limit: 4G deploy: resources: limits: memory: 4G cpus: '2.0'
Пересоздаем контейнеры (docker compose up -d apache_1 apache_2) и проверяем снова:
# docker inspect prj-apache_1-1 --format 'Memory: {{.HostConfig.Memory}} / MemorySwap: {{.HostConfig.MemorySwap}}' Memory: 4294967296 / MemorySwap: 4294967296
На современных системах с cgroups v2 (AlmaLinux 9 / RHEL 9) равенство Memory и MemorySwap транслируется напрямую в параметр ядра memory.swap.max = 0 для данного контейнера. Теперь у процесса нет ни единого байта в подкачке: любая попытка выйти за 4 ГБ RAM приводит к моментальному SIGKILL от ядра ОС без малейшей нагрузки на дисковую подсистему.