Patching a PHP vulnerability without code changes
Closing a critical hole in end-of-life PHP 7.4 / 8.1 apps with a patched base image, no code changes: the stopgap measures and the lasting fix.
When a critical PHP vulnerability is published, the first question is "which version do we move to?". For current releases the answer is easy: pull the patched minor release and rebuild. The hard part is applications running on end-of-life versions whose code can't be touched.
The typical picture: the application runs on an unsupported PHP version, uses an old framework, and its code can't be changed on short notice. A version migration takes weeks; the vulnerability has to be closed today.
1. Inventory first
Start by measuring, not guessing. List the PHP version and SAPI running in every container:
kubectl get pods -A --no-headers \
-o custom-columns=NS:.metadata.namespace,POD:.metadata.name |
while read -r ns pod; do
v=$(kubectl exec -n "$ns" "$pod" -- php -r 'echo PHP_VERSION, " ", PHP_SAPI;' 2>/dev/null) \
&& echo "$ns/$pod $v"
done
The inventory usually shortens the list: some services "thought to be PHP" turn out to be written in another language, and some run under a SAPI the vulnerability doesn't affect. The shorter the list, the clearer the plan.
In the same pass, do a basic compromise check: executables under /tmp and /dev/shm, .php files in upload folders, unexpected cron jobs, oddly named processes. Before you patch, you want to know whether someone is already inside.
2. Same day: stopgap measures
While the patched image is being prepared, you can shrink the attack surface. For this kind of vulnerability, the official recommendations usually concern the request body and upload handling:
; mounted into the php-fpm pool via a ConfigMap
post_max_size = 32M
upload_max_filesize = 30M
session.upload_progress.enabled = Off
On the ingress side, the body limit is reduced as well (nginx.ingress.kubernetes.io/proxy-body-size: 32m). Confirm these values with the teams: how many MB is the largest file the code validates? Set the limit accordingly.
Two traps:
- Containers without a memory limit. Some recommendations involve lifting the memory limit on the PHP side; if the container has no limit of its own, that puts the node at risk. Limit first, then tune.
- Does the CI deploy overwrite the settings? If the pipeline only runs kubectl set image, mounts and limits are preserved. If it applys the full manifest, they get overwritten; check.
3. The lasting fix: same version, patched image
For vulnerabilities like this, the PHP security team may publish source backports for older branches. The idea is simple: take the official php:7.4.33-fpm image, apply the patch to the PHP source inside it, and rebuild with the same configure options.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG PATCH_DIR
COPY ${PATCH_DIR}/ /tmp/patches/
RUN set -eux; \
php -m > /tmp/modules.before; \
opts="$(php-config --configure-options)"; \
docker-php-source extract; cd /usr/src/php; \
for p in /tmp/patches/*.patch; do patch -p1 --fuzz=0 < "$p"; done; \
eval "./configure $opts"; make -j"$(nproc)"; make install; \
php -m | diff /tmp/modules.before -; \
php-fpm -t
Key points:
- --fuzz=0: if the patch doesn't apply exactly, the build fails. "Close enough" is not accepted.
- The module list must be identical before and after. If an extension silently disappears, the application breaks in prod.
- Repackage the patched source as php.tar.xz, so that extensions compiled later with docker-php-ext-install also use the patched source.
- Leave a marker in the image (e.g. a PHP_BACKPORT environment variable and a file containing the patch hashes). Since PHP_VERSION doesn't change, this is where you read the answer to "is this image patched?".
4. CI: no evidence, no image
The pipeline has three steps:
- verify: The patch folder can't be empty; every
.patchfile must matchSHA256SUMS. - build + smoke test:
php -v,php-fpm -tand a check for the backport marker. - push: Only on the main branch and on scheduled runs. One moving tag (
8.1-fpm) and one fixed tag (8.1.34-fpm-<patch-id>) are pushed; the fixed tag is there for rollback.
On the application side, the only change is the base image line:
-FROM php:8.1-fpm
+FROM registry.example.com/base-images/php-backport:8.1-fpm
Each application is then rebuilt in the order test → passive prod → active prod, with a separate approval for each environment.
5. Don't forget
- Weekly scheduled rebuilds. If base images are only built when the Dockerfile changes, the underlying security updates never arrive.
- Leftovers baked into images. Scans like this can also surface hygiene issues, such as test pages embedded in the image or world-writable (777) directories. The patch closes the vulnerability, not the hygiene gaps.
- This is a bridge, not a destination. The cost of staying on unsupported versions keeps growing. The real plan is migrating to supported PHP and framework versions.
Lesson
"We can't change the code" doesn't mean "we can't close the vulnerability". Draw the line of responsibility correctly: the application code belongs to the development team, the runtime and the image belong to DevOps. In a critical vulnerability, DevOps' share of the work is often bigger than people assume.