PHP2Shell (CVE-2026-109618 и CVE-2026-109619): где взять исправление для каждой ветки и какие смягчения вредят
Короткий ответ: обновиться до 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)
- Решение: патч-релиз своей ветки — 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. Advisory: «Updating to a PHP release containing the patch to this vulnerability is the only complete mitigation».
- Для EOL-ветвей: официальный минимальный бэкпорт есть только для 8.1, 8.0 и 7.4 — ветками в php-src. Для 7.3, 7.2, 7.1 и 5.6 — патчи из пакетов sury.
- Авторизация не спасает: тело запроса разбирается до кода приложения. CSRF-токены, maintenance-mode и запрет логина не стоят на этом пути.
- Грабли:
RLIMIT_AS/RLIMIT_DATA/ulimit -v/LimitAS=— не смягчение, а ускоритель: они вызывают тот самый OOM-сбой, на котором строится эксплуатация. - Грабли:
file_uploads=Offне отключает разборщик multipart, а блокировкаmultipart/*на фронте не закрывает urlencoded-вариант. - Грабли: «патч лёг» ≠ «патч собрался» ≠ «патч работает». Номер версии при бэкпорте не меняется, а
php -iпродолжает показыватьsession.upload_progress.enabled => Onдаже на патченой сборке. - Искать в логах с 17 октября, а не с 21-го: попытку эксплуатации зафиксировали за четыре дня до раскрытия.
Что именно сломано: две уязвимости, один патч
Обе уязвимости — про состояние памяти запроса, которое 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.1 | EOL | ветка php-src PHP-8.1-CVE-2026-109618-109619 | последний релиз 8.1 + бэкпорт, самосборкой |
| 8.0 | EOL | ветка php-src, коммит 82f8f31b16a0 | 8.0.30 + бэкпорт, самосборкой |
| 7.4 | EOL | ветка php-src, коммит 712e60f9edfd | 7.4.33 + бэкпорт, самосборкой |
| 7.3 | EOL | sury php7.3 7.3.33-35, патчи 0079 + 0107 + 0139 | 7.3.33 + патчи |
| 7.2 | EOL | sury php7.2 7.2.34-66, патчи 0105 + 0135 + 0159 | 7.2.34 + патчи |
| 7.1 | EOL | sury php7.1 7.1.33-81, патчи 0167 + 0186 + 0188 | 7.1.33 + патчи |
| 5.6 | EOL | sury php5.6 5.6.40-105, патчи 0261 + 0263 | 5.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» почти всё опубликовано наружу.
Ожидаемый результат первого прохода — ноль исправленных установок. Это не признак запущенности: релизы вышли меньше суток назад, а половина ветвей их никогда и не получит.
Способов установки в такой модели будет штук восемь, и каждый проверяется по-своему:
- Официальные образы
php:*в Docker Compose — версия вPHP_VERSIONметаданных образа. - Образ
wordpress:*— там mod_php внутри, и ветка PHP задана тегом образа. - FrankenPHP — своя нумерация, PHP внутри.
- Пакеты дистрибутива (
php7.2-fpmиз Ubuntu) — исправленность только по changelog, не по версии. - PPA ondrej/sury — то же самое.
- remi и webtatic на EL — репозитории заморожены, но пакеты стоят.
- Alpine-пакеты
php7-fpm— своя нумерация. - Сборки мимо пакетного менеджера — находятся только поиском бинарников.
Минимальный набор команд, который я прогонял на каждой машине. Все только читают:
| |
Последние две команды — не про удобство. docker exec на нагруженном или подвисшем контейнере висит сам, и обход парка останавливается на одной машине. PHP_VERSION из Config.Env читается демоном Docker, внутрь контейнера не заходит и отвечает всегда. Если всё-таки нужен exec — только через timeout 20.
Две грабли, на которых встал обход парка
Обе — не про PHP, а про инструменты обхода, и обе съели время до того, как я дошёл до собственно уязвимости.
~/.ssh/config врал про доступ. Половина машин «пускала по ключу» — а пускала она по IdentityFile из конфига, который подхватывал не тот ключ, плюс агент подсовывал ещё три. На части хостов это превращалось в «доступ есть», хотя своего ключа там не было, на части — наоборот, в отказ при живом ключе. Пока не начал ходить с чистым окружением, картина доступности менялась от запуска к запуску:
| |
Урок дешёвый, но до него я успел дважды записать одну и ту же машину в «нет доступа» и в «есть доступ».
Номер версии не говорит об исправленности. Пакеты дистрибутивов патчатся бэкпортом без смены апстрим-версии. Строки вида 7.2.24-0ubuntu0.18.04.17+esm14 или 7.4.33-1+deb11u14 означают «апстрим 7.2.24/7.4.33 плюс неизвестное число бэкпортов», и по ним нельзя сказать ни что пакет уязвим, ни что он исправлен. Решает changelog:
| |
Сюда же — расхождения реестра и реальности. На нескольких машинах модели реестр обещал 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 — потому что код один.
- При превышении
memory_limitворкер уходит через_exit(1). Shutdown-функции не вызываются. Значитregister_shutdown_function()больше не отработает на OOM, сообщение уйдёт в stderr, а nginx отдаст 502 вместо 500. Если у вас на shutdown-хуке висит запись в лог ошибок или отправка в Sentry — вы перестанете видеть именно OOM-ошибки. - Session upload progress выключен в коде. В исходниках это выглядит как
if (!PS(rfc1867_enabled) || true)— то есть ветка отключена безусловно, независимо от ini. Приложения с прогресс-барами на$_SESSIONперестанут их получать. - Появляется (или начинает действовать)
max_multipart_body_parts. Директива не новая — её завели в 8.0.28 под CVE-2023-0662, — но в старые ветки она приезжает вместе с патчем. Дефолт-1означаетmax_input_vars + max_file_uploads. Форма с огромным числом полей обрежется с warning. Любопытно, что в списке директив руководства её до сих пор нет — искать придётся в NEWS.
Первый пункт — единственный, из-за которого у меня был разговор с владельцами проектов. Переход 500 → 502 для внешнего мониторинга выглядит как новая поломка, и лучше предупредить заранее, чем объяснять потом.
Почему «патч лёг» ещё не значит «патч работает»
Проверять надо три разных вещи, и каждая ловится своим способом: что патч приложился, что он собрался и что он действительно работает в собранном бинарнике. Это главный вывод всей серии, и он стоил мне больше времени, чем сами сборки.
- Лёг.
patch --dry-runчистый. Проверяет только текст патча. - Собрался. Компиляция прошла.
--dry-runпро зависимости патча ничего не знает — бэкпорт с новой ветки может использовать макрос, которого в старой нет. Как это выглядит на 7.3 — отдельный разбор. - Работает. Вот это по версии и ini не определяется вообще.
Третий пункт — ловушка, на которую попадётся любой сканер. Номер версии патч не меняет: патченый 7.4.33 и уязвимый 7.4.33 отвечают на php -v одинаково. А php -i на патченой сборке честно покажет session.upload_progress.enabled => On, потому что ini-значение осталось прежним, а выключение сделано в коде. Инвентаризация по версии и ini даст два ложных вывода разом: патченую сборку назовёт уязвимой, а Off в ini примет за доказательство патча.
Остаётся проверка поведением. Патч меняет способ выхода при OOM, и это видно снаружи в двух признаках — код выхода и вызов shutdown-функции:
| |
Этот тест я прогонял на каждом собранном образе и на каждом собранном RPM, по всем ветвям, и расхождений с ожидаемым не было ни разу. Он же годится как приёмочная проверка в CI: один docker run, сравнение кода выхода.
Для некоторых ветвей есть ещё статический маркер — строка, которой в непатченом дереве нет. Выбирать его надо осторожно: первая метка, которую я взял, присутствовала в исходниках и до патча, так что assert в Dockerfile проходил всегда и не проверял ничего. Правило простое: grep -c по ванильному дереву должен дать 0.
Что искать в логах: ретроспектива начинается не с 21 октября
Смотреть надо с 17 октября — попытку эксплуатации зафиксировали за четыре дня до публикации advisory. Amazon Threat Intelligence сообщила, что видела её в своей honeypot-сети MadPot 17 октября, и рекомендует начинать разбор именно оттуда, а не с даты раскрытия.
Признаки, по которым я проходил логи:
Allowed memory size of … exhausted … in Unknown on line 0— OOM до старта скрипта. ИменноUnknown on line 0тут важно: у обычного OOM в приложении будет файл и строка.child … exited on signal 11в логе FPM,AH00052 … Segmentation faultв логе Apache.- Серии крупных POST с одного адреса — десятки и сотни за четверть часа. Эксплуатация требует перебора, одиночным запросом она не делается.
PHP_SESSION_UPLOAD_PROGRESSв теле запроса. Это дефолтное значениеsession.upload_progress.name, и в обычном трафике приложения, не рисующего прогресс-бары, его быть не должно.- Аномально длинный
PHPSESSIDвSet-Cookieответа. - Shell-процессы (
sh -c,curl,wget), порождённые воркерами php-fpm или apache.
Чтобы третий пункт вообще можно было искать, в access.log nginx нужны размер и тип тела — по умолчанию их там нет:
| |
На машинах, где я прошёл ретроспективу, ничего из списка не нашлось. По остальным ретроспектива не проводилась, и честный вывод тут — «не искали», а не «чисто». 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.