BitPage

PHP 5.6 и PHP2Shell: патч sury поверх Dockerfile, который удалили из репозитория в 2019 году

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

Короткий ответ: 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 слишком старая, её это не касается» — неверно

Уязвимый код в 5.6 есть, и проверяется это чтением исходников, а не доверием к диапазону версий в advisory. Я распаковал php-5.6.40.tar.xz и посмотрел три места.

Первое — выход по исчерпанию памяти:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* Zend/zend_alloc.c:1743, ванильный php-5.6.40 */
static void zend_mm_safe_error(zend_mm_heap *heap, ...)
{
	...
	} else if (EG(in_execution)) {
		error_filename = EG(active_op_array)?EG(active_op_array)->filename:NULL;
	...
		heap->overflow = 1;
		zend_try {
			zend_error_noreturn(E_ERROR,

zend_error_noreturn внутри zend_try — это и есть bailout, то есть longjmp() из произвольной точки выполнения. Ровно тот механизм, из которого выросли оба CVE.

Второе — колбэк разбора multipart-тела. В ext/session/session.c он регистрируется безусловно:

1
2
php_session_rfc1867_orig_callback = php_rfc1867_callback;
php_rfc1867_callback = php_session_rfc1867_callback;

Третье — значение по умолчанию той директивы, которая этот колбэк включает:

1
2
STD_PHP_INI_BOOLEAN("session.upload_progress.enabled",
                    "1",  ZEND_INI_PERDIR, OnUpdateBool, rfc1867_enabled, ...)

Единица и ZEND_INI_PERDIR — то же, что в поддерживаемых ветках. Механика, которую разбирает хаб, работает в 5.6 без оговорок: отправитель запроса доходит до аллокаций ещё до того, как начнёт исполняться первая строка приложения.

Второй аргумент — практический. Исследователи, разбиравшие уязвимость после раскрытия, сходятся на том, что затронутый код существует начиная с 5.4. Поставщики с платной поддержкой древних версий выпустили обновления даже для PHP 5.5. «Слишком старое, чтобы быть уязвимым» — не бывает; бывает «слишком старое, чтобы кто-то прислал обновление».

Откуда брать исправление, если поставщика нет

Для 5.6 источник один — патчи из пакета sury, и у них проверяемое происхождение. Я скачал debian.tar.xz из пула и посмотрел хвост debian/patches/series:

1
2
3
4
0261-Avoid-state-corruption-via-bailout.patch
0262-Fix-repeated-warning-for-file-uploads-limit-exceedin.patch
0263-Introduce-max_multipart_body_parts-INI.patch
0264-Add-CVE-2023-0662-to-NEWS.patch

Заголовок 0261:

1
2
3
4
5
6
7
8
From: Arnaud Le Blanc <arnaud.lb@gmail.com>
Date: Sun, 18 Oct 2026 20:09:54 +0200
Subject: Avoid state corruption via bailout

Minimal mitigation for EOL version.

Fix GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
Fix GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)

Дата с точностью до секунды совпадает с официальным бэкпортом для 7.4 — порт на 5.6 сделал тот же человек, который делал официальный. Co-author в обоих — Ilija Tovilo. Это главное, что меня в этом варианте устроило: в веб-сервер на проде попадает код не анонимного происхождения.

Что приезжает с патчами по файлам:

ПатчФайлыСтрокиЧто делает
026113+114 / −75фикс PHP2Shell: безопасный выход вместо bailout
02621+4 / −1не повторять предупреждение о лимите загрузок
02632+12 / −0директива max_multipart_body_parts, CVE-2023-0662
02641—запись в NEWS, не ложится, пропускается

Для сравнения с официальным бэкпортом 7.4: там 17 файлов и +240/−59. Разница — не в полноте, а в том, что часть изменений 7.4 касается кода, которого в 5.6 просто нет.

История про то, почему 0262 и 0263 появились на день позже самого фикса и почему версия пакета -105, а не -104, — в статье про ветки 7.x: это одна и та же ошибка портирования, замеченная внешним читателем в трекере.

Применение по шагам — ровно то, что я прогнал перед сборкой образа:

1
2
3
4
5
curl -sSfO https://www.php.net/distributions/php-5.6.40.tar.xz
echo '1369a51eee3995d7fbd1c5342e5cc917760e276d561595b6052b21ace2656d1c *php-5.6.40.tar.xz' | sha256sum -c -
tar -xJf php-5.6.40.tar.xz && cd php-5.6.40
for p in 0261 0262 0263; do patch -p1 --fuzz=0 --forward < ../patches/$p-*.patch; done
grep -c array_init_singleton Zend/zend_API.h   # 0 до патчей, 2 после

Результат: 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:

1
2
3
=== 0264-Add-CVE-2023-0662-to-NEWS.patch
Hunk #1 FAILED at 90.
1 out of 1 hunk FAILED -- saving rejects to file NEWS.rej

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

Правило, которое из этого следует: при выборочном применении патчей из серии надо знать, какие файлы правит каждый патч, и отличать код от метаданных. Один git apply --numstat по каждому файлу до начала работы снимает вопрос:

1
for f in patches/026*.patch; do echo "== $f"; git apply --numstat "$f"; done

Если в выводе только NEWS, debian/changelog или тесты — патч можно пропустить осознанно, а не гадать над FAILED.

Dockerfile ветки 5.6 удалён из репозитория — и его надо достать из истории

Официального Dockerfile для 5.6 не существует с 1 февраля 2019 года, но он есть в истории git, и доставать надо конкретный коммит. История ветки 5.6 в docker-library/php выглядит так:

КоммитДатаСообщение
2408ff5a5ad72018-12-31«Remove 5.6 as it is end of life today!»
783878384a8f2019-01-11«Update to 5.6.40»
ea377551f7522019-01-31«Adjust “/var/www/html” permissions consistently across variants»
e9320fdb752e2019-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 — ровно один блок:

1
2
3
4
5
6
7
+RUN set -eux; \
+	mkdir -p "$PHP_INI_DIR/conf.d"; \
+# allow running as an arbitrary user (https://github.com/docker-library/php/issues/743)
+	[ ! -d /var/www/html ]; \
+	mkdir -p /var/www/html; \
+	chown www-data:www-data /var/www/html; \
+	chmod 777 /var/www/html

Собрав из 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/mirrorHTTP 404https://www.php.net/distributions/php-5.6.40.tar.xz — HTTP 200
deb.debian.org/debian + stretchHTTP 404archive.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 — и ни одной строчки этого в репозитории.

Что снимается с живого контейнера и во что это превращается:

1
2
3
4
5
docker inspect <ctr> --format '{{json .Config.Env}}{{"\n"}}{{json .Config.Cmd}}{{"\n"}}{{json .Config.Entrypoint}}'
docker exec <ctr> php -m > /tmp/old-modules.txt
docker exec <ctr> php-fpm -tt 2>&1 | sed 's/^\[[^]]*\] //' > /tmp/old-fpm.txt
docker exec <ctr> php -i > /tmp/old-phpinfo.txt
docker diff <ctr> | head -50

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.h0 до патча, 2 после
Собралось с патчемphp -i | grep max_multipart_body_partsдиректива есть; в непатченом 5.6 её не бывает
Работаетповеденческий тест на memory_limitexit=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
PHP5.6.40 + патчи sury 0261 + 0262 + 0263
Правок в Dockerfileтри ссылки (тарбол, apt, keyserver) + COPY патчей + перепаковка /usr/src/php.tar.xz
Изменений в приложенииноль
Отличия в inisession.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.

#PHP #CVE #EOL #Docker #безопасность

<< Previous Post

|

Next Post >>