Crimson.Lab

Лаборатория Кримсона

21 October 2026

CVE-2026-109618 и CVE-2026-109619: проверяем обновление PHP публичным PoC

Обложка php2shell-poc: PHP-слон и повреждённые файлы сессий

В предыдущих статьях мы обновили PHP 8.2 на CentOS 7 и собрали бэкпорт для PHP 7.4. Следующий вопрос вполне практический: как проверить, что исправление действительно работает?

Для этого есть публичный php2shell-poc от Qi Deng. Берём его за основу, читаем код и сравниваем поведение старой и обновлённой сборок. По дороге выясняется, что сообщениям самого PoC нельзя доверять без проверки: обычный HTTP-заголовок способен превратиться в сообщение о падении сервера.

Дополнительно разбираем материал eness о временной защите и бэкпорты Anton-Barinov. Проверяем не только обещания README, но и файлы конкретных ревизий.

Ниже — инструкция и разбор исходников.

Что считаем подтверждением исправления

Нужно проверить три вещи:

  1. Установлены пакеты с нужными исправлениями.
  2. Веб-запросы обслуживают процессы, загрузившие обновлённый код.
  3. В одинаковых условиях воспроизводимый дефект старой сборки перестал воспроизводиться после обновления.

Последний пункт относится к проверенному сценарию. Он не превращает CentOS 7 в поддерживаемую ОС и не доказывает отсутствие других уязвимостей в PHP, Битриксе или расширениях.

У двух CVE разные механизмы:

Уязвимость Что происходит
CVE-2026-109618 Нехватка памяти прерывает операцию, оставляя некорректное состояние запроса. Дальнейшая очистка может обратиться к этому состоянию.
CVE-2026-109619 Состояние предыдущего запроса переживает освобождение его памяти. Следующий запрос в том же воркере обращается к уже недействительным данным.

Поэтому один OOM-тест не покрывает автоматически все варианты второй уязвимости. Отключение session.upload_progress тоже закрывает только часть путей.

В официальном сообщении PHP исправленными названы версии 8.2.35, 8.3.36, 8.4.27 и 8.5.12. Для PHP 7.4 предоставлен минимальный бэкпорт. В нашей старой ветке строка версии осталась 7.4.33, поэтому проверяем полный выпуск RPM и происхождение патча.

Что лежит в php2shell-poc

Разбираем ревизию 6d2168710509a2647545ba78d68afb1e3f745038 от 21 октября 2026 года. Это существенно: репозиторий меняется, и в нём уже появился пятый сценарий, которого не было в первой версии README.

Сценарий Файл Условия и ограничения
A: данные сессии и LFI verify_session_injection.py Для цепочки с выполнением кода требуется уязвимость включения локального файла в приложении
B: десериализация и use-after-free verify_uaf_asan.php Локальная проверка; диагностический отчёт требует PHP, собранного с AddressSanitizer
C: разные форматы сессий verify_handler_mismatch.py Нужны каталоги с разными обработчиками сериализации и общим хранилищем сессий
D: OOM в Apache/mod_php verify_oom_trigger.py Отправляет multipart-запрос и оценивает HTTP-ответ
E: состояние между запросами FPM verify_fpm_stale_state.py Требует подготовленной сессии, специального обработчика и попадания запросов в один воркер

Для серверов из предыдущих статей в первую очередь интересен сценарий D: там nginx перед Apache/mod_php. Сценарий E нужен при проверке FPM. Остальные помогают исследовать отдельные условия, но не образуют универсальный набор тестов, после которого можно написать «обе CVE закрыты».

Сначала читаем, как PoC выносит вердикт

В OOM-скрипте нет проверки выполнения команды на сервере и анализа повреждения памяти. Он отправляет запрос и интерпретирует ответ.

HTTP 500 трактуется как вероятный OOM. Отсутствие ответа — как вероятное падение. Но ни то ни другое само по себе не доказывает конкретную CVE: причиной могут быть ошибка приложения, тайм-аут или штатное завершение воркера.

Есть и более наглядная проблема. После проверки статуса скрипт ищет слово connection во всём ответе и при совпадении сообщает об оборванном соединении. Обычный заголовок Connection: close способен вызвать это сообщение даже при HTTP 200.

Поэтому вывод этого PoC — повод изучить сервер, а не готовый результат аудита. Обычное завершение OOM-скрипта с кодом 0 также не означает, что сервер защищён.

В FPM-скрипте похожая ситуация: HTTP 502 считается свидетельством use-after-free. Для такого вывода нужна трассировка причины падения. Кроме того, скрипт ищет текст ошибки памяти в HTTP-ответе. При отключённом выводе ошибок он может не увидеть OOM, который записан в журнал.

У FPM-проверки код завершения 0 используется при объявлении уязвимости, а 1 — при невоспроизведении. Подключать её к автоматизации как обычную проверку «0 — всё хорошо» нельзя.

Почему не запускаем лабораторию одной командой из README

В текущих файлах лаборатории есть несколько несоответствий:

Здесь достаточно причин сначала подготовить лабораторию, а уже потом запускать запросы. Для наших сборок используем изолированные копии окружения из предыдущих статей. Если берём лабораторию автора, отдельно согласовываем конфиги, фиксируем версии или digest образов, привязываем HTTP-порты к 127.0.0.1 и убираем ненужную публикацию FPM и повышенные привилегии.

Проверка стандартного Docker-образа PHP полезна для изучения PoC. Результат для наших RPM на CentOS нужно получить отдельно.

Готовим старую и новую сборки

Для каждой ветки нужны два состояния с одинаковыми настройками:

Ветка До исправления После исправления
PHP 8.2 Сборка до обновления Наши RPM с PHP 8.2.35
PHP 7.4 Сборка до бэкпорта Наши RPM 7.4.33-31.el7.local

Сохраняем ОС, SAPI, расширения, конфиги PHP и Apache, настройки nginx и путь запроса. Меняем только проверяемые пакеты. Удобно использовать снимки одной тестовой машины.

PoC намеренно создаёт условия для нехватки памяти, поэтому испытание проводим на закрытом стенде. Рабочая база и исходящие интеграции Битрикса ему не нужны.

Сначала воспроизводим исследуемый дефект на старой сборке. Если этого не произошло, отрицательный результат новой сборки мало что добавляет. Возможно, запрос не дошёл до нужного обработчика или отсутствует необходимое условие.

Обычный OOM не считаем положительным воспроизведением уязвимости. Чтобы подтвердить ошибочное обращение к памяти, могут потребоваться стек падения и отдельная сборка с AddressSanitizer. Её результаты записываем отдельно от испытания наших RPM.

Проверяем пакеты и работающий PHP

На CentOS пакеты из основного PHP SRPM можно вывести так:

rpm -qa --qf '%{NAME} %{VERSION}-%{RELEASE} %{SOURCERPM}\n' |
    awk '$3 ~ /^php-[0-9]/' |
    sort

Список показывает, что установлено на диске. Затем проверяем версию через HTTP в нужном виртуальном хосте: php -v относится к CLI, а сайт может обслуживаться другим PHP.

На закрытой копии временно размещаем диагностический файл:

<?php

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

$settings = [
    'memory_limit',
    'post_max_size',
    'upload_max_filesize',
    'session.upload_progress.enabled',
    'session.upload_progress.name',
    'session.serialize_handler',
    'max_multipart_body_parts',
];

$result = [
    'version' => PHP_VERSION,
    'sapi' => PHP_SAPI,
    'pid' => getmypid(),
];

foreach ($settings as $setting) {
    $result[$setting] = ini_get($setting);
}

echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

Сохраняем ответ вместе с адресом backend и именем виртуального хоста. После проверки файл убираем.

Один ответ характеризует один запрос. Если работают несколько серверов, пулов или экземпляров Apache, проверяем каждый. Сопоставляем загруженные бинарные файлы и библиотеки с установленными пакетами.

После обновления нужен успешный полный рестарт всех соответствующих служб. Проверка только основного httpd оставляет открытым вопрос о httpd-scale и отдельном php-fpm. Отсутствие пометки (deleted) у отображённых библиотек само по себе тоже не подтверждает, что загружена нужная сборка.

Запускаем OOM-проверку

Получаем зафиксированную версию PoC на машине для испытаний:

POC_REV=6d2168710509a2647545ba78d68afb1e3f745038

git clone https://github.com/ikow/php2shell-poc.git
cd php2shell-poc
git checkout --detach "$POC_REV"
git rev-parse HEAD

В примере предполагаем, что подготовленная копия Apache доступна по HTTP на 127.0.0.1:18081, а в нужном виртуальном хосте есть /index.php. Сам PoC сервер не создаёт.

LAB_HOST=127.0.0.1
LAB_PORT=18081
LAB_PATH=/index.php

set -o pipefail
python3 poc/verify_oom_trigger.py \
    --host "$LAB_HOST" \
    --port "$LAB_PORT" \
    --path "$LAB_PATH" \
    --strategy headers 2>&1 | tee poc-headers.log

Скрипт использует обычный TCP-сокет без TLS. Порт 443 не превращает его запрос в HTTPS. Значение --host используется и для соединения, и в HTTP-заголовке Host: проверяем, что запрос попал именно в нужный виртуальный хост.

Кроме headers, в этой ревизии есть варианты nested и no_length. Это разные способы формирования запроса, а не три независимых подтверждения безопасности. Сначала разбираем один воспроизводимый случай и сохраняем его параметры.

Перед запросом отмечаем время. После него собираем журналы Apache, PHP, nginx и сообщения ядра. Если стенд работает в Docker, дополнительно сохраняем состояние контейнеров и их логи.

Нужно установить, какой процесс обработал запрос, дошёл ли он до PHP, что произошло с памятью и почему завершился воркер. HTTP 502 от nginx может быть следствием сбоя backend, но не объясняет его причину.

Повторяем тот же запрос на обновлённой копии. Между сравнениями не меняем лимиты, расширения и настройки прокси. Разные стратегии начинаем с одинакового исходного состояния стенда, чтобы предыдущая попытка не влияла на следующую.

Что отдельно нужно для FPM

Для второй CVE важен переход состояния между запросами в одном воркере. Два запроса, ушедшие в разные процессы, этот переход не проверяют.

В комментариях к FPM-PoC перечислены лабораторные условия: включённый upload progress, небольшой лимит памяти, один FPM-воркер, заранее подготовленная большая сессия и обработчик session_start(). Однако setup.sh рассматриваемой ревизии не создаёт ни session_oom.php, ни требуемую большую сессию. Автоматически готового стенда для сценария E здесь нет.

После отдельной подготовки этих условий команда выглядит так:

FPM_LAB_URL=http://127.0.0.1:18080

python3 poc/verify_fpm_stale_state.py \
    --target "$FPM_LAB_URL" \
    --session-path /session_oom.php \
    --upload-path /index.php

До этого запуск не имеет диагностической ценности. Если обработчика или сессии нет, получили отсутствие условий для теста, а не подтверждение исправления.

Даже при одном настроенном воркере проверяем PID: процесс мог завершиться и быть заменён между запросами. Ограничение пула одним процессом используем только для воспроизводимости на стенде.

По официальному описанию, завершение FPM-воркера при ошибке старта запроса блокирует конкретную последовательность, возможную в mod_php. Другие пути могут возникать после успешного старта, во время работы приложения. Поэтому результат OOM-теста Apache нельзя переносить на FPM.

Как трактовать результат

Наблюдение Допустимый вывод
nginx вернул 403 или 413 Этот запрос остановлен на входе; обработка запроса PHP не проверена
Получен HTTP 200 Запрос обслужен; отсутствие уязвимости не доказано
Получен HTTP 500, 502 или нет ответа Требуется установить причину по журналам и состоянию процессов
Зафиксировано превышение лимита памяти Достигнут лимит; повреждение памяти или RCE этим не подтверждены
Есть отчёт AddressSanitizer о use-after-free Найден дефект памяти; его нужно сопоставить с конкретным кодом и CVE
На старой сборке дефект не воспроизводится Проверка исправления пока неубедительна
Тот же дефект воспроизводится после обновления Нужно разбирать полноту патча и его фактическое применение

Отдельно проверяем вход через nginx и непосредственную обработку backend на закрытой копии. Так можно разделить эффект фильтрации и эффект патча. Защитные ограничения рабочего сервера ради эксперимента не отключаем.

Отсутствие ошибок в журнале — тоже не универсальный критерий. Официальное описание CVE-2026-109619 подчёркивает, что некоторые операции обращаются к состоянию до решения о выводе диагностики. Выключенный display_errors не устраняет такой путь.

Что ещё предлагает eness: PoC, временные меры и исходный патч

В репозитории eness собраны не только правила Cloudflare. Рассматриваем ревизию e8db5ee1252ea3d6d084e3e421c6eb7a380efc11: PoC с попыткой выполнения команды, автоматическая настройка PHP, патч счётчиков upload progress и примеры фильтрации для разных веб-серверов.

Описание причин уязвимостей в README сосредоточено на указателях в upload progress. Официальные GHSA описывают более широкий набор ошибок OOM и состояния между запросами. Поэтому меры для одного обработчика не считаем заменой полного исправления. Утверждение README об отсутствии официальных патчей для всех старых веток также требует уточнения: минимальные исходные бэкпорты для 7.4, 8.0 и 8.1 уже опубликованы PHP.

Почему второй PoC не становится универсальным сканером

Файл poc_109618.py использует профиль Ubuntu/PHP 8.5.4 и адреса функций. В README упоминается параметр --syms, но в аргументах этой ревизии есть --dtor и --call, а --syms отсутствует.

Автоматического определения нужного адреса в скрипте нет. Нельзя подставить адрес нашего CentOS-сервера и считать отсутствие выполнения команды доказательством исправления. Кроме того, сетевой код всегда оборачивает соединение в TLS: передача URL с http:// не переключает его на обычный HTTP.

Это отдельный инструмент исследования конкретной сборки. В основной процедуре выше используем более простой запрос ikow и разбираем его последствия на сервере.

Что делает quick-fix

quick-fix/run.sh ищет установки PHP и отключает session.upload_progress.enabled, включая переопределения в конфигурации. У него есть --check, --dry-run, --audit, --probe и восстановление резервных копий через --revert. Обычный запуск изменяет файлы и перезагружает службы.

Полезно различать три результата: настройка выключена, пакет содержит патч, работающий веб-процесс загрузил этот пакет. Скрипт не делает их равнозначными. В рассматриваемой версии список PATCHED_VERSIONS вообще пуст; часть классификации строится на упоминании CVE в changelog пакета. Такой отчёт нужно сверять с составом сборки и работающими процессами.

Режим --probe проверяет фильтрацию multipart-запросов. Он не доказывает отсутствие повреждения памяти. Отключение upload progress оставляет обычную загрузку файлов, но затрагивает использующий его серверный индикатор прогресса.

Что меняет патч счётчиков

session-upload-progress-counters.patch меняет два файла расширения session. Вместо сохранения указателей на элементы массива счётчики хранятся как zend_long, а значения массива обновляются через отдельную функцию.

Это конкретное изменение механизма upload progress. В этом файле нет исправлений обработчика OOM в Zend и общего сброса состояния запросов. Применить только его к старому PHP и объявить обе CVE закрытыми нельзя.

Cloudflare и фильтрация перед PHP

Cloudflare сообщила о включении управляемых правил против php2shell для бесплатных и платных тарифов. Для своего домена проверяем фактические настройки и события WAF, включая исключения. Сообщение провайдера не подтверждает конфигурацию конкретной зоны.

В примерах eness предложены два подхода: блокировать multipart вне разрешённых маршрутов загрузки либо искать поле upload progress.

Первый подход уменьшает число доступных обработчиков, но ломает обычные multipart-формы на заблокированных адресах. Исключённые маршруты по-прежнему передают запрос в PHP. Список путей из примера для WordPress нельзя считать готовой конфигурацией Битрикса.

У второго примера обнаружилась принципиальная проблема:

any(http.request.body.form.names[*] == "PHP_SESSION_UPLOAD_PROGRESS")

По документации Cloudflare поле http.request.body.form.names заполняется для application/x-www-form-urlencoded. Исследуемый запрос использует multipart/form-data. Поэтому такое выражение нельзя выдавать за проверенное правило для данного PoC.

В Enterprise есть доступ к http.request.body.raw, по которому можно искать маркер в доступной части тела запроса. Но это дополнительная сигнатура: она зависит от фактического имени session.upload_progress.name, может давать ложные совпадения и не покрывает OOM-сценарии без этого поля. При усечении тела для инспекции WAF видит только его часть, тогда как origin получает полный запрос.

Блокировать все запросы с cookie PHPSESSID не нужно: это затрагивает обычные сессии пользователей. Фильтрацию проверяем на тестовом запросе и на штатных формах, фиксируя в Security Events, какое именно правило сработало.

Ещё одна обязательная проверка — путь до origin. Если backend доступен напрямую в обход Cloudflare, правило CDN такой трафик не обработает. Это отдельная задача ограничения сетевого доступа, а не свойство PHP-патча.

Apache, FrankenPHP и Lighttpd

Остальные примеры eness решают похожие локальные задачи:

Пример Что делает Что нужно проверить
Apache / ModSecurity Блокирует параметр upload progress Включён ли анализ тела, совпадает ли имя параметра, срабатывает ли правило на реальном multipart-запросе
Apache / mod_rewrite Запрещает multipart для выбранной области Не нужны ли там загрузки и multipart-формы; применяется ли этот конфиг
FrankenPHP Выключает upload progress через Caddyfile Эффективную настройку в работающем PHP
Lighttpd Запрещает multipart на выбранном хосте или участке сайта Область действия и работоспособность штатных форм

Эти меры дополняют установку исправлений. Отсутствие запроса в PHP благодаря фильтру и исправленный обработчик PHP — два разных результата, которые полезно записывать отдельно.

Что дают minimal и enhanced-патчи для старых веток

Anton-Barinov/php2shell-patches собирает официальные минимальные бэкпорты и варианты с дополнительными изменениями сообщества. Здесь проверена ревизия 344f877a82b4cbde0fed55a1745eddda069664f1.

Для PHP 7.4 файл minimal соответствует подходу официального бэкпорта PHP. Он завершает процесс при опасном OOM, сбрасывает ряд глобальных состояний, ограничивает обработку multipart и принудительно отключает session upload progress. Обычный приём файлов при этом сохраняется.

Вариант enhanced пытается сохранить upload progress, заменяя его отключение изменениями счётчиков и дополнительной обработкой сессии. Это самостоятельное изменение сообщества, требующее проверки на конкретной ветке.

Для PHP 7.4 minimal enhanced в проверенной ревизии
Обработка опасного OOM Немедленное завершение процесса Та же основа
Session upload progress Принудительно отключён Сохранён с изменёнными счётчиками
Обычные multipart-загрузки Сохранены Сохранены по заявлению автора и его smoke-тестам
Дополнительные изменения session Минимальное отключение пути Учёт use_only_cookies, фильтрация имени файла, сброс serializer
Проверка совместимости с нашим BitrixVM Через нашу RPM-сборку Требует отдельной сборки и испытаний

Почему нужно читать файл для своей версии

Общий README перечисляет больше изменений, чем фактически содержит enhanced-патч для 7.4. Например, дополнительные ограничения десериализации при upload progress и проверка фактически прочитанного объёма против post_max_size присутствуют в файле для PHP 5.6, но отсутствуют в проверенном файле для PHP 7.4.

Есть и ограничение заявленной фильтрации имён: удаление нулевого байта, | и переводов строк не удаляет PHP-теги. По одному такому фильтру нельзя сделать вывод, что цепочка с включением файла сессии через LFI устранена. Саму LFI в приложении он не исправляет.

Автор также отдельно указывает, что enhanced не закрывает исследуемый сценарий var_destroy/десериализации из Chain B. Значит, формулировка «улучшенный универсальный патч закрывает всё» не следует даже из описания проекта.

Приведённые расширенные результаты тестов относятся прежде всего к PHP 5.6. Для PHP 7.4 описаны сборка и smoke-проверки на Ubuntu 22.04. Это полезные сведения автора, но они не заменяют проверку CentOS 7, нашего набора расширений и Битрикса.

Что это означает для нашей RPM-сборки

Нельзя сравнивать исправления только по числу строк одного patch-файла. В нашем SRPM 7.4.33-31.el7.local ограничение max_multipart_body_parts уже приходит через php-cve-2023-0662.patch: он объявлен как Patch203 и применяется в spec. Поэтому отсутствие повторного добавления этого лимита в отдельном патче 2026 года не означает, что лимит пропущен.

Проверяем весь набор патчей и результирующий исходный код. Enhanced-файл рассчитан на определённое исходное дерево: его нельзя автоматически наложить поверх уже пропатченного Remi без разбора пересечений.

Кроме того, сборочный скрипт Anton-Barinov собирает отдельный CLI/FPM в /opt. Он не собирает Apache-модуль через apxs и не заменяет наши RPM. Для BitrixVM с mod_php сохраняем пакетную схему сборки и повторяем проверки расширений, загрузок и сессий.

Если PHP-индикатор прогресса не нужен, оснований менять выбранный минимальный бэкпорт только из-за слова enhanced нет. Если он нужен, сохранение функции — причина провести отдельное сравнение вариантов, включая тесты восстановления состояния после ошибок.

Автоматическое hardening — тоже не готовая настройка BitrixVM

harden/apply-hardening.sh рассчитан на пути FPM вида /etc/php/*/fpm/pool.d/ и PHP панели Hestia. Apache/mod_php нашего CentOS этим обходом не покрывается.

В числе предлагаемых настроек есть memory_limit=-1. Она убирает управляемый лимитом PHP OOM, но снимает ограничение памяти запроса и не исключает ошибку системного выделения памяти. На сервере с другими службами это нельзя включать как безусловное улучшение. После установки патча не нужно отключать лимит памяти просто ради того, чтобы OOM-PoC перестал срабатывать.

Проверка исправления и поиск следов взлома

В материале eness есть ещё одна полезная мысль: после обновления стоит проверить, что происходило до него. Установка нового PHP не удаляет уже появившиеся посторонние файлы.

В журнале процессов ищем неожиданные дочерние shell-процессы и сетевые утилиты у httpd или php-fpm. Сопоставляем их с запросами, ошибками PHP и изменениями файлов в webroot и временных каталогах. Приложение может вызывать внешние программы штатно, поэтому одно имя процесса не является доказательством взлома.

--audit в quick-fix — эвристический поиск, а не подтверждение чистоты сервера. Отсутствие истории процессов или логирования тела запросов нельзя компенсировать поиском строки, которая никогда не записывалась. Если находятся убедительные признаки компрометации, сохраняем журналы и разбираем инцидент отдельно от проверки патча.

Что сохранить в отчёте

Для каждого проверенного окружения записываем:

Обоснованный вывод должен называть проверенный сценарий и его ограничения. Например: дефект воспроизводился на старой сборке, не воспроизвёлся после обновления в тех же условиях, все обслуживающие сайт процессы используют новые пакеты. Такой вывод можно делать только после получения соответствующих результатов.

Фраза «PoC не сработал, сервер полностью безопасен» этих сведений не содержит. Публичный PoC помогает проверить гипотезу об исправлении, но не заменяет проверку состава патча, работающих процессов и остальных путей эксплуатации.

Источники

tags: php - bitrix - centos - security - poc