Краткий ответ: для PHP 8.2 и новее наличие патча в сборке можно доказать статически. Исправленные релизы 8.2.35, 8.3.36, 8.4.27 и 8.5.12 вводят новую INI-директиву memory_hard_limit_percent , которой не было ни в одной из предыдущих версий. Для каждой ветки от 5.6 до 8.1 нет исправленного релиза и изменения версии, поэтому единственное доказательство — поведенческое: в пропатченной сборке состояние out-of-memory завершает воркер с кодом выхода 1 и пропускает register_shutdown_function , тогда как непропатченная сборка завершается с кодом 255 и выполняет его.
PHP2Shell — это две связанные CVE, опубликованные 21 октября 2026 года и исправленные в релизах от 22-го числа. Самым затратным делом был не сам патчинг. А необходимость отвечать, машина за машиной, на вопрос, который обычно решается одной командой: исправлен этот PHP или нет.
TL;DR
- Исправление: оставайтесь на своей ветке, перейдите на последний патч-релиз этой ветки, затем примените патч. Никогда не патчите ту патч-версию, которая у вас запущена — на 7.1 и 7.2 это оставляет открытой CVE-2019-11043, а на 7.4.5 и 8.0.7 официальный патч даже не применяется.
-
Доказательство, поддерживаемые ветки:
php -r 'var_dump(ini_get("memory_hard_limit_percent") !== false);'. Верно только для 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 и новее. - Доказательство, EOL-ветки: тест поведения при OOM. Выход с кодом 1 и отсутствие функции завершения (shutdown function) означают, что бэкпорт скомпилирован; выход с кодом 255 и наличие функции завершения означают, что его нет.
-
Ловушка: поведенческий тест ничего не доказывает на поддерживаемой ветке. Апстрим-фикс намеренно оставляет обычную обработку
memory_limitбез изменений, поэтому 8.2.29 и 8.2.35 ведут себя при нем идентично. Я замерил оба варианта. -
Ловушка:
max_multipart_body_partsвphp -i— это валидный маркер для версий с 7.1 по 7.4, но бесполезный для 8.0 и 8.1, где эта директива существует с версии 8.0.28. -
Ловушка: наиболее очевидные способы смягчения (
RLIMIT_AS,ulimit -v, systemdLimitAS=) сами по себе вызывают уязвимый сбой выделения памяти.
Что такое PHP2Shell на самом деле
Две ошибки в парсинге тела запроса, обе достижимы до выполнения даже одной строки кода приложения. Единственным авторитетным описанием являются адвайзори php-src; в нескольких описаниях от вендоров перепутаны номера двух CVE, поэтому ориентируйтесь на страницы GHSA.
-
GHSA-w8hw-w9fx-j79w (CVE-2026-109618): состояние out-of-memory во время парсинга тела запроса прерывает парсинг с
longjmp(). Частично инициализированные структуры позже считываются как валидные. Вызов этой ошибки — это проблема усиления памяти (memory-amplification), а не проблема размера: небольшое multipart-тело с огромным количеством частей занимает в куче PHP на порядки больше места, чем при передаче по сети. - GHSA-6wmf-4rv5-p3rp (CVE-2026-109619): устаревшее глобальное состояние. Запрос-производитель, который аварийно завершается (фатальная ошибка, таймаут, OOM), оставляет «мусор», который считывает запрос-потребитель в том же воркере.
Оба адвайзори идентичны по метаданным, что намекает на то, насколько тесно связаны эти баги. У обоих оценка CVSS 4.0 — 9.2, критическая, по одному и тому же вектору CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N : высокая сложность и наличие требований к атаке, но без привилегий и без взаимодействия с пользователем. В обоих перечислены одни и те же пять уязвимостей, а не по одной: CWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419. Оба были опубликованы 21 октября 2026 года с разницей примерно в двадцать секунд, в 13:34 UTC. В обоих указаны одни и те же четыре исправленные версии. Если вы читаете сводку от вендора, где двум CVE присвоены разные CWE, это отсебятина.
Два последствия определяют весь ответ. Тело запроса парсится до роутинга, поэтому аутентификация, CSRF-токены, режим обслуживания и любая фильтрация на уровне фреймворка находятся «ниже» бага и ничего не защищают. А подтвержденный путь потребителя в FPM — это session upload progress, который включен по умолчанию и работает без вызова приложением session_start() . Я замерил session.upload_progress.enabled на 1 в стандартном php:8.2-cli-alpine . Приложение, которое вообще не использует сессии, все равно остается уязвимым.
Исправленные релизы существуют только для четырех веток: 8.5.12, 8.4.27, 8.3.36 и 8.2.35, все датированы 22 октября 2026 года в ChangeLog-8.
Причина, по которой это не могло ждать окна обслуживания, — таймлайн. Команда Amazon по анализу угроз сообщает о первой попытке эксплуатации в телеметрии своего ханипота MadPot 17 октября, за четыре дня до раскрытия и пока баги были под эмбарго, а затем последовали новые попытки с 02:00 UTC 21 октября; Sansec фиксирует, что Cloudflare видела атаки в 07:43 UTC 21 октября. Адвайзори стали публичными в 13:34 UTC того же дня. Какую бы цифру вы ни взяли, льготный период между «это публично» и «это атакуют» был отрицательным.
Почему скрипт для массового сканирования дает неверный ответ
Скрипт, который ходит по SSH везде и запускает php -v , сообщает «PHP не найден» на машинах, где PHP — единственное, что запущено. Я написал такой, а потом выбросил.
Проблема структурная. На хосте, где PHP живет в контейнере, php -v на самом хосте не установлен, поэтому скрипт записывает машину как «чистую». На ноде Proxmox PHP находится внутри LXC-контейнера, о котором docker ps ничего не знает. На старом хосте OpenVZ вам нужен vzlist -a . А docker exec против контейнера, который перестал отвечать, зависает без какого-либо таймаута, что подвешивает сканирование и оставляет зависший процесс на хосте, который кто-то найдет позже.
Возьмем реестр из примерно двухсот записей (физические серверы, ВМ, LXC-контейнеры, старый OpenVZ, облачные инстансы), где часть есть в инвентаре Ansible, а часть существует только в текстовом файле. Проходя по нему вручную по фиксированному чек-листу, машина за машиной, PHP обнаруживается примерно в сорока местах на ветках от 5.6 до 8.4, и статус «вероятно уязвима» никогда не нужен: везде, где есть доступ, точная версия и SAPI известны.
Прежде всего, сверьте инвентарь с реестром. В парке такого размера ожидайте, что около дюжины записей будут не совпадать, и каждое несовпадение — это машина, которая молча выпадает из аудита: контейнеры, перечисленные в одном источнике и отсутствующие в другом, выведенные из эксплуатации ноды, которые все еще описаны как живые, один и тот же ID контейнера, якобы запущенный на двух разных нодах, «почтовый сервер», который оказывается пустым. Ничего экзотического. Это то, что происходит, когда два человека ведут два списка в течение трех лет.
Один пункт чек-листа, который стоил мне реального времени: не тестируйте SSH-доступ простой командой ssh . Мой ~/.ssh/config подставлял личный ключ для каждого хоста, поэтому «общий ключ здесь работает» было правдой для ключа, который я не тестировал. На одной машине это заставило меня откатить ключ, который уже был добавлен правильно, а затем добавить его снова. Единственная честная форма — это:
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/<key> -p <port> root@<host> true && echo OK
Чек-лист для каждой машины, только для чтения
Семь пунктов, в таком порядке, ничего, что записывает:
- ОС; PHP на хосте; процессы, соответствующие
php,php-fpm,apache2|httpd,nginx,frankenphp. Процессы контейнеров видны с хоста, что часто является первым намеком на то, чтоphp -vна уровне хоста лгал. - Где живет PHP: хост,
docker ps,pct listдля Proxmox,vzlist -aдля OpenVZ. - Версия каждого SAPI, который реально обслуживает запросы, а не тот, что на
$PATH.php -v,php-fpm -v, и внутри контейнеров черезdocker exec. - Для дистрибутивных пакетов — CVE в ченджлоге пакета (бэкпорт не меняет номер версии).
- Для Docker — совпадает ли образ запущенного контейнера с образом, который сейчас под этим тегом, и когда этот образ был собран.
- Принимает ли он веб-запросы и что его проксирует.
- Действующие смягчения:
session.upload_progress.enabled,post_max_size, с учетомconf.dиphp_admin_valueна уровне пула.
# What PHP is really serving, host plus containers, with a timeout for hung containers
ps -eo user,comm | grep -E 'php|apache2|httpd|nginx|frankenphp' | sort | uniq -c
for c in $(docker ps --format '{{.Names}}'); do
v=$(timeout 20 docker exec "$c" sh -c \
'for b in php php-fpm frankenphp; do command -v $b >/dev/null && printf "%s: %s; " $b "$($b -v | head -1)"; done' \
</dev/null 2>/dev/null)
echo "$c | $(docker inspect -f '{{.Config.Image}}' "$c") | ${v:-no php}"
done
timeout 20 — это не украшение. Без него один неотвечающий контейнер останавливает аудит. Когда docker exec безнадежен, вы все равно можете получить версию из конфига образа, не заходя в контейнер:
docker image inspect <image:tag> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PHP_VERSION
Для каких веток вообще есть исправление
Для каждой ветки от 5.6 и выше есть путь к исправлению. Наличие исправления и наличие пакета — это разные вещи.
| Ветка | Официально | Что реально можно установить |
|---|---|---|
| 8.5, 8.4, 8.3, 8.2 | релизы 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | официальные Docker-образы, пакеты sury |
| 8.1, 8.0, 7.4 | минимальные бэкпорты исходников от PHP Security Team: ветки PHP-{7.4,8.0,8.1}-CVE-2026-109618-109619 , один коммит поверх последнего тега |
плюс пакеты sury для Debian 11–13; 7.4 также через Freexian ELTS и Ubuntu Pro ESM |
| 7.3, 7.2, 7.1, 5.6 | нет |
sury патчи от Ондржея Суры (Ondřej Surý); Ubuntu Pro ESM для дистрибутива php7.2 и php5 ; TuxCare alt-php, платно |
Официальные бэкпорты — это по одному коммиту каждый, и статистику коммитов стоит знать перед планированием работы: 712e60f9edfd в ветке 7.4 затрагивает 17 файлов (+240/−59), 82f8f31b16a0 в 8.0 затрагивает 17 файлов (+229/−59), а 1d7dec178a09 в 8.1 затрагивает 23 файла (+311/−73). Ни один из них не затрагивает директории SAPI fpm или apache2handler .
Патч sury для самой старой ветки документирует свои собственные ограничения, поэтому я доверяю ему больше, чем комьюнити-портам. Заголовок 0261-Avoid-state-corruption-via-bailout.patch от Арно Ле Блана (Arnaud Le Blanc), соавтора апстрим-фикса, прямо гласит: «Минимальное смягчение для EOL-версии». Затем он перечисляет то, что намеренно оставляет без внимания, включая то, что «часть max_multipart_body_parts патча 7.4 пропущена; ограничение связано с бэкпортом CVE-2023-0662». Патч, который говорит вам, чего он не делает, — это патч, вокруг которого можно планировать работу.
Ходил «универсальный» сторонний порт, покрывающий версии с 5.4 по 7.3. Он модифицирует 8 файлов против официальных 17, при этом zend_hash.c , php_variables.c и VM остаются нетронутыми, а его собственные авторы тестировали его на встроенном CLI-сервере. Я его не использовал.
Правило порядка, которое имеет значение
Патчите последний патч-релиз ветки, а не ту версию, которая у вас запущена. Две независимые причины, обе проверены на ванильных тарболлах:
- Патч часто не применяется. Официальный коммит 7.4 чисто ложится на 7.4.33 и 7.4.29, но конфликтует на 7.4.5 в
zend_vm_*. Коммит 8.0 чисто ложится на 8.0.30 и конфликтует на 8.0.7. - Там, где он применяется, он может оставить открытой старую дыру. Патч sury 7.1 чисто применяется к 7.1.8, 7.1.11 и 7.1.17 — все они старше фикса CVE-2019-11043, который появился в 7.1.33. Закрытие PHP2Shell на 7.1.8 дает вам неаутентифицированный FPM RCE, который у вас уже был.
Как доказать, что сборка пропатчена
Здесь я потратил больше всего времени, и здесь опубликованные советы самые скудные. Три уровня, и они подводят по-разному.
Номер версии — это не доказательство ни в ту, ни в другую сторону. Самостоятельно собранный «7.4.33 плюс патч» сообщает ровно 7.4.33 , так же, как и непропатченный. И дистрибутивы даже не согласны друг с другом относительно того, должна ли меняться версия. Ubuntu делает бэкпорты в замороженную апстрим-версию; Debian делает ребейз на исправленную апстрим-версию. Из трекеров самих двух вендоров:
| Вендор | Релиз | Пакет | Исправленная версия | Апстрим-версия изменилась? |
|---|---|---|---|---|
| Ubuntu | trusty (ESM) | php5 |
5.5.9+dfsg-1ubuntu4.29+esm18 |
нет |
| Ubuntu | xenial (ESM) | php7.0 |
7.0.33-0ubuntu0.16.04.16+esm22 |
нет |
| Ubuntu | bionic (ESM) | php7.2 |
7.2.24-0ubuntu0.18.04.17+esm14 |
нет |
| Ubuntu | focal (ESM) | php7.4 |
7.4.3-4ubuntu2.29+esm5 |
нет |
| Ubuntu | jammy | php8.1 |
8.1.2-1ubuntu2.28 |
нет |
| Ubuntu | noble | php8.3 |
8.3.6-0ubuntu0.24.04.13 |
нет |
| Debian | bookworm | php8.2 |
8.2.35-1~deb12u1 , из 8.2.32-1~deb12u1
|
да |
| Debian | trixie | php8.4 |
8.4.27-1~deb13u1 , из 8.4.24-1~deb13u1
|
да |
Две строки там заслуживают повторного взгляда. php8.1 в Ubuntu на jammy исправлен в 8.1.2 — ветка, которую апстрим объявил EOL без исправленного релиза. Дистрибутив все равно поддерживает ее. php8.3 в Ubuntu на noble остается на 8.3.6 , в то время как апстрим-фикс — это 8.3.36 ; сканер, сравнивающий с «8.3.36 или новее», пометит полностью пропатченную машину как уязвимую. Тем временем в Debian bookworm версия действительно изменилась на 8.2.35 , так что там php -v работает нормально.
Практическое следствие заключается в том, что для проверки версии нужно знать, какой вендор собрал PHP, прежде чем интерпретировать число, что странно и является хорошим аргументом в пользу проверки чего-то другого. Обе таблицы выше взяты из машиночитаемых источников, которые вы можете запросить сами: https://ubuntu.com/security/cves/CVE-2026-109618.json и JSON трекера безопасности Debian. Сканер, работающий только на основе php -v , назовет пропатченный образ уязвимым, а бэкпортированный пакет — безопасным, и в обоих случаях будет неправ.
INI-вывод — это доказательство только для поддерживаемых веток, и это нововведение. Исправленные релизы добавляют директиву, которой нет ни в одном более раннем релизе. Я проверил main/main.c по тегам:
| Тег |
memory_hard_limit_percent вхождений |
|---|---|
| php-8.2.29 | 0 |
| php-8.2.34 | 0 |
| php-8.2.35 | 4 |
| php-8.3.36 | 4 |
| php-8.4.27 | 4 |
| php-8.5.12 | 4 |
| php-8.1.34 | 0 |
PHP-7.4-CVE-... ветка бэкпорта |
0 |
PHP-8.0-CVE-... ветка бэкпорта |
0 |
PHP-8.1-CVE-... ветка бэкпорта |
0 |
Директива появляется ровно в четырех исправленных релизах и ни в одном из их предшественников. Она объявлена как PHP_INI_ENTRY("memory_hard_limit_percent", NULL, PHP_INI_ALL, OnChangeMemoryHardLimitPercent) и отвергает бессмысленные значения с ошибкой "memory_hard_limit_percent must be >= 100, or -1". Это дает одну проверку, валидную для любой поддерживаемой ветки, включая дистрибутивный пакет, чья строка версии отказывается меняться:
php -r 'var_dump(ini_get("memory_hard_limit_percent") !== false);'
# true -> this build is 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or newer
# false -> unpatched, or an EOL backport, or a partial distro backport
Две вещи об этой проверке, в обеих я ошибался, прежде чем сделал правильно.
Первое — это !== false , что является не защитным стилем, а всем механизмом. Значение директивы по умолчанию — NULL , поэтому в пропатченной сборке ini_get() возвращает пустую строку, а пустая строка в PHP приводится к false. Если написать очевидным способом, проверка сообщит, что каждая сборка в парке непропатчена:
| Образ | ini_get(...) |
!== false |
if (ini_get(...)) |
|---|---|---|---|
php:8.2-cli-alpine3.21 (8.2.29) |
false |
false |
false |
php:8.2-cli-alpine (8.2.35) |
'' |
true |
false |
php:8.1-cli-alpine (8.1.34, EOL) |
false |
false |
false |
Замерено на этих трех образах. Средняя строка — та, что имеет значение: пропатченная сборка и непропатченная сборка неразличимы при проверке на истинность (truthiness test), и сбой происходит тихо и единообразно. Сканер, написанный таким образом, выдает отчет, который выглядит чистым, где ничего не пропатчено, — это тот тип вывода, в который люди верят, потому что он соответствует их страхам. array_key_exists('memory_hard_limit_percent', ini_get_all()) тоже работает, и в нем сложнее ошибиться.
Второе — это случай false. Директива отсутствует во всех трех официальных ветках бэкпортов EOL и во всех шести наборах патчей sury EOL, которые я распаковал, поэтому false на 7.x или 8.0 не говорит вам вообще ничего. Это позитивный маркер, а не негативный.
Поведенческий тест — это доказательство для EOL-веток, и только для них. Бэкпорты EOL заменяют аварийный выход (bailout) на жесткий выход (hard exit). В патче 8.0 diff читается дословно:
- heap->overflow = 1;
- zend_try {
- zend_error_noreturn(E_ERROR, format, limit, size);
- } zend_catch {
- } zend_end_try();
- heap->overflow = 0;
- zend_bailout();
- exit(1);
+ (void) heap;
+ zend_mm_safe_error_ex(format, limit, size);
и новая функция заканчивается на _exit(1) после записи фатальной ошибки напрямую в stderr. Это наблюдаемо из userland. Весь тест выглядит так:
<?php
register_shutdown_function(function () { fwrite(STDERR, "SHUTDOWN-RAN\n"); });
ini_set('memory_limit', '64M');
$a = [];
while (true) { $a[] = str_repeat('x', 100000); }
docker run --rm --memory 512m -v "$PWD/oom.php:/oom.php:ro" <image> php /oom.php
echo "exit=$?"
Три сборки, один скрипт, memory_limit=64M в 512 МБ контейнере:
| Сборка | Код выхода | Фатальная ошибка записана в | Функция завершения |
|---|---|---|---|
php:8.2-cli-alpine3.21 , PHP 8.2.29 (непропатчен) |
255 | stdout | выполнилась |
php:8.2-cli-alpine , PHP 8.2.35 (исправлен) |
255 | stdout | выполнилась |
sury php8.0-cli 1:8.0.30-25+0~20261021.88+debian12~1.gbpabebea , EOL бэкпорт |
1 | stderr | не выполнилась |
Деструктор не выполнился ни в одном из трех случаев.
Средняя строка — та, что меня удивила, и она исправляет то, что я предполагал, читая патчи. Фикс для поддерживаемой ветки оставляет обычную обработку memory_limit точно такой же, какой она была (soft OOM откладывается до безопасной точки, а не преобразуется в выход процесса), поэтому 8.2.29 и 8.2.35 неразличимы в этом тесте. Только бэкпорты EOL превращают нарушение memory_limit в _exit(1) . Любой, кто использует OOM-тест как общую проверку «пропатчено ли» на 8.2, будет получать ложноотрицательный результат каждый раз.
В поддерживаемых ветках есть жесткий путь через новую директиву: zend_mm_hard_limit_error выводит "Hard memory limit of %zu bytes exhausted (tried to allocate %zu bytes) in %s on line %d" и вызывает _exit(137) . Я не смог достичь этого при тестировании, потому что soft limit срабатывает первым даже с -d memory_hard_limit_percent=100 , поэтому я описываю это на основе исходного кода, а не измерений.
Таблица маркеров по веткам
Подводя итог, потому что ответ зависит от ветки, а ошибка стоит целого дня:
| Ветка | Статический маркер в php -i
|
Почему |
|---|---|---|
| 8.2–8.5 | memory_hard_limit_percent |
новое в исправленных релизах |
| 8.1, 8.0 | нет |
max_multipart_body_parts уже существует начиная с 8.0.28 |
| 7.4, 7.3, 7.2, 7.1 | max_multipart_body_parts |
отсутствует во всех ванильных 7.x, добавлено исправлением |
| 5.6 |
max_multipart_body_parts , но только со вторым патчем sury |
сам по себе патч bailout не добавляет директиву |
Я проверил второй столбец, подсчитав директиву в main/main.c по ванильным тегам: ноль в 5.6.40, 7.1.33, 7.2.34, 7.3.33 и 7.4.33, единица в 8.0.28, 8.0.30 и 8.1.34.
Сборка с assert, который ничего не проверяет
Если вы собираете PHP самостоятельно, добавьте проверку маркера в процесс сборки, чтобы он падал, если патч перестал применяться без предупреждения. Мой первый выбор был неверным: я использовал grep -q zend_objects_store_mark_destructed , который проходит и на непатченном исходном коде, поэтому assert был декоративным. Правило такое: сначала проверить целевой символ в ванильном дереве и потребовать ноль.
array_init_singleton в Zend/zend_API.h — это то, что я бы использовал сейчас. Он встречается ноль раз в ванильных 5.6.40, 7.4.33 и 8.0.30 и добавляется каждым из шести наборов патчей EOL — один маркер покрывает версии с 5.6 по 8.0, вместо того чтобы использовать разный grep для каждой ветки.
grep -c array_init_singleton Zend/zend_API.h # 0 before the patch, >=1 after
Один нюанс, если вы проверяете пакет, а не дерево исходников внутри контейнера: образы debian:*-slim поставляются с /etc/dpkg/dpkg.cfg.d/docker , содержащим path-exclude /usr/share/doc/* и path-include /usr/share/doc/*/copyright . Changelog пакета, который вам советовали проверять через grep на наличие CVE, в образе вообще отсутствует; остается только copyright . Я потратил некоторое время, будучи уверенным, что пакет sury был выпущен без записи в changelog.
Меры защиты, которые не работают, и одна, которая делает только хуже
Большинство очевидных «крутилок» находятся ниже по потоку от самой ошибки.
-
file_uploads = Offне помогает. Multipart-тело все равно парсится. -
ini_set()и.user.iniприменяются уже после того, как тело было распарсено. Слишком поздно по архитектурным причинам. Помогает толькоphp.ini,php_admin_valueна уровне пула или конфигурация Apache, за которыми следует полный перезапуск воркеров — для Apache, а неgraceful. - Блокировка
multipart/*на прокси не закрывает urlencoded-вариант, поэтому вам также нужно ограничение размера тела запроса. И не ограничивайте блок только POST-запросами: начиная с 8.4,request_parse_body()парсит и другие методы. -
RLIMIT_AS,RLIMIT_DATA,ulimit -vи systemdLimitAS=активно вредят. Они вызывают ту же ошибку выделения памяти, в которой и кроется баг. Я ожидаю, что beancounters в OpenVZ ведут себя так же, но это предположение, которое я не тестировал. -
memory_limit = -1, который предлагается в бюллетене безопасности как мера защиты для 109618, требует осторожности. В пуле с несколькими сотнями воркеров и без лимита памяти на контейнер это меняет трудноэксплуатируемый RCE на гарантированный OOM всей виртуальной машины, а в контейнере, делящем ресурсы с базой данных, это подставляет базу под OOM killer.
Единственная мера защиты, которую стоит применять везде — это session.upload_progress.enabled = 0 в php.ini или конфигурации пула. Она закрывает подтвержденный путь FPM для 109619 и почти ничего не ломает — разве что индикаторы прогресса загрузки, если они есть в приложении.
Что патч меняет в поведении приложения
Бэкпорты для EOL-версий меняют три вещи, которые можно заметить со стороны приложения, и обо всех трех стоит знать до деплоя, а не после.
-
Функции завершения (shutdown functions) перестают выполняться при OOM. Воркер проходит через
_exit(1), поэтомуregister_shutdown_functionникогда не срабатывает, сообщение уходит в stderr вместо потока вывода, а FPM за nginx возвращает 502 там, где раньше возвращал 500. Приложение, которое регулярно балансирует на граниmemory_limit, начнет выдавать 502-е ошибки, и все, что вы делаете в обработчике завершения (сброс буфера репортера ошибок, освобождение задачи из очереди, запись последней строки лога), перестанет выполняться. Если ваш мониторинг настроен на код выхода 255, он не сработает на 1. -
Прогресс загрузки сессии отключен в коде, а не в конфигурации. Бэкпорты патчат проверку на
if (!PS(rfc1867_enabled) || true), поэтому функция выключена, хотяphp -iвсе еще сообщаетsession.upload_progress.enabled => On. Поддерживаемые ветки пошли другим путем: они сохраняют функцию и добавляют проверкуif (PG(modules_activated)) { /* request_parse_body(). Do not run here as it corrupts the session. */ return retval; }. Эта проверка появилась в исправленном релизе:PG(modules_activated)встречается дважды вext/session/session.cв версиях 8.2.29 и 8.2.34, и трижды в 8.2.35. -
Новый лимит обрезает очень большие формы.
max_multipart_body_partsпо умолчанию равен-1, который вычисляется какmax_input_vars + max_file_uploads. При стандартных настройках это 1000 + 20, то есть 1020 частей, после чего вы получаете: "Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini." Я измерил стандартные значения наphp:8.2-cli-alpine:max_input_vars1000,max_file_uploads20,max_multipart_body_parts-1. Формула фоллбэка идентична вmain/rfc1867.cна ванильном 8.2 и в бэкпорте для 5.6.
Поиск следов
Поскольку эксплуатация началась до раскрытия информации, ретроспективный поиск — часть работы. Сигнатуры исходят из механизма, а не из конкретного пейлоада:
# OOM before the script even starts: the parse never reached user code
grep -h 'Allowed memory size.*Unknown on line 0' /var/log/php*/*.log
# worker crashes
grep -h 'AH00052' /var/log/apache2/error.log # mod_php segfault
grep -h 'exited on signal 11' /var/log/php*-fpm.log # FPM
Помимо логов: серии больших POST-запросов с одного адреса за короткий промежуток времени, PHP_SESSION_UPLOAD_PROGRESS , появляющийся в телах запросов, необычно длинный PHPSESSID в ответе Set-Cookie , и процессы оболочки, такие как sh -c , curl или wget , порожденные воркером PHP. Добавление $content_type и $request_length в формат логов доступа nginx делает первое из этого списка доступным для поиска, и ничего не стоит оставить это включенным.
Пошаговый план
- Сверьте реестр с инвентаризацией перед тем, как прикасаться к любой машине. Каждое расхождение — это машина, которая не была проверена.
- Обойдите парк машин вручную по чек-листу из семи пунктов. Записывайте точную версию и SAPI, никогда не пишите «вероятно».
- Для каждой найденной ветки определите источник исправления из таблицы доступности — официальный релиз, официальный EOL-бэкпорт или набор патчей sury.
- Для каждой цели, собранной самостоятельно, примените патч к последнему патч-релизу ветки и проверьте наличие старых CVE в том, с чего вы переходите.
- Проверяйте в три этапа: патч применяется, патч компилируется, патч работает. Третий этап — это таблица маркеров плюс тест поведения для EOL.
- Тегируйте самостоятельно собранные образы и пакеты, чтобы сборку можно было идентифицировать позже — тег образа и
LABELили увеличениеReleaseв RPM. - Примените
session.upload_progress.enabled = 0везде, независимо от всего, вphp.iniили конфигурации пула. - Проведите ретроспективный поиск по логам вплоть до 17 октября, прежде чем закрыть инцидент.
Итог
Утверждение «EOL означает, что исправлений нет» — ложно: каждая ветка от 5.6 и выше имеет рабочий патч, от команды безопасности PHP для 7.4–8.1 и от мейнтейнера Debian для всего, что старше. «Есть исправление» — это не то же самое, что «есть пакет», и для старых веток это означает сборку, которую теперь поддерживаете вы. Патчьте последний патч-релиз ветки, а не ту версию, на которой вы сейчас находитесь.
То, что стоит запомнить — это верификация. php -v не может ответить на вопрос ни для одной ветки. На 8.2 и новее ini_get('memory_hard_limit_percent') !== false может, и это работает через дистрибутивный бэкпорт, который никогда не меняет строку версии. На версиях с 5.6 по 8.1 ничто статическое не дает ответа, и вам нужен тест поведения OOM — который, если его инвертировать, означает, что тот же самый тест ничего не доказывает на 8.2, где и уязвимая, и исправленная сборки завершаются с кодом 255 и выполняют свои функции завершения. Я проверил это на собственном горьком опыте.
Работа по каждой ветке описана отдельно: PHP 8 и замороженные Docker-теги, PHP 7.1–7.4 с официальными бэкпортами и бэкпортами sury, и PHP 5.6, где единственным доказательством является поведение.
Первоисточники: GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, php.net ChangeLog-8, php-src для веток бэкпортов, packages.sury.org для наборов патчей EOL и docker-library/php для исторических Dockerfile.
Оригинал опубликован на bitpage.me — BitPage, технический блог о бэкенде, базах данных, инфраструктуре и разборах инцидентов.
Комментарии (0)
Пока нет комментариев — будьте первым.