The page
On 21 October 2026 the PHP project published PHP2Shell: two critical flaws in how PHP handles a request that ends badly. One comes from out-of-memory handling, the other from internal state left pointing at freed memory. Both can lead to remote code execution, neither needs a login, and they affect anything serving web requests through PHP: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. Fixed versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
PHP isn't a big part of our estate, which is exactly why it was worth a careful look. The PHP you forget about is the PHP that doesn't get patched.
Triage
First question: where does PHP actually run? Not where we think it runs.
# containers: every image running in the cluster that looks like PHP
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{range .spec.containers[*]}{.image}{" "}{end}{"\n"}{end}' \
| grep -i php | sort -u
# version and SAPI inside one of them
kubectl exec -n <ns> <pod> -- php-fpm -v
# VMs: packages, running processes, and whether Apache has mod_php loaded
dpkg -l | grep -E '^ii +php[0-9.]*-(fpm|cli|common)'
ps -eo pid,lstart,args | grep -E "php-fpm|php-cgi|httpd|apache2" | grep -v grep
apachectl -M 2>/dev/null | grep -i php
That gave two very different lists. One was a live service, a small PHP-FPM app in containers that people use every day. The other was a VM running Apache with mod_php and a folder of old scripts that nobody had mentioned in years.
Instance 1: the service we needed, patched
The container image was pinned to an older 8.3 patch release. The fix was a
base image bump to php:8.3.36-fpm, a rebuild, and the normal pipeline:
# confirm the new image really has the fixed version before it ships
docker run --rm --entrypoint php registry.example.com/app:candidate -v
# roll it out and watch it land
kubectl rollout status deploy/app -n <ns>
kubectl get pods -n <ns> -o jsonpath='{range .items[*]}{.spec.containers[*].image}{"\n"}{end}' | sort | uniq -c
On the VM side, the trap is the same as every runtime patch: the package updates but the running workers don't. Check that they restarted:
sudo apt-get install --only-upgrade php8.3-fpm php8.3-cli php8.3-common
sudo systemctl restart php8.3-fpm
ps -o lstart,args -C php-fpm8.3 # start times should be after the upgrade
php-fpm8.3 -v
While the rollout ran we used the published stopgaps (turning off
session.upload_progress and lowering post_max_size) and tightened the WAF
rules in front of it. Those only reduce exposure. The patch is the fix.
Instance 2: the scripts we didn't need, decommissioned
The mod_php box was a different story. Before patching it, I asked whether anything still used it.
# every PHP path requested in the last 90 days of access logs, busiest first
zcat -f /var/log/apache2/access.log* \
| awk '$7 ~ /\.php/ {split($7,a,"?"); print a[1]}' | sort | uniq -c | sort -rn
# every PHP file on disk, with its last change date
find /var/www -name "*.php" -printf "%TY-%Tm-%Td %p\n" | sort
# files nobody requested (paths relative to the docroot)
comm -23 <(cd /var/www/html && find . -name "*.php" | sed 's|^\.||' | sort) \
<(zcat -f /var/log/apache2/access.log* | awk '$7 ~ /\.php/ {split($7,a,"?"); print a[1]}' | sort -u)
# make sure an "unused" script isn't pulled in by one that is used
grep -rnE "(include|require)(_once)?" /var/www/html --include=*.php
Most of the folder hadn't been requested in the whole log window. The few hits were scanners and health checks, not people. So instead of patching scripts nobody used, we turned them off in stages:
- Return
410 Gonefor the paths and watch the logs and the support queue for two weeks. - Archive the folder (to object storage, with a note on what it was) so nothing is lost.
- Remove the scripts, then remove PHP and mod_php from the box entirely.
tar czf legacy-php-$(date +%F).tgz -C /var/www html
aws s3 cp legacy-php-$(date +%F).tgz s3://ops-archive/legacy-php/
sudo apt-get purge 'php8.1*' libapache2-mod-php8.1
What I learnt
The fastest patch is the one you don't need. Half the work of a big vulnerability is the inventory, and the inventory always finds something that should have been switched off long ago. Deleting it removes this risk and every future one.
What I do now
- Inventory by asking the systems (images, packages, processes), not the wiki.
- After patching a runtime, check the running processes restarted.
- For anything old, check the access logs before patching it. If nobody uses it, decommission it in stages.
- Treat stopgap config as a bridge to the patch, and take it out once the patch is in.
Advisory coverage: PHP ships emergency patches for critical PHP2Shell flaws.
stage 0 starts with the same inventory → get the free audit