BitPage

PHP2Shell (CVE-2026-109618 и CVE-2026-109619): где взять исправление для каждой ветки и какие смягчения вредят

Автор:  ·   · 15 мин чтения

Короткий ответ: обновиться до 8.2.35, 8.3.36, 8.4.27 или 8.5.12 — единственное полное исправление; advisory говорит это дословно. Для EOL-ветвей релизов нет: PHP Security Team выпустила минимальные бэкпорты ровно для трёх ветвей — 8.1, 8.0 и 7.4, они лежат в php-src ветками PHP-<версия>-CVE-2026-109618-109619 без тарболов и Docker-образов. Для 7.3 и ниже исправления есть только в пакетах sury. И отдельно: RLIMIT_AS, ulimit -v и systemd LimitAS= не смягчают проблему, а сами вызывают тот самый сбой, через который она эксплуатируется.

21 октября 2026 PHP раскрыл две связанные уязвимости, которым сообщество быстро дало имя PHP2Shell — по утёкшему инструменту эксплуатации. Я закрывал их на парке, где PHP жил в восьми разных ветках, от 5.6 до 8.4, и половина установок — на системах, для которых официальных релизов не бывает уже годы. Эта статья — про общую часть: что именно сломано, где брать исправление для каждой ветки и какие смягчения из интернета работают, а какие делают хуже.

Разбор по ветвям вынесен отдельно: поддерживаемые 8.x и Docker-теги, ветки 7.1–7.4, 5.6.

Коротко (TL;DR)

Что именно сломано: две уязвимости, один патч

Обе уязвимости — про состояние памяти запроса, которое PHP оставляет после аварийного выхода из разбора тела. GHSA-w8hw-w9fx-j79w (CVE-2026-109618) описывает механизм дословно:

«PHP uses longjmp() for out-of-memory (OOM) errors to exit the current context and abort handling of the request. In some contexts, request memory is temporarily reachable before it is fully initialized. If any allocation fails at that point, PHP does not properly account for the early exit and leaves the request memory in an invalid state.»

То есть: кончилась память в неудачный момент — longjmp() выпрыгнул из разбора, а структуры остались полуинициализированными и дальше читаются как валидные. Атакующий управляет моментом: размер тела запроса задаёт он, memory_limit — величина известная и предсказуемая.

Вторая, GHSA-6wmf-4rv5-p3rp (CVE-2026-109619), — про мусор, который первый запрос оставляет следующему в том же воркере:

«Some input, configuration, session and error handling operations run before normal initialization of the next request. They inadvertently access stale state not reset by the previous request before initialization has a chance to replace it, causing a use after free.»

Обе с CVSS v4 9.2 Critical, обе исправлены одним коммитом. У mod_php RCE подтверждено на настройках по умолчанию. У FPM подтверждённый путь — session upload progress; про него в advisory сказано, что он «can reach session handling before ordinary session initialization».

Этот механизм включён по умолчанию и работает без session_start() в приложении. Руководство PHP предупреждало о нём задолго до PHP2Shell:

«Upload progress writes the client-supplied file name and field name to the session store while the upload in progress… file names and field names are not validated and may contain PHP code, making any local file inclusion vulnerability trivial to exploit.»

Default у session.upload_progress.enabled — "1", режим — INI_PERDIR. Из этого следует практический вывод, к которому я ещё вернусь: ini_set() эту директиву не меняет вовсе.

Почему авторизация, CSRF и maintenance-mode не защищают

Ни один механизм уровня приложения на этом пути не стоит, потому что тело запроса PHP разбирает до того, как начнёт исполнять ваш код. Проверка сессии, CSRF-токен, middleware, maintenance-режим Laravel, собственная «заглушка на время инцидента» — всё это код, который к моменту разбора ещё не запущен.

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

Первый рефлекс в такой ситуации — «включим правило на WAF и выдохнем». Правило помогает частично (у AWS есть отдельный разбор по WAF), но ровно настолько, насколько полно вы перечислили векторы, — а перечислены они не полностью, о чём ниже.

Где брать исправление: таблица по ветвям

Источник исправления зависит от ветки, и вариантов четыре: релиз, официальный бэкпорт, патч sury, ничего. Я собрал таблицу, потому что без неё каждый разговор начинался с «а для 7.2 вообще что-нибудь есть».

ВеткаСтатусИсточник исправленияЧто в итоге ставится
8.5поддерживаетсярелиз8.5.12
8.4поддерживаетсярелиз8.4.27
8.3поддерживаетсярелиз8.3.36
8.2только security-фиксырелиз8.2.35
8.1EOLветка php-src PHP-8.1-CVE-2026-109618-109619последний релиз 8.1 + бэкпорт, самосборкой
8.0EOLветка php-src, коммит 82f8f31b16a08.0.30 + бэкпорт, самосборкой
7.4EOLветка php-src, коммит 712e60f9edfd7.4.33 + бэкпорт, самосборкой
7.3EOLsury php7.3 7.3.33-35, патчи 0079 + 0107 + 01397.3.33 + патчи
7.2EOLsury php7.2 7.2.34-66, патчи 0105 + 0135 + 01597.2.34 + патчи
7.1EOLsury php7.1 7.1.33-81, патчи 0167 + 0186 + 01887.1.33 + патчи
5.6EOLsury php5.6 5.6.40-105, патчи 0261 + 02635.6.40 + патчи

Три строки тут требуют пояснения.

Официальных бэкпортов ровно три. Я проверил это запросом к API GitHub, а не по чьему-то списку: ветки с суффиксом -CVE-2026-109618-109619 в php-src существуют только для 8.1, 8.0 и 7.4. Для 7.3, 7.2, 7.1, 5.6 таких ветвей нет. Коммит у 7.4-бэкпорта — 712e60f9edfd, у 8.0 — 82f8f31b16a0. Автор обоих — Arnaud Le Blanc, co-author — Ilija Tovilo, сообщение одинаковое:

«Avoid state corruption via bailout. Minimal mitigation for EOL version.»

Диффы почти совпадают: 17 файлов в каждом, +240/−59 у 7.4 и +229/−59 у 8.0, и единственный файл, где они расходятся, — main/rfc1867.c (32 добавленных строки против 21). Оба датированы 18 октября, то есть за три дня до публикации advisory.

Бэкпорт — это не релиз. Ветки в php-src и всё: ни тарболов на php.net, ни образов на Docker Hub, ни пакетов. Накладывать и собирать придётся самостоятельно.

Патчи sury — это бэкпорт того же коммита. Я скачал debian.tar.xz из пула sury и посмотрел заголовок патча 0139-Avoid-state-corruption-via-bailout.patch. Там стоит From: Arnaud Le Blanc <arnaud.lb@gmail.com> и Date: Sun, 18 Oct 2026 20:09:54 +0200 — та же секунда, что у официального коммита 7.4. И пояснение:

«Backported from the PHP 7.4 version: … “|| 1” instead of “|| true” in the session upload progress callback (no stdbool.h).»

То есть для старых ветвей это не «какой-то сторонний патч», а работа того же автора. В changelog пакета CVE прописаны явно: php7.2 (7.2.34-66) … * Backported from 8.2.35 + [CVE-2026-109618, CVE-2026-109619]: Avoid state corruption via bailout.

Сканеры про EOL-ветки молчат. В advisory затронутые версии перечислены как «< 8.2.35, < 8.3.36, < 8.4.27, < 8.5.12» — только поддерживаемые ветки. Инструмент, который сверяется с этим списком буквально, про ваш 7.2 ничего не скажет. Это надо держать в голове, когда отчёт сканера выглядит чистым.

Как найти в парке весь PHP, когда реестр врёт

Сверять надо не реестр, а машины: реестр показывает, что когда-то задумывалось, а не что сейчас запущено. Возьмём модель — парк из ста машин, какой складывается у подрядчика за десять лет: физические серверы, виртуалки, LXC под Proxmox, пара наследных контейнеров OpenVZ, что-то в облаке. Пусть PHP стоит примерно на трети из них, из этой трети четыре пятых смотрят в интернет через nginx или Apache, а до десятка машин окажутся без рабочего доступа. Абсолютные величины тут произвольны, несущие — соотношения: машин без PHP больше, чем с ним, а внутри «с PHP» почти всё опубликовано наружу.

Ожидаемый результат первого прохода — ноль исправленных установок. Это не признак запущенности: релизы вышли меньше суток назад, а половина ветвей их никогда и не получит.

Способов установки в такой модели будет штук восемь, и каждый проверяется по-своему:

  1. Официальные образы php:* в Docker Compose — версия в PHP_VERSION метаданных образа.
  2. Образ wordpress:* — там mod_php внутри, и ветка PHP задана тегом образа.
  3. FrankenPHP — своя нумерация, PHP внутри.
  4. Пакеты дистрибутива (php7.2-fpm из Ubuntu) — исправленность только по changelog, не по версии.
  5. PPA ondrej/sury — то же самое.
  6. remi и webtatic на EL — репозитории заморожены, но пакеты стоят.
  7. Alpine-пакеты php7-fpm — своя нумерация.
  8. Сборки мимо пакетного менеджера — находятся только поиском бинарников.

Минимальный набор команд, который я прогонял на каждой машине. Все только читают:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# процессы, которые уже запущены — самый честный источник
ps -eo pid,comm,args | grep -E 'php|fpm|httpd|apache' | grep -v grep
# что установлено пакетами
dpkg -l 2>/dev/null | grep -E '^ii +(php|libapache2-mod-php)' || rpm -qa 'php*'
# mod_php внутри Apache
apache2ctl -M 2>/dev/null | grep -i php || httpd -M 2>/dev/null | grep -i php
# бинарники мимо пакетов
ls -l /usr/local/bin/php /usr/local/sbin/php-fpm 2>/dev/null
# контейнеры и их образы
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}'
# версия PHP в образе — без запуска кода внутри контейнера
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' <контейнер> | grep ^PHP_VERSION
# какой образ реально крутится под контейнером
docker inspect -f '{{.Image}}' <контейнер>

Последние две команды — не про удобство. docker exec на нагруженном или подвисшем контейнере висит сам, и обход парка останавливается на одной машине. PHP_VERSION из Config.Env читается демоном Docker, внутрь контейнера не заходит и отвечает всегда. Если всё-таки нужен exec — только через timeout 20.

Две грабли, на которых встал обход парка

Обе — не про PHP, а про инструменты обхода, и обе съели время до того, как я дошёл до собственно уязвимости.

~/.ssh/config врал про доступ. Половина машин «пускала по ключу» — а пускала она по IdentityFile из конфига, который подхватывал не тот ключ, плюс агент подсовывал ещё три. На части хостов это превращалось в «доступ есть», хотя своего ключа там не было, на части — наоборот, в отказ при живом ключе. Пока не начал ходить с чистым окружением, картина доступности менялась от запуска к запуску:

1
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/<ключ> <пользователь>@<хост>

Урок дешёвый, но до него я успел дважды записать одну и ту же машину в «нет доступа» и в «есть доступ».

Номер версии не говорит об исправленности. Пакеты дистрибутивов патчатся бэкпортом без смены апстрим-версии. Строки вида 7.2.24-0ubuntu0.18.04.17+esm14 или 7.4.33-1+deb11u14 означают «апстрим 7.2.24/7.4.33 плюс неизвестное число бэкпортов», и по ним нельзя сказать ни что пакет уязвим, ни что он исправлен. Решает changelog:

1
2
zcat /usr/share/doc/php8.2-cli/changelog.Debian.gz | grep -m2 CVE-2026-10961
rpm -q --changelog php72-php-common | grep -m2 CVE-2026-10961

Сюда же — расхождения реестра и реальности. На нескольких машинах модели реестр обещал PHP, которого там нет, а на нескольких других — наоборот. Любая цифра «сколько у нас уязвимых» до полного обхода остаётся гаданием.

Какие смягчения работают, какие бесполезны, а какие вредят

Единственное полное исправление — патч; всё остальное в advisory названо частичным, а два популярных совета вредны. Таблицу ниже я собирал как рабочую памятку, когда надо было решить, что ставить прямо сейчас, пока образы собираются.

СмягчениеЭффектПочему
session.upload_progress.enabled = 0 в php.ini или конфиге пулазакрывает подтверждённый путь на FPMмеханизм работает без session_start(); режим директивы INI_PERDIR, ini_set() её не меняет
memory_limit = -1частичное, рекомендовано advisoryубирает предсказуемый лимит; при сотнях воркеров рискует OOM всей машины
post_max_size поменьше + лимит тела на фронтечастичное, рекомендовано advisoryуменьшает управляемость аллокаций, не закрывает путь
Блокировать multipart/* на фронтечастичноеостаётся urlencoded-вариант: одно поле с очень длинным именем, порядка нескольких мегабайт
file_uploads = Offне помогаеттело разбирает тот же код в main/rfc1867.c; отказ принимать файлы разборщик не выключает
Лимит памяти cgroup/контейнеранеполное, так и помечено в advisoryограничивает последствия, не путь
RLIMIT_AS, RLIMIT_DATA, ulimit -v, systemd LimitAS=вредитadvisory прямо: «Do not use RLIMIT_AS or RLIMIT_DATA» — они вызывают уязвимый longjmp()-путь
Перевод mod_php → FPMне фикс109618 есть во всех SAPI, 109619 на FPM тоже
maintenance-mode, запрет логина, выключенные загрузкине помогаеттело разбирается до кода приложения

Про RLIMIT_AS стоит сказать отдельно, потому что это самый естественный неверный шаг. Человек читает «проблема при нехватке памяти» и идёт ограничивать память процессу штатным способом — через ulimit -v или LimitAS= в unit-файле. Получается прямо противоположное: аллокация начинает падать там, где раньше не падала, и уязвимый путь срабатывает чаще.

Я поставил session.upload_progress.enabled = 0 везде, где мог, в первый же вечер — до того, как собрал хоть один образ. Это одна строка в ini, подтверждённый вектор на FPM она закрывает, а ломает ровно одну вещь: прогресс-бары загрузки, если приложение их читает из $_SESSION. Ни одно приложение в модели их не читало.

Директива INI_PERDIR означает, что ini_set() в коде её не изменит — вернёт неудачу молча. Ставить надо в php.ini, в конфиге пула FPM или в конфиге веб-сервера. На .user.ini я бы тут не рассчитывал: смысл всей уязвимости в том, что тело разбирается раньше кода приложения.

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

Патч меняет три наблюдаемые вещи, и первая из них видна в продакшене сразу. Это одинаково для официальных релизов, официальных бэкпортов и портов sury — потому что код один.

  1. При превышении memory_limit воркер уходит через _exit(1). Shutdown-функции не вызываются. Значит register_shutdown_function() больше не отработает на OOM, сообщение уйдёт в stderr, а nginx отдаст 502 вместо 500. Если у вас на shutdown-хуке висит запись в лог ошибок или отправка в Sentry — вы перестанете видеть именно OOM-ошибки.
  2. Session upload progress выключен в коде. В исходниках это выглядит как if (!PS(rfc1867_enabled) || true) — то есть ветка отключена безусловно, независимо от ini. Приложения с прогресс-барами на $_SESSION перестанут их получать.
  3. Появляется (или начинает действовать) max_multipart_body_parts. Директива не новая — её завели в 8.0.28 под CVE-2023-0662, — но в старые ветки она приезжает вместе с патчем. Дефолт -1 означает max_input_vars + max_file_uploads. Форма с огромным числом полей обрежется с warning. Любопытно, что в списке директив руководства её до сих пор нет — искать придётся в NEWS.

Первый пункт — единственный, из-за которого у меня был разговор с владельцами проектов. Переход 500 → 502 для внешнего мониторинга выглядит как новая поломка, и лучше предупредить заранее, чем объяснять потом.

Почему «патч лёг» ещё не значит «патч работает»

Проверять надо три разных вещи, и каждая ловится своим способом: что патч приложился, что он собрался и что он действительно работает в собранном бинарнике. Это главный вывод всей серии, и он стоил мне больше времени, чем сами сборки.

Третий пункт — ловушка, на которую попадётся любой сканер. Номер версии патч не меняет: патченый 7.4.33 и уязвимый 7.4.33 отвечают на php -v одинаково. А php -i на патченой сборке честно покажет session.upload_progress.enabled => On, потому что ini-значение осталось прежним, а выключение сделано в коде. Инвентаризация по версии и ini даст два ложных вывода разом: патченую сборку назовёт уязвимой, а Off в ini примет за доказательство патча.

Остаётся проверка поведением. Патч меняет способ выхода при OOM, и это видно снаружи в двух признаках — код выхода и вызов shutdown-функции:

1
2
3
4
5
6
T='register_shutdown_function(function(){echo "SHUTDOWN-CALLED\n";}); $a=str_repeat("x", 20*1024*1024);'
php -d memory_limit=8M -r "$T"; echo "exit=$?"
# или: docker run --rm <образ> php -d memory_limit=8M -r "$T"; echo "exit=$?"

# патченая сборка:   Fatal error: Allowed memory size ... , SHUTDOWN-CALLED нет, exit=1
# непатченая сборка: Fatal error: Allowed memory size ... , SHUTDOWN-CALLED есть, exit=255

Этот тест я прогонял на каждом собранном образе и на каждом собранном RPM, по всем ветвям, и расхождений с ожидаемым не было ни разу. Он же годится как приёмочная проверка в CI: один docker run, сравнение кода выхода.

Для некоторых ветвей есть ещё статический маркер — строка, которой в непатченом дереве нет. Выбирать его надо осторожно: первая метка, которую я взял, присутствовала в исходниках и до патча, так что assert в Dockerfile проходил всегда и не проверял ничего. Правило простое: grep -c по ванильному дереву должен дать 0.

Что искать в логах: ретроспектива начинается не с 21 октября

Смотреть надо с 17 октября — попытку эксплуатации зафиксировали за четыре дня до публикации advisory. Amazon Threat Intelligence сообщила, что видела её в своей honeypot-сети MadPot 17 октября, и рекомендует начинать разбор именно оттуда, а не с даты раскрытия.

Признаки, по которым я проходил логи:

Чтобы третий пункт вообще можно было искать, в access.log nginx нужны размер и тип тела — по умолчанию их там нет:

1
2
3
log_format hunt '$remote_addr - [$time_local] "$request" $status '
                '$body_bytes_sent rl=$request_length ct="$content_type" '
                '"$http_user_agent"';

На машинах, где я прошёл ретроспективу, ничего из списка не нашлось. По остальным ретроспектива не проводилась, и честный вывод тут — «не искали», а не «чисто». PoC я не запускал: доказывать работоспособность эксплойта на живых сайтах заказчика незачем, а проверка патча делается тестом поведения.

Итог

Порядок действий, который я бы повторил: сначала одна строка session.upload_progress.enabled = 0 во все php.ini и конфиги пулов — это дешёво и закрывает подтверждённый вектор на FPM. Потом полный обход парка по процессам и метаданным образов, а не по реестру и не по php -v. Потом поветочная таблица источников исправления и сборка. И приёмка каждой сборки тестом поведения, а не версией.

Чего делать не надо, даже если очень хочется закрыть вопрос за вечер: ограничивать память через RLIMIT_AS, ulimit -v или LimitAS=. Advisory говорит про это отдельным предупреждением, и оно не формальное — такое «смягчение» увеличивает вероятность ровно того сбоя, через который уязвимость эксплуатируется.

И общее наблюдение, которое не про PHP. В парке, собиравшемся десять лет, «обновить PHP» — это не одна операция, а восемь разных проектов с разными источниками исправления: от apt install --only-upgrade до сборки RPM в контейнере CentOS 6. Единственное, что их объединяет, — проверка результата. Она одна для всех и занимает один docker run.

Разборы по ветвям: поддерживаемые 8.x и ловушки Docker-тегов, 7.1–7.4 после EOL, 5.6.

Первоисточники: GHSA-w8hw-w9fx-j79w и GHSA-6wmf-4rv5-p3rp (advisory PHP), ChangeLog PHP 8, коммиты бэкпортов 712e60f9edfd (7.4) и 82f8f31b16a0 (8.0) в php-src, документация по session.upload_progress, пул пакетов sury, пост Amazon Threat Intelligence про MadPot.

#PHP #CVE #RCE #аудит #Docker

<< Previous Post

|

Next Post >>