BitPage

docker build --pull прошёл, а PHP остался уязвимым: как теги образов замораживаются молча

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

Короткий ответ: --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)

Почему docker build --pull не означает «свежий PHP»

--pull делает ровно то, что написано: тянет последнюю версию базового образа под указанным тегом. Если тег больше не пересобирается, «последняя версия под тегом» — это та же самая версия, что год назад. Сборка при этом успешна, слои приложения свежие, дата сборки сегодняшняя, и ни одна команда в выводе не намекает на проблему.

Официальный образ — это PHP плюс операционная система плюс политика тегов docker-library. Исправленный PHP появляется не под каждым тегом, под которым его ждут. Вот что я получил запросами к Docker Hub 26 октября 2026 — через пять дней после выхода исправленных релизов:

ТегОбновлёнDigestPHP внутри
php:8.2-fpm2026-10-24sha256:77900c43ba8b…8.2.35
php:8.2-fpm-trixie2026-10-24sha256:77900c43ba8b…8.2.35
php:8.2-fpm-bookworm2026-10-21sha256:91d643dd1485…8.2.35
php:8.2-fpm-bullseye2025-08-05sha256:e0d898e69420…8.2.29
php:8.4-fpm2026-10-23sha256:af1728147713…8.4.27
php:8.4-fpm-trixie2026-10-23sha256:af1728147713…8.4.27
php:8.0-fpm2023-11-22sha256: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:

1
2
3
4
org.opencontainers.image.version:   8.2.29-fpm-bullseye
org.opencontainers.image.base.name: debian:bullseye-slim
org.opencontainers.image.created:   2025-08-04T20:50:00Z
org.opencontainers.image.source:    github.com/docker-library/php.git#eb7898cb9c29…:8.2/bullseye/fpm

А у php:8.2-fpm — 8.2.35-fpm-trixie, с исходником #f6048b5f6e3c…:8.2/trixie/fpm. То есть версию и ОС можно узнать, не запуская внутри образа ничего.

Как узнать версию PHP в теге, не скачивая образ

Самый быстрый способ — запрос к Hub API: он отдаёт дату последнего обновления тега и digest, и этого хватает, чтобы понять, заморожен тег или нет.

1
2
3
curl -s https://hub.docker.com/v2/repositories/library/php/tags/8.2-fpm-bullseye \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["last_updated"], d["digest"])'
# 2025-08-05T05:00:54.230152Z sha256:e0d898e694202df3327dd9945792202de12798f8d0969ab6b44d3163c2b033b2

Дата больше года назад для живой ветки PHP — достаточный сигнал, чтобы не собирать с этого тега. Сам PHP_VERSION достаётся из конфига образа, тоже без docker pull:

1
2
3
docker buildx imagetools inspect php:8.2-fpm-bullseye \
  --format '{{ range .Image }}{{ .Config.Env }}{{ end }}' | tr ' ' '\n' | grep PHP_VERSION | sort -u
# PHP_VERSION=8.2.29

Две оговорки по второй команде. Она ходит в 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:

ТегDigestPHP внутри
wordpress:latestsha256:d42f63cb89ad…8.3.36
wordpress:php8.3-apachesha256:d42f63cb89ad…8.3.36
wordpress:php8.2-apachesha256: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:

AdvisorySeverityСуть
GHSA-6q3j-62j9-f9hhhighподготовленные файлы в os.TempDir()/frankenphp_<checksum> подменяют код embedded-приложения
GHSA-ccgp-ch9h-533fmediumциклический PHP-массив в frankenphp_log() роняет весь процесс через переполнение стека Go
GHSA-fq24-r53r-xcx3mediumзапросы из очереди исполняются после отключения клиента
GHSA-4gfw-9v29-gp9hlowсостояние ошибок 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)
OpenSSL1.1.1w-0+deb11u83.0.22-1~deb12u1
curl7.74.0-1.3+deb11u167.88.1-10+deb12u15
ICUlibicu67 (67.1)libicu72 (72.1)

Мажорный переход OpenSSL 1.1.1 → 3.0 — единственный пункт, из-за которого я не выкатывал новый образ сразу. OpenSSL 3 строже к устаревшим шифрам и к цепочкам сертификатов, а приложение ходит к внешним API, которые чинить не мы будем. Поэтому перед выкаткой я прогонял рукопожатия ко всем внешним эндпоинтам из старого и из нового образа и сравнивал результат. Расхождений не нашлось, но проверка стоила десять минут, а потенциальная поломка — отвалившиеся интеграции на проде.

Сборку я делал на машине с Apple Silicon, поэтому платформа задаётся явно — иначе получится arm64-образ, который на прод-серверах не запустится:

1
docker buildx build --platform linux/amd64 --pull -t app/php:candidate --load ./php-fpm

И отдельный ini со смягчением — одна строка, которая закрывает подтверждённый путь эксплуатации на FPM:

1
2
; zz-php2shell.ini
session.upload_progress.enabled = 0

Директива работает только из php.ini, из конфига пула FPM или из конфига веб-сервера. ini_set() и .user.ini тут бесполезны — почему именно, разобрано в хабе.

Пакеты из PPA: номер версии не меняется при бэкпорте

Для PHP, установленного пакетами, версия не говорит ни о наличии, ни об отсутствии исправления — решает changelog пакета. Мейнтейнеры патчат бэкпортом, не поднимая апстрим-версию, так что php -v даёт одинаковый ответ до и после.

1
2
apt install --only-upgrade 'php8.2-*'
zcat /usr/share/doc/php8.2-cli/changelog.Debian.gz | grep -m2 CVE-2026-10961

Перед обновлением стоит посмотреть, что из PHP на машине вообще стоит:

1
dpkg -l 'php8.2*' | grep ^ii

Если это только CLI — обновление ничего не перезапускает и может ехать вне окна. Если в списке найдутся php8.2-fpm, php8.2-cgi или libapache2-mod-php8.2, это уже обслуживание сетевого сервиса с перезапуском, а не тихий apt. На машине, где я был уверен, что стоит только CLI, я всё равно проверил — потому что уверенность в содержимом машины и содержимое машины у меня уже расходились на этом же обходе парка.

Образ обновлён, а работающий PHP — нет

Новый образ в registry не означает, что он где-то запущен. Это самый неприятный класс проблем, потому что все артефакты выглядят правильно: сборка зелёная, тег в registry свежий, прод обновлён — а часть машин продолжает работать на старом PHP.

Два механизма, оба «срабатывают потом»:

  1. Роль выкатки со up -d --no-recreate. Такая роль не пересоздаёт контейнер, если он уже запущен, и без pull не получает новый образ. Стенды в такой схеме останутся на старом PHP до следующего ручного действия. Я нашёл это чтением роли до выкатки, и стенды пересоздавал отдельной командой.
  2. CI-раннеры. Образ php-cli подтягивается при следующей задаче. До первой задачи после обновления раннер держит уязвимый образ, и это не видно ни в registry, ни в отчёте о сборке.

Проверять надо на каждой машине, и именно соответствие работающего контейнера новому образу:

1
2
3
docker inspect -f '{{.Image}}' <контейнер>        # сверить с ID нового образа
docker exec <контейнер> php -r 'echo PHP_VERSION, " up=", ini_get("session.upload_progress.enabled"), PHP_EOL;'
docker exec <контейнер> sh -c '. /etc/os-release; echo $PRETTY_NAME'

Первая команда читается демоном 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.8FILTER_VALIDATE_URL стал строже
8.0.24, 8.0.27имена cookie больше не url-декодируются
8.0.28появился лимит частей multipart max_multipart_body_parts; исправлен password_verify
8.0.30libxml не загружает внешние сущности по умолчанию

Пункт про 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
FROM debian:bullseye-slim
# bullseye уходит в архив: зафиксировать источник, пока он ещё доступен на обоих зеркалах
RUN sed -i 's#http://deb.debian.org/debian-security#http://archive.debian.org/debian-security#' /etc/apt/sources.list \
 && grep -q 'archive.debian.org/debian-security bullseye-security' /etc/apt/sources.list
# ... дальше как в оригинале docker-library, до сборки PHP
COPY php2shell-8.0.patch /usr/src/php2shell-8.0.patch   # git format-patch коммита 82f8f31b16a0
RUN set -eux; \
    apt-get update; apt-get install -y --no-install-recommends patch; \
    docker-php-source extract; \
    cd /usr/src/php; \
    patch -p1 --forward < /usr/src/php2shell-8.0.patch; \
# перепаковать исходники уже с патчем: docker-php-ext-install распакует php.tar.xz заново
    tar -cJf /usr/src/php.tar.xz -C /usr/src php; \
    ./configure ...

Три места тут требуют пояснения.

Про 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»:

То есть инвентаризация по версии и ini даст две ошибки сразу: патченый образ назовёт уязвимым, а Off в ini примет за доказательство патча. Остаётся проверка поведением — патч меняет способ выхода при исчерпании memory_limit:

1
2
3
4
T='register_shutdown_function(function(){echo "SHUTDOWN-CALLED\n";}); $a=str_repeat("x", 20*1024*1024);'
docker run --rm app/php:candidate php -d memory_limit=8M -r "$T"; echo "exit=$?"
# патченая:   Fatal error: Allowed memory size ... , SHUTDOWN-CALLED нет, exit=1
# непатченая: Fatal error: Allowed memory size ... , SHUTDOWN-CALLED есть, exit=255

Проверено на 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-bookworm8.2.x8.2.35PHP_VERSION базы и работающего контейнера
Боты на php:8.2-fpm-bullseye8.2.2x8.2.35 на bookwormто же + сверка TLS к внешним API
WordPress на wordpress:latestmod_php 8.2.x8.2.35 на php8.2-apachedigest тега + отпечаток ответов
FrankenPHP 1.8PHP 8.4.101.13.2 / PHP 8.4.27тег с явными версиями + digest
PHP CLI из PPA8.2.x8.2.35CVE в changelog пакета
Laravel на php:8.0-fpm-buster8.0.78.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.

#Docker #PHP #CVE #инфраструктура #образы

<< Previous Post

|

Next Post >>