← all posts

PHP2Shell: what to patch and what to contain

How to patch, verify and contain PHP2Shell on servers and Kubernetes.

What Was Fixed

On 21 October 2026 PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35, fixing two critical remote code execution flaws: CVE-2026-109618 and CVE-2026-109619, nicknamed PHP2Shell. Details leaked a day early, so the release was brought forward. The issues were reported by researchers from Calif, Wiz, Areion and OpenAI and fixed by PHP core developers. Calif’s write-up covers the research; the two PHP advisories are the reference for which setups are affected.

In plain terms:

  • CVE-2026-109618: when a request runs PHP out of memory at the wrong moment, PHP abandons work halfway through, and the cleanup that runs afterwards trusts that half-finished state.
  • CVE-2026-109619: a PHP worker keeps some state between requests. After certain errors that state is not reset, and the next request handled by the same worker uses memory that has already been freed.

Both can be reached without authentication, and in the worst cases before any application code runs. The confirmed impact is code execution as the web server’s PHP user. A detailed public write-up with demo videos is already available, so treat this as urgent whether or not exploitation has been reported yet.

Who Is Most Exposed

  • Apache with mod_php: the most exposed setup. Full remote code execution was confirmed with default limits.
  • PHP-FPM: somewhat better placed, because a worker that fails during request startup is killed and replaced. The advisories still treat it as susceptible, and one of the session-related paths was validated against FPM.
  • FrankenPHP and LiteSpeed (LSAPI): considered likely susceptible from source analysis.
  • PHP 8.1 and older: no fix from PHP. These versions are likely affected too, so they depend on a distribution or LTS vendor backport, or an upgrade.

Patch, Then Prove It

The fix is the patched release. Everything else in this post reduces risk; it does not remove it.

A new package or image is not the same as a patched worker. Long-running PHP-FPM and Apache processes keep the old binary until they restart, and an image tag can point at yesterday’s build. Check the version that is actually running:

kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace} {.metadata.name}{"\n"}{end}' |
while read -r ns pod; do
  v=$(kubectl exec -n "$ns" "$pod" -- php -r 'echo PHP_VERSION;' 2>/dev/null </dev/null) && echo "$ns/$pod $v"
done

This checks the first container of every pod, which also catches images that do not have “php” in their name. Anything below 8.5.12, 8.4.27, 8.3.36 or 8.2.35 still needs work. A few things that are easy to miss:

  • If your Dockerfiles pin a base image by digest, the digest needs bumping. A pinned image will not pick up a fixed release on its own.
  • Distribution packages carry their own version strings. Check your distribution’s security tracker for the two CVE IDs instead of comparing against upstream numbers.
  • Queue workers, cron containers and CI images share the same binary. Patch them in the same pass.

On Debian, Ubuntu and RHEL Servers

Start by finding out where your PHP packages come from, because that decides which version number counts as fixed:

# Debian / Ubuntu: the suite shows the source (e.g. trixie-security)
apt list --installed 'php*' 'libapache2-mod-php*'
# RHEL, Rocky, Alma: the repo column shows appstream or remi
dnf list --installed 'php*'
  • Distribution packages backport fixes, so their version numbers differ from upstream. Debian has fixed builds: 8.4.27-1~deb13u1 for trixie (DSA-6574-1) and 8.2.35-1~deb12u1 for bookworm (DLA-4848-1). Ubuntu and Red Hat had not published entries for these CVEs when this was written. Follow their security trackers, and confirm that the package changelog mentions the CVE IDs (apt changelog php8.4-fpm or rpm -q --changelog php-fpm).
  • Ondřej Surý’s repository (Debian and Ubuntu) and Remi’s repository (RHEL and derivatives) package upstream releases. Both published 8.4.27 on release day, and Surý also published 8.3.36.
  • PHP 8.1 and older from your distribution, such as 8.1 on Ubuntu 22.04, is fixed only if the distribution backports the patch.

Upgrade, then restart everything still running the old binary:

# Debian / Ubuntu
sudo apt update && sudo apt upgrade
sudo needrestart -r l                        # list services on old binaries
sudo systemctl restart php8.4-fpm apache2

# RHEL family
sudo dnf upgrade 'php*'
sudo dnf needs-restarting -s
sudo systemctl restart php-fpm httpd

php -v only reports the CLI. Check the FPM binary (php-fpm8.4 -v on Debian and Ubuntu, php-fpm -v on RHEL) and every installed version. Surý’s packages let several versions run side by side, each with its own service, and systemctl list-units 'php*-fpm*' lists them.

If apache2ctl -M lists php_module, you are running mod_php, the most exposed setup. On Debian and Ubuntu, switching to FPM takes three commands: a2dismod php8.4 mpm_prefork, a2enmod mpm_event proxy_fcgi setenvif and a2enconf php8.4-fpm. RHEL already pairs httpd with php-fpm by default.

The stopgaps below go in a drop-in file: /etc/php/8.4/fpm/conf.d/99-php2shell.ini on Debian and Ubuntu, or /etc/php.d/99-php2shell.ini on RHEL. To bound memory outside PHP, a systemd drop-in with MemoryMax= on the FPM service works like a container limit, with the same drawback: it can kill the whole service at once.

If You Cannot Patch Today

The advisories list stopgaps. Set them administratively (in php.ini, the FPM pool or with php_admin_value) so applications cannot override them, then restart every worker:

; Stopgap until patched. Revert memory_limit afterwards.
memory_limit = -1
session.upload_progress.enabled = 0
fatal_error_backtraces = 0   ; PHP 8.5
  • Memory: removing PHP’s own limit takes away the predictable out-of-memory point the confirmed exploit relies on. Bound memory with the container limit instead. Do not use ulimit -v or similar address-space limits, which make the vulnerable path more likely. A container limit applies to every worker in the pod, so one large request can take them all down.
  • Request size at the edge: enforce body limits before PHP sees the request (client_max_body_size in nginx, proxy-body-size on ingress-nginx). Keep post_max_size conservative, and lower max_input_nesting_level, max_input_vars and max_file_uploads. If an endpoint never takes uploads, reject multipart/form-data requests to it.
  • Per-request configuration: stop passing INI values per request through PHP_VALUE or PHP_ADMIN_VALUE from nginx, and remove php_value and php_flag from .htaccess. Warnings raised while applying those values are one of the triggers. Remove deprecated directives from your configuration for the same reason.
  • Worker recycling: pm.max_requests = 1 stops state carrying from one request to the next, at a real performance cost.

Session upload progress is rarely used. Leave it disabled after patching unless you need it.

After Patching, Memory Behaves Differently

The fix changes what happens when a request exceeds memory_limit. Instead of stopping immediately, PHP raises the error a moment later at a safe point. A second, higher limit, memory_hard_limit_percent, kills the worker outright if a request keeps growing. It defaults to 200% of memory_limit, or memory_limit plus 1 GB once the limit is above 1 GB.

So a single request can now briefly use up to twice its configured limit. With pm.max_children = 10 and memory_limit = 256M, the worst case is about 5 GB rather than 2.5 GB. Check container memory limits and requests against that figure, for the reasons covered in Resource requests are load-bearing. Expect a new log line when the hard limit is hit, “Hard memory limit of … bytes exhausted”, followed by the worker exiting.

Limit What a Compromised Worker Can Reach

This will not be the last PHP remote code execution flaw, so make sure an attacker on a worker gets as little as possible:

automountServiceAccountToken: false
containers:
  - name: php
    securityContext:
      runAsNonRoot: true
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]

A read-only root filesystem needs writable emptyDir volumes for temporary files, sessions and uploads. Add a default-deny egress NetworkPolicy so web pods can only reach their database, cache and the APIs they actually call. Mount only the secrets each application needs, and give its database user only the privileges it uses.

Then watch for signs that something is probing:

  • PHP workers crashing, such as FPM logging a child that “exited on signal 11”
  • bursts of “Allowed memory size exhausted” errors on POST requests to routes that should not use much memory
  • shells or download tools started by PHP workers
  • unexpected outbound connections from web pods

None of this replaces the patch, but it limits the damage the next vulnerability can do.

Comments

Loading comments…

← older
From CakePHP to Laravel