PHP 5.6 и PHP2Shell: патч sury поверх Dockerfile, который удалили из репозитория в 2019 году
Короткий ответ: PHP 5.6 уязвим к PHP2Shell, и исправление для него существует — патчи из пакета sury php5.6 5.6.40-105: 0261 (сам фикс, 13 файлов, +114/−75), 0262 и 0263 (лимит max_multipart_body_parts из бэкпорта CVE-2023-0662). Все три ложатся на ванильный php-5.6.40 с --fuzz=0, только со смещениями строк. Образ собирается поверх Dockerfile из docker-library, где ветка 5.6 ещё существовала: коммит 783878384a8f от 11 января 2019, путь 5.6/stretch/fpm. Брать последнее состояние дерева (ea377551f752) не надо — оно добавляет chmod 777 /var/www/html, которого в опубликованном образе не было.
PHP2Shell — две RCE с CVSS 9.2, раскрытые 21 октября 2026: тело HTTP-запроса разбирается до кода приложения, поэтому авторизация и CSRF на этом пути не стоят. Затронуты версии от 5.4 — то есть 5.6 попадает в диапазон целиком.
Эта статья — про самый неудобный край парка: контейнер с PHP 5.6, у которого нет ни поставщика, ни Dockerfile в репозитории, ни Dockerfile в вашем git. Общий разбор уязвимости и смягчений — в хабе; ветки 7.1–7.4 с их патч-серией и RPM — отдельно; ловушки Docker-тегов на поддерживаемых 8.x — там же, в третьей статье серии.
Коротко (TL;DR)
- 5.6 уязвим, и это видно в исходниках.
zend_mm_safe_error()вZend/zend_alloc.c:1743ванильного 5.6.40 уходит вzend_error_noreturn(E_ERROR, …)внутриzend_try— то есть через bailout. Колбэкphp_session_rfc1867_callbackрегистрируется,session.upload_progress.enabledпо умолчанию"1". - Патчи есть: sury
php5.6 5.6.40-105,0261+0262+0263. Автор0261— Arnaud Le Blanc, датаSun, 18 Oct 2026 20:09:54 +0200: та же секунда, что у официального бэкпорта для 7.4. - Применяются чисто. Я прогнал их по распакованному
php-5.6.40.tar.xz: 13 + 1 + 2 файла,--fuzz=0, только смещения (−11, −15, −21, −27 в0261; +12 в0263). - Четвёртый патч серии,
0264, не ложится — он правитNEWS, которого в ванильном дереве уже коснулись патчи sury. Это не проблема:1 out of 1 hunk FAILEDна changelog пропускается. - У 5.6 нет скрытой зависимости. На 7.x фикс тянет за собой патч
ZSTR_MAX_LEN; в 5.6zend_stringне существует, порт переписан без макроса, иgrep -c ZSTR_MAX_LENпо0261даёт 0. - Dockerfile брать из
783878384a8f, а не изea377551f752: разница между ними — ровно блок сchmod 777 /var/www/html, добавленный 31 января 2019, за день до удаления ветки. - В Dockerfile 2019 года три мёртвые ссылки:
secure.php.net/get/…/from/this/mirror→ 404,deb.debian.org/debian stretch→ 404,ha.pool.sks-keyservers.net— сеть закрыта. - Проверка:
grep -c array_init_singleton Zend/zend_API.h— 0 до патча, 2 после; в собранном PHP появляется директиваmax_multipart_body_parts, которой в 5.6 не было никогда.
Почему «5.6 слишком старая, её это не касается» — неверно
Уязвимый код в 5.6 есть, и проверяется это чтением исходников, а не доверием к диапазону версий в advisory. Я распаковал php-5.6.40.tar.xz и посмотрел три места.
Первое — выход по исчерпанию памяти:
| |
zend_error_noreturn внутри zend_try — это и есть bailout, то есть longjmp() из произвольной точки выполнения. Ровно тот механизм, из которого выросли оба CVE.
Второе — колбэк разбора multipart-тела. В ext/session/session.c он регистрируется безусловно:
| |
Третье — значение по умолчанию той директивы, которая этот колбэк включает:
| |
Единица и ZEND_INI_PERDIR — то же, что в поддерживаемых ветках. Механика, которую разбирает хаб, работает в 5.6 без оговорок: отправитель запроса доходит до аллокаций ещё до того, как начнёт исполняться первая строка приложения.
Второй аргумент — практический. Исследователи, разбиравшие уязвимость после раскрытия, сходятся на том, что затронутый код существует начиная с 5.4. Поставщики с платной поддержкой древних версий выпустили обновления даже для PHP 5.5. «Слишком старое, чтобы быть уязвимым» — не бывает; бывает «слишком старое, чтобы кто-то прислал обновление».
Откуда брать исправление, если поставщика нет
Для 5.6 источник один — патчи из пакета sury, и у них проверяемое происхождение. Я скачал debian.tar.xz из пула и посмотрел хвост debian/patches/series:
| |
Заголовок 0261:
| |
Дата с точностью до секунды совпадает с официальным бэкпортом для 7.4 — порт на 5.6 сделал тот же человек, который делал официальный. Co-author в обоих — Ilija Tovilo. Это главное, что меня в этом варианте устроило: в веб-сервер на проде попадает код не анонимного происхождения.
Что приезжает с патчами по файлам:
| Патч | Файлы | Строки | Что делает |
|---|---|---|---|
0261 | 13 | +114 / −75 | фикс PHP2Shell: безопасный выход вместо bailout |
0262 | 1 | +4 / −1 | не повторять предупреждение о лимите загрузок |
0263 | 2 | +12 / −0 | директива max_multipart_body_parts, CVE-2023-0662 |
0264 | 1 | — | запись в NEWS, не ложится, пропускается |
Для сравнения с официальным бэкпортом 7.4: там 17 файлов и +240/−59. Разница — не в полноте, а в том, что часть изменений 7.4 касается кода, которого в 5.6 просто нет.
История про то, почему 0262 и 0263 появились на день позже самого фикса и почему версия пакета -105, а не -104, — в статье про ветки 7.x: это одна и та же ошибка портирования, замеченная внешним читателем в трекере.
Применение по шагам — ровно то, что я прогнал перед сборкой образа:
| |
Результат: 16 строк patching file, ни одного FAILED. Смещения в 0261 — −11 в zend_strtod.c и −15/−21/−27 в main/php_variables.c; в 0263 — +12 во всех трёх кусках main/rfc1867.c. Смещения тут ожидаемы и безобидны: пакет sury несёт свои двести шестьдесят патчей до нашего, и дерево у мейнтейнера сдвинуто относительно ванильного.
Чем порт на 5.6 отличается от порта на 7.x
Порт на 5.6 — не копия: семь пунктов в заголовке 0261 описывают, что именно пришлось переписать, и каждый из них объясняет, почему механический git cherry-pick здесь не сработал бы. Три самых показательных, дословно:
- GPC arrays start with 2 buckets: nTableMask == 0 marks an uninitialized hash table in 5.6, so a single bucket cannot be used.
- zend_mm_safe_error_ex() keeps the PHP 5.6 way of finding the script file and line (TSRM, EG(in_execution)).
- "|| 1" instead of "|| true" in the session upload progress callback.
Третий пункт — про отсутствие stdbool.h в сборке 5.6; первый — про внутреннее устройство хеш-таблиц, где ноль в nTableMask означает «таблица не инициализирована», и поэтому нельзя завести массив с одним бакетом, как в 7.x. Это ровно те места, на которых ломается попытка перенести патч из более новой ветки своими руками.
Один практический бонус у 5.6 есть. На 7.x фикс использует макрос ZSTR_MAX_LEN, которого в ветке нет, и без отдельного патча-зависимости сборка падает после двадцати минут компиляции — самая дорогая грабля в этой серии. В 5.6 никакого zend_string не существует, порт написан без этого макроса, и grep -c ZSTR_MAX_LEN по 0261 даёт ноль. То есть самая древняя ветка оказалась единственной, где набор патчей самодостаточен.
Патч на NEWS не ложится, и это нормально
Четвёртый патч серии падает, и пугаться тут нечего — он правит только changelog:
| |
Причина простая: NEWS в дереве мейнтейнера уже содержит записи предыдущих двухсот шестидесяти патчей, а в ванильном тарболе — нет. На собранный бинарник это не влияет никак.
Правило, которое из этого следует: при выборочном применении патчей из серии надо знать, какие файлы правит каждый патч, и отличать код от метаданных. Один git apply --numstat по каждому файлу до начала работы снимает вопрос:
| |
Если в выводе только NEWS, debian/changelog или тесты — патч можно пропустить осознанно, а не гадать над FAILED.
Dockerfile ветки 5.6 удалён из репозитория — и его надо достать из истории
Официального Dockerfile для 5.6 не существует с 1 февраля 2019 года, но он есть в истории git, и доставать надо конкретный коммит. История ветки 5.6 в docker-library/php выглядит так:
| Коммит | Дата | Сообщение |
|---|---|---|
2408ff5a5ad7 | 2018-12-31 | «Remove 5.6 as it is end of life today!» |
783878384a8f | 2019-01-11 | «Update to 5.6.40» |
ea377551f752 | 2019-01-31 | «Adjust “/var/www/html” permissions consistently across variants» |
e9320fdb752e | 2019-02-01 | «Revert “Update to 5.6.40” (this removes the EOL 5.6 series)» |
Ветку убрали в день EOL, через полторы недели вернули ради финального релиза 5.6.40, а 1 февраля удалили окончательно — e9320fdb752e сносит 71 файл.
Интуитивно хочется взять последнее состояние дерева, то есть ea377551f752. Я так и начал — и зря. Теги php:5.6-* на Docker Hub запушены 25 января 2019, то есть до коммита от 31 января. Последнее состояние дерева в опубликованный образ никогда не попало. Разница между коммитами для 5.6/stretch/fpm/Dockerfile — ровно один блок:
| |
Собрав из ea377551f752, я бы в рамках закрытия RCE молча сделал webroot доступным на запись всем. Для задачи «устранить уязвимость, ничего больше не меняя» это прямо противоположный результат. Поэтому база — 783878384a8f, вариант 5.6/stretch/fpm, и в нём 236 строк, из которых меняются три ссылки и добавляются патчи.
Кстати, про состояние тегов. Docker Hub отдаёт по php:5.6-fpm дату пуша 2019-01-25T10:58:18Z, digest sha256:4f070f1b7b93cc5ab364839b79a5b26f38d5f89461f7bc0cd4bab4a3ad7d67d7 и tag_status: inactive. У тегов ветки 7, как я показывал в статье про 7.x, статус до сих пор active — то есть для 5.6 Docker Hub хотя бы честно помечает тег неактивным. Это единственный случай в серии, где реестр сам подсказывает, что образ брошен.
Что в Dockerfile 2019 года сломалось за семь лет
Ломается не PHP, а всё вокруг него, и починить надо три места. Проверено запросами сегодня:
| Что в Dockerfile | Что сейчас | Замена |
|---|---|---|
PHP_URL=https://secure.php.net/get/php-5.6.40.tar.xz/from/this/mirror | HTTP 404 | https://www.php.net/distributions/php-5.6.40.tar.xz — HTTP 200 |
deb.debian.org/debian + stretch | HTTP 404 | archive.debian.org/debian — HTTP 200; archive.debian.org/debian-security + stretch/updates — HTTP 200 |
gpg --keyserver ha.pool.sks-keyservers.net | сеть SKS закрыта | keyserver.ubuntu.com |
Замена URL тарбола проверяется, а не принимается на веру: PHP_SHA256 в Dockerfile — 1369a51eee3995d7fbd1c5342e5cc917760e276d561595b6052b21ace2656d1c, и файл с www.php.net/distributions/ даёт ровно эту сумму, 12 472 236 байт. То есть новый адрес отдаёт тот же архив, который использовался при сборке образа в 2019 году. Подпись .asc при этом продолжает проверяться — ключи 0BD78B5F97500D450838F95DFE857D9A90D90EC1 и 6E4F6AB321FDC07F2C332E3AC2BF0BC433CFC8B3 импортируются с нового keyserver, и отключать проверку не требуется.
Одну «общеизвестную» правку я добавил и потом убрал. Во всех руководствах по сборке на архивных Debian написано добавлять Acquire::Check-Valid-Until "false". Я проверил Release-файлы stretch на archive.debian.org: поля Valid-Until там нет ни в dists/stretch/Release, ни в debian-security/dists/stretch/updates/Release. Проверять нечего — значит, apt и не ругается, а опция просто кочует из статьи в статью. Лишние «на всякий случай» в Dockerfile, который закрывает RCE, вредны: каждый такой флаг надо потом объяснять аудиту.
Базовый образ при этом на месте: debian:stretch-slim последний раз запушен 23 июня 2022, четыре архитектуры, tag_status: active.
Про docker-php-source delete в конце официального Dockerfile и про перепаковку /usr/src/php.tar.xz сразу после patch — в статье про 8.x; на 5.6 эта мера нужна по той же причине и выглядит так же.
Dockerfile, которого нет в вашем git
Если Dockerfile потерян, восстанавливать его надо не по памяти, а по работающему контейнеру — четырьмя командами docker. Самая неприятная часть задачи была именно тут, а не в патче: образ, который работал на проде, собирался когда-то вручную — FROM php:5.6-fpm, несколько расширений, свой php.ini — и ни одной строчки этого в репозитории.
Что снимается с живого контейнера и во что это превращается:
| |
php -m даёт список расширений — он же становится списком docker-php-ext-install в новом Dockerfile. php-fpm -tt печатает разобранный конфиг пулов и позволяет сравнить его построчно. php -i — источник значений ini, которые кто-то выставил руками. docker diff показывает, что в контейнер дописали после запуска; если там обнаружится код приложения, это отдельный разговор, но знать об этом лучше до пересборки, чем после.
Критерий готовности я взял простой: php -m старой и новой сборки совпадает строка в строку, php-fpm -tt совпадает, а в итоговом ini допускаются ровно два отличия — session.upload_progress.enabled и появившийся max_multipart_body_parts. Третьим отличием всплыл OpenSSL: в образе 2019 года 1.0.2q, в сборке сегодня — 1.0.2u-1~deb9u7 из stretch/updates. Это не регресс, а семь лет накопленных обновлений безопасности stretch, но знать про смену версии надо заранее — на старом коде, который делает что-нибудь нестандартное с TLS, это может проявиться.
Как проверить, что патч действительно в сборке
У 5.6 теперь есть и статический маркер, и поведенческий тест — до версии -105 был только второй. Три проверки, от самой дешёвой:
| Уровень | Проверка | Ожидаемый результат |
|---|---|---|
| Патч лёг | grep -c array_init_singleton Zend/zend_API.h | 0 до патча, 2 после |
| Собралось с патчем | php -i | grep max_multipart_body_parts | директива есть; в непатченом 5.6 её не бывает |
| Работает | поведенческий тест на memory_limit | exit=1, shutdown-функция не вызвана |
Второй уровень для 5.6 надёжнее, чем для любой другой ветки: директива max_multipart_body_parts появилась в PHP только в феврале 2023, в 8.0.28, и в ванильном 5.6 её не может быть ни при каких настройках. На 8.0.30, например, этот маркер бесполезен — там директива есть и без патча.
Третий уровень — общий для всей серии тест: патч меняет способ выхода при исчерпании памяти, и это видно по коду возврата и по тому, вызвалась ли shutdown-функция. Он прошёл на собранном образе так же, как на ветках 7.x.
Важная оговорка про первый уровень. Маркер надо выбирать по строке, которой в ванильном дереве нет, — я на этом уже спотыкался на ветке 7, где первая выбранная метка существовала и до патча, так что assert проходил всегда. Для 5.6 array_init_singleton этому требованию удовлетворяет: в чистом php-5.6.40 grep -c даёт 0, после 0261 — 2, потому что патч добавляет _array_init_singleton() в Zend/zend_API.c и объявление в Zend/zend_API.h.
Что получилось и чего в этой работе нет
Получился один свой базовый образ на ветку и одна строка FROM в Dockerfile приложения — та же схема, что и на 7.x:
| Что | Значение |
|---|---|
| База | debian:stretch-slim (пуш 2022-06-23), Dockerfile из 783878384a8f, вариант 5.6/stretch/fpm |
| PHP | 5.6.40 + патчи sury 0261 + 0262 + 0263 |
| Правок в Dockerfile | три ссылки (тарбол, apt, keyserver) + COPY патчей + перепаковка /usr/src/php.tar.xz |
| Изменений в приложении | ноль |
| Отличия в ini | session.upload_progress.enabled, max_multipart_body_parts, OpenSSL 1.0.2q → 1.0.2u-1~deb9u7 |
| Простой при подмене контейнера | порядка секунд |
Структура — настоящая: один контейнер, за ним несколько доменов-лендингов, Dockerfile потерян. Количества машин, контейнеров и доменов я не привожу: это периметр заказчика. Полезная деталь из этой части работы вообще не про PHP — часть лендингов на момент проверки была сломана задолго до нас, и обнаружилось это только потому, что перед подменой контейнера я снял коды ответов по списку доменов. Смоук-тест, который делается ради сравнения «до и после», попутно показывает, что в хозяйстве уже не работает.
Чего в этой работе нет, и это стоит сказать прямо. Публичный PoC я не запускал — ни на патченой, ни на непатченой сборке; весь вывод «уязвимо» построен на чтении исходников и на диапазоне версий из advisory, а «исправлено» — на патчах известного происхождения и на поведенческом тесте. Это слабее, чем воспроизведённая эксплуатация, и в отчёте записано именно так.
И отдельно: всё это не отменяет переезда с 5.6. Патч закрывает конкретные две CVE, а не делает ветку поддерживаемой. Он покупает время, и ценность у него в том, что покупается оно за один вечер, а не за квартал.
Итог
Отсутствие поставщика не означает отсутствия исправления. Для 5.6 нет ни релиза, ни официального бэкпорта, ни живого Docker-образа — но есть патчи, сделанные автором официального бэкпорта, и они ложатся на ванильный php-5.6.40 без единого конфликта.
Для древней ветки главный риск — не патч, а Dockerfile вокруг него. Из четырёх сломанных вещей в сборке три были инфраструктурными: мёртвый URL тарбола, уехавшие репозитории stretch, закрытая сеть keyserver. И одна — тихая: взять последний коммит дерева вместо того, из которого собран опубликованный образ, и добавить в webroot chmod 777, закрывая RCE.
Проверять надо то, что проверяемо. Сумма sha256 из Dockerfile 2019 года сходится с файлом, скачанным с нового адреса сегодня. Поле Valid-Until, из-за которого все добавляют Acquire::Check-Valid-Until "false", в Release-файлах stretch отсутствует. Маркер array_init_singleton даёт 0 на чистом дереве и 2 на патченом. Каждое из этих утверждений — одна команда, и каждое из них я получил вместо того, чтобы предположить.
Остальное по серии: общий разбор уязвимости и смягчений, ветки 7.1–7.4, поддерживаемые 8.x и ловушки Docker-тегов.
Первоисточники: GHSA-w8hw-w9fx-j79w и GHSA-6wmf-4rv5-p3rp, коммит 712e60f9edfd (официальный бэкпорт 7.4), пул пакетов sury, коммит 783878384a8f и коммит e9320fdb752e в docker-library/php, issue #146 в трекере deb.sury.org, архив Debian.