Краткий ответ: для 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 , systemd  LimitAS= ) сами по себе вызывают уязвимый сбой выделения памяти.

Что такое 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
 

Чек-лист для каждой машины, только для чтения

Семь пунктов, в таком порядке, ничего, что записывает:

  1. ОС; PHP на хосте; процессы, соответствующие  php ,  php-fpm ,  apache2|httpd ,  nginx ,  frankenphp . Процессы контейнеров видны с хоста, что часто является первым намеком на то, что  php -v  на уровне хоста лгал.
  2. Где живет PHP: хост,  docker ps ,  pct list  для Proxmox,  vzlist -a  для OpenVZ.
  3. Версия каждого SAPI, который реально обслуживает запросы, а не тот, что на  $PATH .  php -v ,  php-fpm -v , и внутри контейнеров через  docker exec .
  4. Для дистрибутивных пакетов — CVE в ченджлоге пакета (бэкпорт не меняет номер версии).
  5. Для Docker — совпадает ли образ запущенного контейнера с образом, который сейчас под этим тегом, и когда этот образ был собран.
  6. Принимает ли он веб-запросы и что его проксирует.
  7. Действующие смягчения:  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  и systemd  LimitAS=  активно вредят. Они вызывают ту же ошибку выделения памяти, в которой и кроется баг. Я ожидаю, что beancounters в OpenVZ ведут себя так же, но это предположение, которое я не тестировал.
  •  memory_limit = -1 , который предлагается в бюллетене безопасности как мера защиты для 109618, требует осторожности. В пуле с несколькими сотнями воркеров и без лимита памяти на контейнер это меняет трудноэксплуатируемый RCE на гарантированный OOM всей виртуальной машины, а в контейнере, делящем ресурсы с базой данных, это подставляет базу под OOM killer.

Единственная мера защиты, которую стоит применять везде — это  session.upload_progress.enabled = 0  в  php.ini  или конфигурации пула. Она закрывает подтвержденный путь FPM для 109619 и почти ничего не ломает — разве что индикаторы прогресса загрузки, если они есть в приложении.

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

Бэкпорты для EOL-версий меняют три вещи, которые можно заметить со стороны приложения, и обо всех трех стоит знать до деплоя, а не после.

  1.  Функции завершения (shutdown functions) перестают выполняться при OOM. Воркер проходит через  _exit(1) , поэтому  register_shutdown_function  никогда не срабатывает, сообщение уходит в stderr вместо потока вывода, а FPM за nginx возвращает 502 там, где раньше возвращал 500. Приложение, которое регулярно балансирует на грани  memory_limit , начнет выдавать 502-е ошибки, и все, что вы делаете в обработчике завершения (сброс буфера репортера ошибок, освобождение задачи из очереди, запись последней строки лога), перестанет выполняться. Если ваш мониторинг настроен на код выхода 255, он не сработает на 1.
  2.  Прогресс загрузки сессии отключен в коде, а не в конфигурации. Бэкпорты патчат проверку на  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.
  3.  Новый лимит обрезает очень большие формы.  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_vars  1000,  max_file_uploads  20,  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 делает первое из этого списка доступным для поиска, и ничего не стоит оставить это включенным.

Пошаговый план

  1. Сверьте реестр с инвентаризацией перед тем, как прикасаться к любой машине. Каждое расхождение — это машина, которая не была проверена.
  2. Обойдите парк машин вручную по чек-листу из семи пунктов. Записывайте точную версию и SAPI, никогда не пишите «вероятно».
  3. Для каждой найденной ветки определите источник исправления из таблицы доступности — официальный релиз, официальный EOL-бэкпорт или набор патчей sury.
  4. Для каждой цели, собранной самостоятельно, примените патч к  последнему патч-релизу ветки и проверьте наличие старых CVE в том, с чего вы переходите.
  5. Проверяйте в три этапа: патч применяется, патч компилируется, патч работает. Третий этап — это таблица маркеров плюс тест поведения для EOL.
  6. Тегируйте самостоятельно собранные образы и пакеты, чтобы сборку можно было идентифицировать позже — тег образа и  LABEL  или увеличение  Release  в RPM.
  7. Примените  session.upload_progress.enabled = 0  везде, независимо от всего, в  php.ini  или конфигурации пула.
  8. Проведите ретроспективный поиск по логам вплоть до 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, технический блог о бэкенде, базах данных, инфраструктуре и разборах инцидентов.