docker build --pull прошёл, а PHP остался уязвимым: как теги образов замораживаются молча
Короткий ответ: --pull скачивает последний образ под тегом, а не последний PHP. Тег php:8.2-fpm-bullseye последний раз обновлялся 5 августа 2025 года и несёт PHP 8.2.29 — сборка с --pull пройдёт успешно и даст уязвимый PHP. Проверять надо PHP_VERSION конкретного тега до сборки, а не факт успешной сборки: curl -s https://hub.docker.com/v2/repositories/library/php/tags/8.2-fpm-bullseye показывает last_updated и digest без скачивания образа.
21 октября 2026 вышли PHP 8.2.35, 8.3.36, 8.4.27 и 8.5.12 — они закрывают PHP2Shell: две RCE с CVSS 9.2, где тело запроса разбирается до кода приложения, так что авторизация и CSRF на этом пути не стоят. Для поддерживаемых ветвей исправление выглядит как «поднять тег и пересоздать контейнер». На практике в нескольких местах обновлённый по всем признакам образ остался с уязвимым PHP, а стандартная проверка этого не показала.
Эта статья — про ветку 8.x и про механику тегов. Общий разбор уязвимости, таблица «ветка → источник исправления» и смягчения — в статье-хабе. EOL-ветки 7.x и 5.6 — отдельно.
Коротко (TL;DR)
--pullне гарантирует свежий PHP. Он подтягивает последний образ под тегом. Теги на старых ОС перестают пересобираться, и тег продолжает отдавать уязвимую версию.php:8.2-fpmиphp:8.2-fpm-trixie— один digest (sha256:77900c43ba8b…). Тег без суффикса ОС сменил Debian на 13: в trixie нетapt-key, и старые Dockerfile на нём перестают собираться.wordpress:latestиwordpress:php8.3-apache— один digest (sha256:d42f63cb89ad…). Слепойpullменяет минорную версию PHP, а не только патч.- Минорные теги сторонних образов тоже замерзают.
dunglas/frankenphp:1.8-php8.4-bookwormстоит на 14 июля 2025 и несёт PHP 8.4.10. У FrankenPHP на эту тему есть своё advisory с рейтингом critical. - Проверка без скачивания образа: запрос к Hub API отдаёт
last_updatedи digest тега;docker buildx imagetools inspect --formatдостаётPHP_VERSIONиз конфига образа. - Образ обновлён ≠ PHP обновлён. Роль с
up -d --no-recreateи CI-раннеры получат новый образ когда-нибудь потом. Это надо помечать в отчёте отдельной строкой. - Для EOL 8.0 исправленного релиза нет: только коммит
82f8f31b16a0в php-src. И он не ложится на 8.0.7 — путь только через 8.0.30.
Почему docker build --pull не означает «свежий PHP»
--pull делает ровно то, что написано: тянет последнюю версию базового образа под указанным тегом. Если тег больше не пересобирается, «последняя версия под тегом» — это та же самая версия, что год назад. Сборка при этом успешна, слои приложения свежие, дата сборки сегодняшняя, и ни одна команда в выводе не намекает на проблему.
Официальный образ — это PHP плюс операционная система плюс политика тегов docker-library. Исправленный PHP появляется не под каждым тегом, под которым его ждут. Вот что я получил запросами к Docker Hub 26 октября 2026 — через пять дней после выхода исправленных релизов:
| Тег | Обновлён | Digest | PHP внутри |
|---|---|---|---|
php:8.2-fpm | 2026-10-24 | sha256:77900c43ba8b… | 8.2.35 |
php:8.2-fpm-trixie | 2026-10-24 | sha256:77900c43ba8b… | 8.2.35 |
php:8.2-fpm-bookworm | 2026-10-21 | sha256:91d643dd1485… | 8.2.35 |
php:8.2-fpm-bullseye | 2025-08-05 | sha256:e0d898e69420… | 8.2.29 |
php:8.4-fpm | 2026-10-23 | sha256:af1728147713… | 8.4.27 |
php:8.4-fpm-trixie | 2026-10-23 | sha256:af1728147713… | 8.4.27 |
php:8.0-fpm | 2023-11-22 | sha256:b07b8df17506… | 8.0.30 |
Из таблицы читаются сразу три механизма.
Тег на старой ОС заморожен. -bullseye стоит на 5 августа 2025 — почти пятнадцать месяцев. Ветка PHP 8.2 живая и исправление получила, но под Debian 11 её больше не собирают: bullseye вышел из обычной поддержки, а docker-library не держит сборки на архивных ОС. Тег остаётся на месте и продолжает отдавать 8.2.29. Исправленный 8.2.35 есть только в -bookworm и -trixie.
Тег без суффикса ОС сменил ОС. php:8.2-fpm и php:8.2-fpm-trixie совпадают по digest до символа — это буквально один и тот же образ. То же у php:8.4-fpm и php:8.4-fpm-trixie. Если ваш Dockerfile написан под Debian 11 или 12, переход на тег без суффикса — это не обновление патч-версии PHP, а смена дистрибутива. Самая заметная поломка: в trixie apt-key больше нет. Это видно по списку файлов пакета apt на сайте Debian — в bookworm /usr/bin/apt-key есть, в trixie его нет. Любой слой с apt-key add падает.
Метаданные образа честно говорят, что внутри. У образов docker-library есть OCI-аннотации, и они снимают всякую двусмысленность. У php:8.2-fpm-bullseye:
| |
А у php:8.2-fpm — 8.2.35-fpm-trixie, с исходником #f6048b5f6e3c…:8.2/trixie/fpm. То есть версию и ОС можно узнать, не запуская внутри образа ничего.
Как узнать версию PHP в теге, не скачивая образ
Самый быстрый способ — запрос к Hub API: он отдаёт дату последнего обновления тега и digest, и этого хватает, чтобы понять, заморожен тег или нет.
| |
Дата больше года назад для живой ветки PHP — достаточный сигнал, чтобы не собирать с этого тега. Сам PHP_VERSION достаётся из конфига образа, тоже без docker pull:
| |
Две оговорки по второй команде. Она ходит в registry, поэтому упирается в анонимные лимиты Docker Hub — при массовой проверке парка ответом будет 429 Too Many Requests; запрос к Hub API в этом смысле дешевле. И шаблон {{ .Manifest.Digest }} у imagetools в версии 0.31.1 игнорируется: вместо digest команда печатает весь манифест-лист и выходит с кодом 1. Digest надёжнее брать из Hub API.
Сравнение digest — вообще самый дешёвый способ проверить, совпадают ли два тега. Он же отвечает на вопрос «а latest — это что».
Почему wordpress:latest молча меняет минорную версию PHP
Потому что latest у WordPress — это алиас на одну из вариаций с конкретной веткой PHP, и алиас переезжает. На 26 октября 2026:
| Тег | Digest | PHP внутри |
|---|---|---|
wordpress:latest | sha256:d42f63cb89ad… | 8.3.36 |
wordpress:php8.3-apache | sha256:d42f63cb89ad… | 8.3.36 |
wordpress:php8.2-apache | sha256:7b713d741828… | 8.2.35 |
latest и php8.3-apache — один digest. Образ, который когда-то собрали с FROM wordpress:latest на ветке 8.2, после pull поедет на 8.3. Для WordPress с десятком плагинов это не «обновление безопасности», а смена минорной версии PHP под работающим сайтом, то есть отдельное изменение со своим тестированием.
Внутри wordpress:* — Apache с mod_php, а у mod_php RCE по CVE-2026-109618 подтверждён на настройках по умолчанию. Так что медлить тут нельзя, но и прыгать через минорную версию заодно с патчем — плохая сделка. Я ставил wordpress:php8.2-apache: PHP остаётся на своей ветке, меняется патч-версия. Переход на 8.3 — следующим шагом, когда для него есть время.
FrankenPHP: своя нумерация, свои уязвимости и своё признание проблемы
У FrankenPHP версия продукта и версия PHP внутри — разные числа, и минорная линия перестаёт пересобираться так же, как теги на старых ОС. dunglas/frankenphp:1.8-php8.4-bookworm последний раз обновлялся 14 июля 2025 и несёт PHP 8.4.10. PHP 8.4.27 есть только в линии 1.13.x: dunglas/frankenphp:1.13.2-php8.4.27-bookworm, digest sha256:49369ff211d8…, опубликован 21 октября 2026.
Самое интересное: у проекта есть собственное advisory ровно про этот механизм, и у него рейтинг critical — GHSA-x9p2-77v6-6vhf, «Delayed propagation of security fixes in upstream base images», 5 февраля 2026:
«if an upstream base image (such as Alpine Linux or official PHP/Go images) received a security patch under an existing tag, the FrankenPHP image would remain on the older, vulnerable version of those base layers»
И про корень:
«base images such as Alpine, PHP, and Go usually overwrite existing Docker tags to apply security fixes, which wasn’t triggering a new build on our side»
Исправлено в 1.1.11: с 4 февраля 2026 в пайплайне появилась ежедневная сверка digest базовых образов с registry. Механизм починен — но он пересобирает поддерживаемые линии. Линия 1.8 замерла в июле 2025, то есть до появления этой сверки, и никакая автоматика её уже не разморозит.
Переход 1.8 → 1.13.2 закрывает заодно четыре собственные уязвимости FrankenPHP, опубликованные в тот же день, что и PHP2Shell — 21 октября 2026, все исправлены в 1.13.2:
| Advisory | Severity | Суть |
|---|---|---|
| GHSA-6q3j-62j9-f9hh | high | подготовленные файлы в os.TempDir()/frankenphp_<checksum> подменяют код embedded-приложения |
| GHSA-ccgp-ch9h-533f | medium | циклический PHP-массив в frankenphp_log() роняет весь процесс через переполнение стека Go |
| GHSA-fq24-r53r-xcx3 | medium | запросы из очереди исполняются после отключения клиента |
| GHSA-4gfw-9v29-gp9h | low | состояние ошибок libxml протекает между запросами в worker-режиме |
Отсюда правило для сторонних образов: тег с явными версиями обоих компонентов плюс digest, то есть dunglas/frankenphp:1.13.2-php8.4.27-bookworm@sha256:49369ff211d8…. Явные версии дают читаемость, digest — воспроизводимость сборки.
Что ставить на поддерживаемых ветках: явный тег ОС вместо тега без суффикса
Правильный FROM для ветки 8.2 — php:8.2-fpm-bookworm или php:8.2-fpm-trixie, с явным указанием ОС, а не php:8.2-fpm и не -bullseye. Тег без суффикса меняет ОС тогда, когда это решит docker-library, а не когда вы к этому готовы.
Переход -bullseye → -bookworm — это смена Debian 11 на 12, и под ней меняются системные библиотеки, с которыми PHP линкуется. По версиям пакетов из самого Debian:
| Библиотека | Debian 11 (bullseye) | Debian 12 (bookworm) |
|---|---|---|
| OpenSSL | 1.1.1w-0+deb11u8 | 3.0.22-1~deb12u1 |
| curl | 7.74.0-1.3+deb11u16 | 7.88.1-10+deb12u15 |
| ICU | libicu67 (67.1) | libicu72 (72.1) |
Мажорный переход OpenSSL 1.1.1 → 3.0 — единственный пункт, из-за которого я не выкатывал новый образ сразу. OpenSSL 3 строже к устаревшим шифрам и к цепочкам сертификатов, а приложение ходит к внешним API, которые чинить не мы будем. Поэтому перед выкаткой я прогонял рукопожатия ко всем внешним эндпоинтам из старого и из нового образа и сравнивал результат. Расхождений не нашлось, но проверка стоила десять минут, а потенциальная поломка — отвалившиеся интеграции на проде.
Сборку я делал на машине с Apple Silicon, поэтому платформа задаётся явно — иначе получится arm64-образ, который на прод-серверах не запустится:
| |
И отдельный ini со смягчением — одна строка, которая закрывает подтверждённый путь эксплуатации на FPM:
| |
Директива работает только из php.ini, из конфига пула FPM или из конфига веб-сервера. ini_set() и .user.ini тут бесполезны — почему именно, разобрано в хабе.
Пакеты из PPA: номер версии не меняется при бэкпорте
Для PHP, установленного пакетами, версия не говорит ни о наличии, ни об отсутствии исправления — решает changelog пакета. Мейнтейнеры патчат бэкпортом, не поднимая апстрим-версию, так что php -v даёт одинаковый ответ до и после.
| |
Перед обновлением стоит посмотреть, что из PHP на машине вообще стоит:
| |
Если это только CLI — обновление ничего не перезапускает и может ехать вне окна. Если в списке найдутся php8.2-fpm, php8.2-cgi или libapache2-mod-php8.2, это уже обслуживание сетевого сервиса с перезапуском, а не тихий apt. На машине, где я был уверен, что стоит только CLI, я всё равно проверил — потому что уверенность в содержимом машины и содержимое машины у меня уже расходились на этом же обходе парка.
Образ обновлён, а работающий PHP — нет
Новый образ в registry не означает, что он где-то запущен. Это самый неприятный класс проблем, потому что все артефакты выглядят правильно: сборка зелёная, тег в registry свежий, прод обновлён — а часть машин продолжает работать на старом PHP.
Два механизма, оба «срабатывают потом»:
- Роль выкатки со
up -d --no-recreate. Такая роль не пересоздаёт контейнер, если он уже запущен, и безpullне получает новый образ. Стенды в такой схеме останутся на старом PHP до следующего ручного действия. Я нашёл это чтением роли до выкатки, и стенды пересоздавал отдельной командой. - CI-раннеры. Образ
php-cliподтягивается при следующей задаче. До первой задачи после обновления раннер держит уязвимый образ, и это не видно ни в registry, ни в отчёте о сборке.
Проверять надо на каждой машине, и именно соответствие работающего контейнера новому образу:
| |
Первая команда читается демоном Docker и отвечает всегда. Две следующие заходят внутрь контейнера, поэтому на нагруженном или подвисшем контейнере висят сами — оборачивать в timeout 20, иначе обход парка встанет на одной машине.
Вывод, который я унёс из этой грабли: в отчёте о закрытии уязвимости нужна отдельная строка «здесь исправление случится при следующем событии». Не «исправлено», не «не исправлено», а третье состояние — иначе через неделю никто не вспомнит, что раннеры считались закрытыми условно.
EOL 8.0: исправленного релиза нет, собираем свой базовый образ
Для ветки 8.0 PHP выпустил только минимальный бэкпорт — коммит 82f8f31b16a0 в ветке PHP-8.0-CVE-2026-109618-109619 в php-src. Ни тарбола, ни пакета, ни образа: 17 файлов, +229/−59, затронуты Zend/*, main/rfc1867.c, main/SAPI.c, main/main.c, main/php_variables.c и ext/session/session.c. Автор — Arnaud Le Blanc, сообщение «Avoid state corruption via bailout. Minimal mitigation for EOL version».
Первое, что выяснилось при наложении: патч не ложится на произвольную версию ветки. Возьмём типичный случай — образ собран в 2021 году и несёт 8.0.7. На него patch --dry-run даёт конфликты в zend_vm_*. На php-8.0.30 — чисто. Значит путь только один: сначала 23 патч-версии вперёд, до последнего релиза ветки, и уже потом бэкпорт. Это меняет характер задачи: вместо «накатить патч безопасности» получается обновление, у которого свои риски.
Что приезжает вместе с 8.0.7 → 8.0.30, по NEWS:
| Версия | Изменение, которое может сломать приложение |
|---|---|
| 8.0.8 | FILTER_VALIDATE_URL стал строже |
| 8.0.24, 8.0.27 | имена cookie больше не url-декодируются |
| 8.0.28 | появился лимит частей multipart max_multipart_body_parts; исправлен password_verify |
| 8.0.30 | libxml не загружает внешние сущности по умолчанию |
Пункт про cookie — не абстрактный риск. На соседнем проекте этой же истории я уже ловил именно его: имя cookie с точкой или пробелом после обновления перестаёт находиться в $_COOKIE под тем именем, под которым приложение его ищет.
База для своего образа — официальный Dockerfile docker-library, путь 8.0/bullseye/fpm, коммит 41c9ad0 («Adding missing gpg key to 8.0 releasers», 7 августа 2023). Смысл брать именно его, а не писать свой: раскладка (/usr/local/etc/php, docker-php-ext-*, docker-php-source) остаётся точно такой, как у официального образа, и в Dockerfile приложения меняется ровно одна строка — FROM.
Патч накладывается после docker-php-source extract:
| |
Три места тут требуют пояснения.
Про sources.list. Debian 11 доживает свой срок, и репозитории постепенно переезжают на archive.debian.org. На 26 октября 2026 bullseye-security отдаётся и с deb.debian.org (HTTP 200), и с archive.debian.org (тоже 200) — то есть прямо сейчас работают оба варианта. Но Dockerfile, который воспроизводит образ пятилетней давности, тем и опасен, что зависит от репозиториев, которые из-под него уезжают: однажды apt-get update просто вернёт 404, и сборку безопасности придётся чинить в самый неподходящий момент. Зафиксировать архивное зеркало сразу стоит ничего. grep -q рядом — assert: если формат sources.list в базовом образе изменится, sed тихо ничего не заменит, а сборка должна упасть на этом же слое, а не через двадцать минут на непонятной ошибке.
Про перепаковку php.tar.xz. Официальный Dockerfile в конце делает docker-php-source delete, а в образе приложения позже может вызываться docker-php-ext-install — он заново распаковывает /usr/src/php.tar.xz. Это оригинальный тарбол, без патча. Расширение соберётся из непатченых исходников. Честная оговорка: реального сбоя из-за этого я не наблюдал, перепаковка — защитная мера. Стоит она одну строку.
Про pecl. REST-канал pecl.php.net не отдаёт старые релизы под PHP 8.0, и обычный pecl install redis в такой сборке падает. Решение — скачать нужный тарбол напрямую и сверить sha256: я брал ту же версию, что была в старом образе, redis-5.3.4.tgz. Для старых ветвей это общее правило: всё, что ставится «из интернета по имени», в какой-то момент перестаёт ставиться, и для воспроизводимой сборки это надо прибивать к конкретному файлу.
Почему версия и php -i не доказывают, что патч в образе
На самосборке с бэкпортом исправленность не определяется ни по версии, ни по ini — только по поведению. Это ловушка, в которую попадёт любой сканер и любой чек-лист «посмотри php -v»:
php -vна патченой и непатченой сборке 8.0.30 отвечает одинаково: бэкпорт номер версии не меняет.max_multipart_body_partsмаркером не служит — директива есть в 8.0.30 и без патча, она появилась в 8.0.28.php -iпоказываетsession.upload_progress.enabled => Onдаже на патченой сборке: ini-значение осталось прежним, а выключение сделано в коде.
То есть инвентаризация по версии и ini даст две ошибки сразу: патченый образ назовёт уязвимым, а Off в ini примет за доказательство патча. Остаётся проверка поведением — патч меняет способ выхода при исчерпании memory_limit:
| |
Проверено на 8.0.30 с патчем и на 8.0.7 — коды выхода 1 и 255 соответственно. Полный разбор трёх уровней проверки («лёг» ≠ «собрался» ≠ «работает») — в хабе; тут важно, что этот тест встаёт в чек-лист сборки как приёмка, один docker run и сравнение кода выхода. Самосборный образ при этом стоит метить тегом вида 8.0.30-p2s-<дата> и LABEL — иначе через полгода никто не отличит его от обычного 8.0.30.
Что в итоге получилось
Правка в каждом случае свелась к одной строке FROM или одной команде apt-get, а вот способ доказать исправленность у каждого свой — и это главная строка таблицы:
| Случай | Было | Стало | Как доказана исправленность |
|---|---|---|---|
Laravel на php:8.2-fpm-bookworm | 8.2.x | 8.2.35 | PHP_VERSION базы и работающего контейнера |
Боты на php:8.2-fpm-bullseye | 8.2.2x | 8.2.35 на bookworm | то же + сверка TLS к внешним API |
WordPress на wordpress:latest | mod_php 8.2.x | 8.2.35 на php8.2-apache | digest тега + отпечаток ответов |
| FrankenPHP 1.8 | PHP 8.4.10 | 1.13.2 / PHP 8.4.27 | тег с явными версиями + digest |
| PHP CLI из PPA | 8.2.x | 8.2.35 | CVE в changelog пакета |
Laravel на php:8.0-fpm-buster | 8.0.7 | 8.0.30 + бэкпорт, свой образ | тест поведения при OOM |
| CI-раннеры | старый образ | новый образ в registry | условно: станут исправленными при следующей задаче |
Состав набора — настоящий, абсолютные количества машин и контейнеров в каждой строке опущены: это периметр заказчика. Что важнее количеств: шесть случаев на одной ветке 8.x — это шесть разных способов доказать исправленность. Образ docker-library — по PHP_VERSION. Пакет PPA — по changelog. Самосборка — по поведению. Сторонний образ — по digest. Один чек-лист на всё не получается.
После каждой выкатки я сравнивал не только версию PHP. Для образов приложения сверялись список собранных модулей, итоговый ini и php-fpm -tt: состав модулей должен совпадать строка в строку, а в ini допустимо ровно одно отличие — max_multipart_body_parts, который приезжает с патчем. Для WordPress — отпечаток ответов: заголовки, форма входа и тело REST-ответа байт в байт против снятого до обновления. Это дешёвая проверка, которая ловит ровно тот класс проблем, который возникает при смене базовой ОС: отвалившееся расширение, уехавшая локаль, пропавший ini-файл.
Итог
Три правила, которые я бы выписал на стену после этой истории.
Проверять тег, а не факт сборки. Зелёная сборка с --pull не доказывает ничего про версию PHP. Доказательство — PHP_VERSION базового образа до сборки и работающего контейнера после. Запрос к Hub API за last_updated и digest стоит один curl и ловит замороженные теги мгновенно.
Теги без суффикса и алиасы вроде latest в Dockerfile не писать. php:8.2-fpm — это сегодня trixie, а завтра что-то ещё; wordpress:latest — это сегодня PHP 8.3. Явный тег с ОС, а для сторонних образов — явные версии плюс digest. Воспроизводимость сборки тут не абстрактная ценность: именно она отличает «обновили PHP» от «заодно сменили дистрибутив и минорную версию».
Способ доказать исправленность зависит от источника PHP. Официальный образ — по версии. Пакет дистрибутива — по changelog. Самосборка с бэкпортом — только по поведению. Самая дорогая ошибка на этом обходе была не технической: я сначала искал один универсальный способ проверки и потерял на этом вечер, прежде чем принял, что их четыре.
Остальные ветки парка — 7.1–7.4 после EOL и 5.6. Общий разбор уязвимости и смягчений — в хабе серии.
Первоисточники: GHSA-w8hw-w9fx-j79w и GHSA-6wmf-4rv5-p3rp (advisory PHP), коммит 82f8f31b16a0 в php-src, репозиторий docker-library/php, GHSA-x9p2-77v6-6vhf (FrankenPHP про непропагацию исправлений базовых образов), список файлов пакета apt в trixie, страница релизов PHP.