21 октября PHP выпустил внеплановые обновления 8.5.12, 8.4.27, 8.3.36 и 8.2.35. Они закрывают две критические уязвимости, которые авторы назвали PHP2Shell: CVE-2026-109618 и CVE-2026-109619. Обе дают удалённое выполнение кода без авторизации, оценка CVSS v4 — 9.2. Исследователи показали атаку на WordPress, Drupal, Joomla, Magento, Nextcloud, Laravel, Symfony и другие приложения.
Уязвимость не теоретическая
Многие сочли PHP2Shell слишком сложной для реальной атаки. Один из пользователей Hacker News решил это проверить и запустил челлендж stealmybitcoin.com: на сервере с непропатченным PHP лежал кошелёк с настоящими биткоинами, и забрать их мог любой, кто взломает сервер. Кошелёк с 0.025 BTC (около $2000) опустошили 25 октября. Победитель опубликовал разбор: он собрал цепочку от переполнения memory_limit при разборе multipart до выполнения команды в выжившем воркере PHP. Как проходил челлендж, подробно разобрал LiveOverflow:
Простой атаку при этом не назовёшь. Она привязана к конкретной сборке PHP, требует выиграть гонку между воркерами и узнать адреса в памяти процесса, которые система при каждом запуске выбирает случайно. На тестовом стенде у автора разбора она сработала в 7 запусках из 10. На Hacker News спорили и о том, насколько реалистичен был стенд: PHP там работал под встроенным dev-сервером php -S. Сработает ли атака на нагруженном продакшене — вопрос открытый. Но невозможной её уже не назовёшь.
Самое интересное в этих уязвимостях — где они живут. Это не код фреймворков и не свежие фичи языка. Это серверная часть самого PHP: разбор тела POST-запроса и загрузки файлов. Файл main/rfc1867.c, который разбирает multipart/form-data, появился в репозитории в 1999 году, main/php_variables.c — в 2000-м.
Обновите PHP, какой бы сервер вы ни использовали. Ниже речь о том, почему одни схемы запуска PHP пострадали сильнее других. Это не повод откладывать обновление.
Как устроена атака
Общая идея у всех вариантов атаки одна: атакующий заставляет PHP упасть с fatal error в момент, когда структуры в памяти заполнены лишь частично. Чаще всего для этого запрос съедает больше memory_limit. Подходят глубоко вложенные поля формы в multipart или тело без объявленной длины: chunked в HTTP/1.1 или HTTP/2 без Content-Length. Проверка post_max_size смотрит на объявленную длину и пропускает такие данные.
Уязвимости нашла исследовательская команда Calif вместе с OpenAI и Wiz. Calif описывает три способа атаки:
- Разбор POST. Память кончается, пока PHP разбирает тело
application/x-www-form-urlencodedилиmultipart/form-dataна старте запроса. В хеш-таблице остаётся элемент, который уже учтён, но ключ которого ещё не создан. session.upload_progress. Частьmultipartс файлом запускает сессию прямо во время разбора тела. Fatal error прерывает старт запроса, и сессия не завершается. Её данные указывают на уже освобождённую память.- Декодирование после старта. Fatal error случается в
unserialize(),session_decode()илиsession_start(), когда запрос уже стартовал и выполняется код приложения. Состояние декодера остаётся указывать на освобождённую память. Этот способ зависит от приложения: оно должно декодировать данные, которые контролирует атакующий.
Во всех трёх случаях испорченное состояние само по себе безвредно. Его превращает в выполнение кода следующий запрос, который получает тот же процесс. Атака работает, только если процесс PHP переживает fatal error. Поэтому почти всё решает то, как сервер запускает PHP и передаёт ему запрос.
Как серверы общаются с PHP
| Сервер | Как запускает PHP | Кто разбирает тело запроса | Переживает ли процесс fatal error | Итог для PHP2Shell |
|---|---|---|---|---|
| Apache + mod_php | Модуль внутри Apache | PHP | Да | Уязвим, все три способа |
| PHP-FPM | Отдельные процессы, FastCGI | PHP | При сбое на старте запроса — нет, при сбое в коде приложения — да | Уязвим, способ 3 |
| FrankenPHP | Встроен в Go-сервер | PHP | Да, как mod_php | В списке затронутых |
| Swoole | Расширение PHP | Swoole | Нет | Не затронут |
| RoadRunner | Отдельные процессы PHP | Go | Нет, RoadRunner запускает новый процесс | Не затронут |
| Rapira [Classic, Worker] | Встроен в Rust-сервер | PHP | Да, перезапускается только приложение | Те же условия, что у mod_php |
| Rapira [Dispatcher] | Встроен в Rust-сервер | Rust | Да, перезапускается только приложение | Способы 1 и 2 не срабатывают |
mod_php пострадал сильнее всех: Calif показывает RCE на произвольном PHP-скрипте с настройками по умолчанию.
PHP-FPM завершает воркер, если старт запроса провалился. Поэтому способы 1 и 2, которые ломают старт запроса, на FPM не работают. Способ 3 работает: fatal error в декодере случается уже после старта, и воркер остаётся жив. Демонстрация Calif как раз использует nginx и PHP-FPM.
FrankenPHP встраивает PHP как отдельный SAPI и вызывает штатный php_request_startup(), так что тело запроса разбирает сам PHP. PHP включил FrankenPHP в список затронутых серверов. Advisory отмечает, что FrankenPHP вызывает завершение запроса даже после неудачного старта, как mod_php. FrankenPHP выпустил v1.13.2 с исправленным PHP в тот же день.
Swoole разбирает HTTP и multipart своим кодом и не поддерживает session.upload_progress. Мейнтейнеры Swoole заявили, что эти CVE на Swoole не влияют.
RoadRunner тоже разбирает HTTP за пределами PHP, а воркер после fatal error завершается. Ни один из трёх способов атаки здесь не срабатывает. Почему — разберём подробнее.
Как с PHP общается RoadRunner
RoadRunner не встраивает PHP в себя и не использует FastCGI. Воркер — это обычный процесс php worker.php, который работает в CLI SAPI. RoadRunner и воркер обмениваются фреймами протокола Goridge через STDIN/STDOUT или сокет. Каждый фрейм — это заголовок с длиной и контрольной суммой, за которым идут данные.
HTTP-запрос целиком обрабатывает http-плагин RoadRunner. Он принимает соединение, проверяет max_request_size, разбирает application/x-www-form-urlencoded и multipart/form-data и сохраняет загруженные файлы во временный каталог. PHP получает готовый DTO: контекст запроса (метод, URI, заголовки, cookies, разобранные поля, список загрузок) и тело. PHP-пакет roadrunner/http собирает из него PSR-7 запрос.
Что это значит для PHP2Shell:
- Способ 1:
rfc1867_post_handler()иphp_std_post_handler()не видят HTTP-запросов. CLI-воркер стартует один раз и без тела запроса. - Способ 2:
session.upload_progressне выполняется. - Способ 3: fatal error завершает CLI-скрипт, а вместе с ним и процесс. RoadRunner видит выход воркера и запускает новый. Испорченное состояние не доживает до следующего запроса.
Двадцатилетний код разбора запросов в PHP в этой схеме просто не запускается. Пайпы появились в Unix ещё в 1973 году, и с тех пор сломать их так никто и не смог. Между RoadRunner и PHP нет общей памяти, нет общих потоков и нет хитрых хуков в движок — только байты в трубе. Если PHP-процесс падает, RoadRunner просто запускает новый, и следующий запрос уже попадает в чистый процесс.
Мы и раньше говорили, что по надёжности RoadRunner не уступает PHP-FPM. История с PHP2Shell показала, что по безопасности он даже выигрывает: там, где FPM пришлось срочно обновлять, RoadRunner просто не запускает уязвимый код. И это явно ещё не конец истории.
Обновить PHP всё равно стоит: исправление меняет поведение PHP при нехватке памяти.
Неумирающее приложение
У этой архитектуры есть цена: приложение должно уметь работать в долгоживущем процессе. Воркер загружает фреймворк один раз и потом обрабатывает запросы в цикле. Статические свойства, синглтоны и глобальное состояние переживают запрос, поэтому код не должен хранить в них данные запроса.
WordPress так работать не умеет. А вот приложения на Symfony, Laravel и Yii3, если они не используют статику бездумно, скорее всего перейдут на RoadRunner без боли благодаря готовым мостам:
- Symfony — Symfony Runtime или baldinof/roadrunner-bundle;
- Laravel — Laravel Octane или spiral/roadrunner-laravel;
- Yii3 — yiisoft/yii-runner-roadrunner.
А если вы начинаете новый проект, посмотрите на наш фреймворк Spiral. Он с самого начала создавался для RoadRunner и работает в долгоживущем процессе без мостов и компромиссов. Spiral из коробки использует возможности RoadRunner по максимуму: HTTP, gRPC, очереди, key-value хранилища, WebSocket через Centrifugo, Temporal и метрики.
Но у меня WordPress!
Наш новый проект Rapira — следующий шаг после RoadRunner. Мы пишем его на Rust вдумчиво и не отдаём всё на откуп AI. Архитектура самой Rapira и её PHP-контрактов, через которые приложение общается с сервером, проходит несколько циклов ревью и бенчмарков, прежде чем попасть в релиз. Rapira запускает PHP в одном из трёх режимов: Classic, Worker и Dispatcher.
Classic подходит для WordPress и старых кодовых баз. Скрипт выполняется заново на каждый запрос, как под php-fpm, и приложение не нужно переписывать. Rapira поддерживает PHP 8.4 и 8.5, так что мигрировать на PHP 8.4+ всё же придётся.
Worker держит приложение в памяти и заполняет суперглобальные переменные на каждый запрос. Это модель worker-режима FrankenPHP, но без CGO и без потоков ZTS: каждый воркер — отдельный процесс с одним NTS-интерпретатором.
Dispatcher — самый производительный режим. Он работает как RoadRunner: приложение получает объект запроса, а не суперглобальные переменные, и Rust сам разбирает multipart. Но в отличие от RoadRunner, здесь нет прослойки в виде Goridge и сериализации, а все воркеры используют общий OPcache мастер-процесса.
Бенчмарки Yii3 на разных серверах, включая все три режима Rapira, доступны на yii3-benchmarks.github.io.
PHP2Shell и Rapira
Часть опасных запросов Rapira отбрасывает ещё до запуска PHP. Rapira буферизует тело целиком и отвечает 413, если оно больше http.max_body_size_mb. Поэтому обход post_max_size через chunked-тела в HTTP/1.1 и через HTTP/2 без Content-Length не сработает.
Однако в режимах Classic и Worker тело запроса разбирает сам PHP. Это оставляет вектор атаки через вложенные поля multipart: такое тело укладывается в лимит размера, но при разборе занимает больше memory_limit.
Стремитесь писать приложение так, чтобы оно работало в режиме Dispatcher. Так вы повысите не только производительность, но и надёжность и безопасность приложения. Попробовать этот подход можно уже сейчас на суперстабильном RoadRunner.
В крайнем случае есть радикальное средство: pm.max_requests = 1 в PHP-FPM или max_jobs: 1 в пуле RoadRunner, тогда каждый запрос получит свежий процесс — прямо как CGI в 1997 году. Хоть это и написано на правах шутки, однако с безопасностью не шутят.
Итог
PHP2Shell — не последняя уязвимость такого рода. Код запуска запроса, разбора POST и загрузки файлов написан на C больше двадцати лет назад, и в серверной части PHP ещё не раз найдут ошибки. Чем меньше этого кода стоит между сетью и вашим приложением, тем меньше поверхность атаки. RoadRunner и режим Dispatcher в Rapira разбирают HTTP за пределами PHP. А для приложений, которым нужен классический PHP, главное средство защиты — своевременно обновлять PHP.
