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:

  1. 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), это приводит к выполнению произвольного кода.

  2. CVE-2026-109619 (RCE через устаревшее глобальное состояние):

    Когда запрос падает с фатальной ошибкой или по таймауту (max_execution_time), PHP также выпрыгивает из контекста через longjmp(). При определенных условиях (в частности, при активном механизме отслеживания загрузки сессий session.upload_progress) глобальное состояние рабочего процесса сохраняет указатель на память, которая была освобождена в конце аварийного запроса (Use‑After‑Free / Stale Pointer). Когда следующий HTTP‑запрос попадает в этот же воркер, PHP обращается к «грязному» указателю еще на этапе инициализации запроса (RINIT).

Исходная архитектура и почему мы в красной зоне

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

  1. Периметр: TLS‑терминатор Hitch (172.19.0.101:443) принимает HTTPS и по протоколу PROXY v2 отдает трафик в Varnish 6.0 LTS (172.19.0.102).

  2. Роутинг в Varnish: Часть трафика уходит на бэкенды с Node.js, а статика и легаси‑приложения маршрутизируются на два отдельных контейнера с Apache (apache_1 172.19.0.105 и apache_2 172.19.0.109).

  3. Внутри 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:

  1. Выключить session.upload_progress.enabled = 0 (закрывает основной вектор CVE-2026-109619).

  2. Выставить 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‑данных!

В результате:

  1. Вредоносный парсер multipart/form-data даже не запускается;

  2. Массивы $_POST и $_FILES остаются девственно пустыми;

  3. index.php отрабатывает за миллисекунды как пустой запрос и отдает 200 OK, после чего воркер Apache сразу завершается благодаря MaxConnectionsPerChild 1.

  4. А если атакующий попытается обмануть 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):

  1. На уровне прокси (Varnish / Nginx):

    • Блокируйте запросы, содержащие PHP_SESSION_UPLOAD_PROGRESS в Cookie или query‑параметрах (403 Forbidden).

    • Отсекайте POST‑запросы с Content-Length выше разумного лимита (413 Request Entity Too Large) и обязательно принудительно выставляйте Connection: close на синтетических ответах ошибок, чтобы прокси не ждал выгрузки тела в сокет.

  2. На уровне Apache (mpm_prefork):

    • Поставьте MaxConnectionsPerChild 1, чтобы исключить переиспользование памяти воркера между запросами.

    • Задайте LimitRequestBody в блоках <Directory> на случай chunked‑запросов без Content-Length.

  3. На уровне 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.

  4. На уровне контейнеризации (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 от ядра ОС без малейшей нагрузки на дисковую подсистему.