Skip to content
PHP2Shell и RoadRunner

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 описывает три способа атаки:

  1. Разбор POST. Память кончается, пока PHP разбирает тело application/x-www-form-urlencoded или multipart/form-data на старте запроса. В хеш-таблице остаётся элемент, который уже учтён, но ключ которого ещё не создан.
  2. session.upload_progress. Часть multipart с файлом запускает сессию прямо во время разбора тела. Fatal error прерывает старт запроса, и сессия не завершается. Её данные указывают на уже освобождённую память.
  3. Декодирование после старта. Fatal error случается в unserialize(), session_decode() или session_start(), когда запрос уже стартовал и выполняется код приложения. Состояние декодера остаётся указывать на освобождённую память. Этот способ зависит от приложения: оно должно декодировать данные, которые контролирует атакующий.

Во всех трёх случаях испорченное состояние само по себе безвредно. Его превращает в выполнение кода следующий запрос, который получает тот же процесс. Атака работает, только если процесс PHP переживает fatal error. Поэтому почти всё решает то, как сервер запускает PHP и передаёт ему запрос.

Как серверы общаются с PHP ​

СерверКак запускает PHPКто разбирает тело запросаПереживает ли процесс fatal errorИтог для PHP2Shell
Apache + mod_phpМодуль внутри ApachePHPДаУязвим, все три способа
PHP-FPMОтдельные процессы, FastCGIPHPПри сбое на старте запроса — нет, при сбое в коде приложения — даУязвим, способ 3
FrankenPHPВстроен в Go-серверPHPДа, как mod_phpВ списке затронутых
SwooleРасширение PHPSwooleНетНе затронут
RoadRunnerОтдельные процессы PHPGoНет, 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 без боли благодаря готовым мостам:

А если вы начинаете новый проект, посмотрите на наш фреймворк 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.

Обновлено: