How To

PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP

On 21 October 2026, PHP fixed CVE-2026-109618 and CVE-2026-109619 (CVSS 4.0 score 9.2), the remote code execution pair known as PHP2Shell. Both bugs sit in the PHP engine itself, not in a framework or a plugin. Any PHP that serves web requests and predates 8.5.12, 8.4.27, 8.3.36 or 8.2.35 is affected (the php2shell.com researchers trace affected releases back to PHP 5.4), and the attacks need no login, only HTTP requests to a PHP endpoint.

Original content from computingforgeeks.com - post 176644

This page answers the two questions that matter on your servers: is this PHP vulnerable, and is the fix actually running. We cover the fixed version for every common way PHP is installed, a read-only checker script that audits every PHP CLI and FPM binary in the usual paths on a host, the three ways to prove a fix, and a post-patch audit checklist. All of it was tested on 21 and 22 October 2026 on Ubuntu 26.04, 24.04 and 22.04, Debian 13 and 12 (VM and containers) and Rocky Linux 10, 9 and 8 lab VMs, with SELinux enforcing on the Rocky boxes, so no step turns it off.

What PHP2Shell Exposes

The two CVEs are separate bugs with one root cause: PHP could jump out of a request in the middle of updating its own state, then trust that half-written state later. The fix (a commit titled “Avoid state corruption via bailout”) stops PHP from bailing out at those points and resets leftover state before the next request:

  • CVE-2026-109618 (GHSA-w8hw-w9fx-j79w): running out of memory while PHP parses a multipart request body corrupts memory that request cleanup later uses.
  • CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp): state from one request survives into the next request on the same worker. The confirmed paths are session upload progress and, on PHP 8.5, fatal_error_backtraces.

The exposure depends on how PHP runs. PHP’s advisory rates Apache with mod_php the highest risk, because mod_php runs request shutdown even when request startup failed, so one anonymous request reaches the bug before your code runs. PHP-FPM is not a safe harbour either: PHP validated the session upload-progress attack against FPM, and its source review treats FPM as susceptible to the out-of-memory bug as well. The researchers at php2shell.com add a quieter problem, which is that the same bugs can leak memory from other requests, including passwords, tokens and API keys, without running any code.

On the day of release, CISA said it had not observed exploitation, while CERT-EU reported proofs of concept circulating. The CVE IDs began circulating the day before, with a wrong CVSS score of 10.0, and the PHP Foundation says no technical details leaked with them. Two things did slip out early: one of the reporting firms published a proof-of-concept video by mistake that evening, and Chainguard shipped container images carrying the fix more than six hours before the coordinated release. That is why proofs of concept were already circulating on release day. The PHP security team had still not identified a functional exploit when the fix shipped. Treat every internet-facing PHP server as a target until it is patched.

Fixed Versions by Platform

“Fixed” looks different depending on who built your PHP. Upstream, Debian, Red Hat’s supported streams, Amazon Linux, the Remi repository’s 8.2 to 8.5 streams, the sury.org packages, cPanel’s ea-php82 to ea-php85 and the official Docker images move to the new version number. Ubuntu, AlmaLinux’s own rebuilds, CloudLinux and every end-of-life backport (8.1, 8.0 and 7.4, whoever built it) keep the old number and change only the package release, so php -v cannot tell you anything there. This table combines our lab results with each vendor’s published status and repository contents as of 22 October 2026:

Where PHP comes fromFixed buildHow to confirmSource
php.net upstream8.5.12, 8.4.27, 8.3.36, 8.2.35versionPHP advisory
Ubuntu 26.04 / 24.04 / 22.04php8.5 8.5.4-0ubuntu1.5, php8.3 8.3.6-0ubuntu0.24.04.13, php8.1 8.1.2-1ubuntu2.28changeloglab + USN-8948-1
Debian 13 / 12php8.4 8.4.27-1~deb13u1, php8.2 8.2.35-1~deb12u1versionlab + DSA-6574-1, DLA-4848-1
RHEL 10 / 9 / 8 AppStream10: php 8.3.36, php8.4 8.4.27; 9: php:8.3 8.3.36, php:8.2 8.2.35, php 8.0.30-8.el9_8.1; 8: php:8.2 8.2.35, php:7.4 7.4.33-8version; changelog for 8.0 and 7.4RHSA-2026:87070, 87071, 87072, 87073, 87079, 87080, 87151
Rocky Linux 10 / 9 / 8the same builds as RHEL, in the repositories from 22 Octoberversion; changelog for 8.0 and 7.4Rocky AppStream repository
AlmaLinux 10 / 9 / 8own .alma.1 backports that keep the version (for example 8.4.24-1.el10_2.alma.1, 8.0.30-8.el9_8.alma.1), plus Red Hat’s 8.3.36 and 8.2.35 rebuilds on 10 and 9changelogAlmaLinux repository, package changelogs
Remi repository (EL 8, 9, 10)8.2.35 to 8.5.12; 8.1.34-6, 8.0.30-21, 7.4.33-31 backportsversion or changeloglab + Remi index
cPanel EasyApache 4ea-php82 to 85 bumped; ea-php74/80/81 backportedchangelogEA4 change log 25.91
DirectAdmin CustomBuild8.2 to 8.5 bumped; 8.1 and older marked with *da build versionsDirectAdmin forum (staff)
CloudLinux ea-php and alt-phpbackports that keep the old versionchangelogCloudLinux status page
Docker official php imagesphp:8.x.y tags for the fixed releasesversiondocker-library/php
Amazon Linux 2023php8.2 8.2.35, php8.3 8.3.36, php8.4 8.4.27, php8.5 8.5.12 (-1.amzn2023.0.1); php8.1 no fix plannedversionALAS2023-2026-3301 to 3304

Two gaps remain, and both leave a server looking patched when it is not. Red Hat fixed only the streams it still supports. RHEL 9’s php:8.1 stream (8.1.32) and RHEL 8’s php:8.0 stream (8.0.30-1) had no erratum on 22 October, so on RHEL and Rocky a clean dnf upgrade on those streams changes nothing and the fix has to come from Remi. AlmaLinux is the exception. It shipped its own .alma.1 backports for both streams (8.1.32-1 and 8.0.30-1). And PHP 7.3 and older (5.6, 7.0 to 7.3) have no free fix from PHP or most vendors. DirectAdmin has since added patches for those branches, but if your stack is not on that list, the only options are a paid long-term support build or a PHP upgrade.

Audit Every PHP Binary With the Checker

A single host often carries more PHP than its owner remembers: the distro PHP, a Remi or sury build for one application, an SCL package for an old one, and a control panel’s own copies. We wrote a read-only bash script that finds them all and judges each one the way the table above does. It changes nothing on the server. For every PHP binary it finds, it checks, in order:

  1. whether the version is at or above the fixed upstream release for its branch
  2. whether the owning package’s changelog names CVE-2026-109618 or CVE-2026-109619
  3. whether a DirectAdmin build is newer than DirectAdmin’s PHP2Shell patch file
  4. whether the new memory_hard_limit_percent setting exists, as a last hint

It audits PHP-FPM binaries as well as the CLI, reading each one through -i because php-fpm refuses -r. That matters on the RHEL family, where the php-fpm package does not pull in php-cli, so an nginx server built with dnf install php-fpm may have no php command at all. Symlinks and wrapper scripts are resolved to the binary they run. It does not audit Apache’s mod_php module, the php-cgi or lsphp binaries, or NGINX Unit’s PHP module, and its restart check does not cover long-running Swoole or RoadRunner workers. Check those through their package or vendor.

It then lists any web or PHP process still running a replaced library and warns when Apache loads mod_php. Create the script:

sudo vim /usr/local/bin/php2shell-check

Paste the full script. It is the exact file we ran on the Rocky Linux 9 and Ubuntu 22.04 VMs and in a Debian 13 container on 22 October:

#!/usr/bin/env bash
# php2shell-check: read-only audit of every PHP build on a host for
# CVE-2026-109618 / CVE-2026-109619 (PHP2Shell). Changes nothing.
# computingforgeeks.com
set -u

FIXED="8.2.35 8.3.36 8.4.27 8.5.12"
CVE_RE='CVE-2026-10961[89]'
rc=0
audited=0

ver_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; }

fixed_for_branch() {
  local b=$1 f
  for f in $FIXED; do [ "${f%.*}" = "$b" ] && { echo "$f"; return; }; done
}

pkg_changelog_has_fix() {
  local bin=$1 pkg
  if command -v dpkg >/dev/null 2>&1 && pkg=$(dpkg -S "$bin" 2>/dev/null | head -1 | cut -d: -f1) && [ -n "$pkg" ]; then
    zgrep -qE "$CVE_RE" "/usr/share/doc/$pkg/changelog.Debian.gz" 2>/dev/null && { echo "$pkg"; return 0; }
  elif command -v rpm >/dev/null 2>&1 && pkg=$(rpm -qf "$bin" 2>/dev/null) && [ -n "$pkg" ]; then
    rpm -q --changelog "$pkg" 2>/dev/null | grep -qE "$CVE_RE" && { echo "$pkg"; return 0; }
  fi
  return 1
}

directadmin_status() {
  local bin=$1 b=$2 patch
  patch=$(ls /usr/local/directadmin/custombuild/patches/php-"$b"/*cve-2026-109618* 2>/dev/null | head -1)
  [ -n "$patch" ] || return 1
  [ "$bin" -nt "$patch" ] && echo "built after $(basename "$patch")" || echo "older than $(basename "$patch"), rebuild"
}

candidates() {
  {
    command -v php
    ls /usr/bin/php[0-9]* /usr/sbin/php-fpm* /usr/local/sbin/php-fpm 2>/dev/null
    ls /opt/remi/php*/root/usr/bin/php /opt/remi/php*/root/usr/sbin/php-fpm 2>/dev/null
    ls /opt/cpanel/ea-php*/root/usr/bin/php /opt/cpanel/ea-php*/root/usr/sbin/php-fpm 2>/dev/null
    ls /opt/alt/php*/usr/bin/php /opt/alt/php*/usr/sbin/php-fpm 2>/dev/null
    ls /usr/local/php*/bin/php /usr/local/php*/sbin/php-fpm* /opt/plesk/php/*/bin/php /opt/plesk/php/*/sbin/php-fpm 2>/dev/null
    ls /usr/local/bin/php 2>/dev/null
  } | while read -r p; do
    [ -x "$p" ] || continue
    p=$(readlink -f "$p")
    [ -x "$p" ] || continue
    case $p in
      *fpm*) echo "$p" ;;
      # wrappers and panel shims exec the real binary, which PHP_BINARY names
      *) r=$("$p" -n -r 'echo PHP_BINARY;' 2>/dev/null); [ -x "$r" ] && readlink -f "$r" || echo "$p" ;;
    esac
  done | while read -r p; do
    case ${p##*/} in php|php[0-9]|php[0-9][0-9]|php[0-9].[0-9]|php-fpm|php-fpm[0-9]*) echo "$p" ;; esac
  done | sort -u
}

printf '%-48s %-10s %s\n' "BINARY" "VERSION" "STATUS"
for bin in $(candidates); do
  # php-fpm rejects -r, so read everything from -i, which every SAPI binary supports
  audited=$((audited + 1))
  if ! info=$("$bin" -i 2>/dev/null); then
    printf '%-48s %-10s %s\n' "$bin" "?" "SKIPPED (-i failed, run it by hand)"; rc=1; continue
  fi
  v=$(printf '%s\n' "$info" | awk -F' => ' '/^PHP Version => /{print $2; exit}')
  v=${v%%[-+~]*}
  if [ -z "$v" ]; then
    printf '%-48s %-10s %s\n' "$bin" "?" "SKIPPED (no version in -i output)"; rc=1; continue
  fi
  b=$(echo "$v" | cut -d. -f1,2)
  f=$(fixed_for_branch "$b")
  if [ -n "$f" ] && ver_ge "$v" "$f"; then
    st="FIXED (upstream $f or later)"
  elif pkg=$(pkg_changelog_has_fix "$bin"); then
    st="FIXED (vendor backport in $pkg)"
  elif da=$(directadmin_status "$bin" "$b"); then
    case $da in built*) st="LIKELY FIXED (DirectAdmin build newer than patch file)";; *) st="VULNERABLE (DirectAdmin: $da)"; rc=1;; esac
  elif printf '%s\n' "$info" | grep -q '^memory_hard_limit_percent '; then
    st="LIKELY FIXED (memory_hard_limit_percent present, no changelog proof)"
  else
    st="VULNERABLE"; rc=1
  fi
  up=$(printf '%s\n' "$info" | awk -F' => ' '/^session.upload_progress.enabled => /{print tolower($3); exit}')
  printf '%-48s %-10s %s, ini upload_progress=%s\n' "$bin" "$v" "$st" "${up:-n/a}"
done

if [ "$audited" -eq 0 ]; then
  echo "no PHP binary audited: none found in the usual paths, check where your PHP lives"
  rc=2
fi

echo
echo "Running PHP processes still mapping replaced files (restart these):"
stale=0
for pid in $({ pgrep -x 'httpd|apache2|lsphp|frankenphp|php-cgi[0-9.]*'; pgrep -f '^php-fpm: master'; } 2>/dev/null | sort -u); do
  if grep -qE ' /(usr|lib|opt).*\(deleted\)$' "/proc/$pid/maps" 2>/dev/null; then
    printf '  pid %-8s %s\n' "$pid" "$(tr '\0' ' ' < /proc/$pid/cmdline | cut -c1-70)"
    stale=1
  fi
done
[ "$stale" = 0 ] && echo "  none" || { [ "$rc" -eq 0 ] && rc=1; }

if command -v apachectl >/dev/null 2>&1 || command -v apache2ctl >/dev/null 2>&1; then
  if { apachectl -M 2>/dev/null || apache2ctl -M 2>/dev/null; } | grep -qi 'php[0-9_]*_module'; then
    echo
    echo "Apache loads mod_php, the highest-risk SAPI: after any PHP update, restart Apache fully, not a graceful reload."
  fi
fi

if [ "$(id -u)" -ne 0 ]; then
  echo
  echo "Not run as root: the restart check could not read every process. Run it again with sudo."
  [ "$rc" -eq 0 ] && rc=1
fi
exit $rc

Make it executable and run it as root, so it can read every process’s memory map. Call it by its full path: sudo on the RHEL family uses a secure_path without /usr/local/bin, and a bare sudo php2shell-check on our Rocky Linux 9 VM failed with “sudo: php2shell-check: command not found”:

sudo chmod 755 /usr/local/bin/php2shell-check
sudo /usr/local/bin/php2shell-check

In a Debian 13 container still on the stock 8.4.24 build, it flagged both the CLI and the PHP-FPM binary and exited with status 1:

BINARY                                           VERSION    STATUS
/usr/bin/php8.4                                  8.4.24     VULNERABLE, ini upload_progress=on
/usr/sbin/php-fpm8.4                             8.4.24     VULNERABLE, ini upload_progress=on

Running PHP processes still mapping replaced files (restart these):
  none

The Rocky Linux 9 VM is the harder case. Its default PHP 8.0 now comes from the Rocky rebuild of the Red Hat 8.0.30-8.el9_8.1 backport, and a Remi PHP 8.1 software collection runs next to it. Both keep their old version numbers, yet all four binaries came back clean, each with the package whose changelog proves it, and the script exited 0:

BINARY                                           VERSION    STATUS
/opt/remi/php81/root/usr/bin/php                 8.1.34     FIXED (vendor backport in php81-php-cli-8.1.34-6.el9.remi.x86_64), ini upload_progress=on
/opt/remi/php81/root/usr/sbin/php-fpm            8.1.34     FIXED (vendor backport in php81-php-fpm-8.1.34-6.el9.remi.x86_64), ini upload_progress=on
/usr/bin/php                                     8.0.30     FIXED (vendor backport in php-cli-8.0.30-8.el9_8.1.x86_64), ini upload_progress=on
/usr/sbin/php-fpm                                8.0.30     FIXED (vendor backport in php-fpm-8.0.30-8.el9_8.1.x86_64), ini upload_progress=on

Running PHP processes still mapping replaced files (restart these):
  none

We then removed php-cli and php81-php-cli from the same VM to mimic an FPM-only nginx box. With no php command left, the checker still audited both FPM builds:

BINARY                                           VERSION    STATUS
/opt/remi/php81/root/usr/sbin/php-fpm            8.1.34     FIXED (vendor backport in php81-php-fpm-8.1.34-6.el9.remi.x86_64), ini upload_progress=on
/usr/sbin/php-fpm                                8.0.30     FIXED (vendor backport in php-fpm-8.0.30-8.el9_8.1.x86_64), ini upload_progress=on

Running PHP processes still mapping replaced files (restart these):
  none

Read the result by its exit status, which drops straight into an Ansible task or a monitoring check:

  • Exit 0 means every binary it found reads FIXED or LIKELY FIXED and no PHP or web server process maps a replaced file.
  • Exit 1 means at least one binary is VULNERABLE, a binary was SKIPPED because -i failed, a process still needs a restart, or the script ran without root and could not read every process.
  • Exit 2 means it found no PHP binary at all, which on a web server means PHP lives in a path the script does not know, never that the host is clean.

Two columns need care. The ini upload_progress value comes from that binary’s own php.ini tree, so it misses a pool-level php_admin_value, and on the end-of-life backports it reads on even though the fix turns the feature off in code. The restart check flags any deleted library a running process still maps, so an openssl or glibc update trips it too, and restarting that service clears it either way. On DirectAdmin the script only compares the build time of the binary with DirectAdmin’s patch file, which is why it reports LIKELY FIXED there. The proof is in the build itself, as the DirectAdmin patch guide shows: the build log has no FAILED or “failed to apply” lines, and da build versions no longer offers an update for that slot.

Three Ways to Prove the Fix

A version number alone misleads on half the platforms in the table, so we use three kinds of evidence and trust the strongest one available on each build:

  • The version is at or above 8.5.12, 8.4.27, 8.3.36 or 8.2.35 on an upstream-numbered build.
  • The package changelog names CVE-2026-109618 or CVE-2026-109619 on a backported build.
  • The web server reports the patched version to a request from another machine.

The changelog check is the one that settles a backport. On Debian and Ubuntu it reads the local file, and on the RHEL family it asks rpm:

zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php8.3-fpm/changelog.Debian.gz
rpm -q --changelog php-fpm | grep -E 'CVE-2026-10961[89]'

Remi’s upstream-numbered builds (8.2.35 and newer) only say “Update to 8.3.36” in the changelog, so an empty grep there is normal and the version is your proof. The reverse holds for backports, including Red Hat’s 8.0 and 7.4 errata and AlmaLinux’s .alma.1 rebuilds: the version stays put and the changelog carries the evidence.

The fixed engine also gained a new ini setting, memory_hard_limit_percent, which makes a handy runtime hint. On a vulnerable build, ini_get returns false because the setting does not exist. On a full fix it returns an empty string. We saw the empty string on Ubuntu’s backported 8.5 and 8.3, Debian’s 8.4.27 and Remi’s 8.3.36. The end-of-life backports (Ubuntu’s 8.1, Remi’s 8.0 and 8.1) do not add it, so a false there does not mean vulnerable, and that is why the checker uses this only as a last resort.

For the outside view, the RHEL family leaves expose_php on, while Ubuntu sets it Off in the FPM and Apache php.ini (only the CLI file has it On, as our 22.04 VM showed). On the RHEL family, a plain header request from another host shows which build answered. Against our patched Rocky Linux 10 server it returned X-Powered-By: PHP/8.3.36. Turn expose_php off afterwards, because scanners read that header too.

Patch Guides by Platform

Each platform below has its own tested walkthrough, because the commands, the restart behaviour and the proof all differ:

Containers follow their own rule. A running container never updates itself, so pull the fixed php image, rebuild your own images with docker build --pull, and recreate the containers. FrankenPHP and other software that bundles its own PHP needs a new release of that software, not an apt or dnf update.

When Patching Has to Wait

PHP’s position is blunt: updating is the only complete fix, and no configuration closes every path. If a server genuinely cannot take the update today, the published mitigations narrow the confirmed attack paths while you schedule it. The two that matter most are switching off session upload progress in the configuration PHP-FPM actually reads (not the CLI’s, and never through ini_set() or .user.ini), and blocking multipart requests at the web server on sites that accept no uploads. Every mitigation, tested on nginx, Apache and Cloudflare with the pitfalls we hit, is in the PHP2Shell mitigation guide.

Once patched, the upgrade changes one behaviour you will see in logs. The fix adds a hard memory ceiling (by default twice memory_limit for limits up to 1G), and a PHP-FPM worker that crosses it ends with exit code 137 instead of failing gracefully. PHP calls _exit(137) itself, so the FPM log says “exited with code 137”, not “exited on signal 9”. We reproduced that and documented the log lines in the memory_hard_limit_percent explainer.

Post-Patch Audit Checklist

Run through this list on every PHP host before you mark it done. Each item is a condition that must hold, and the first four are the ones our lab VMs failed before the fix was complete:

  1. Every PHP binary on the host reports FIXED or LIKELY FIXED in php2shell-check, including FPM-only, SCL, panel and second-branch builds, and the script exits 0 when run as root.
  2. No PHP-FPM, Apache, LiteSpeed or FrankenPHP process still maps a deleted library.
  3. Apache with mod_php was fully restarted, not gracefully reloaded.
  4. The PHP-FPM build reports session.upload_progress.enabled as Off through php-fpm8.X -i on Ubuntu and Debian (php-fpm8.3, php-fpm8.4 and so on) or php-fpm -i on the RHEL family, never php -i, and no pool file switches it back on with php_admin_value.
  5. Any interim memory_limit = -1 or pm.max_requests = 1 has been reverted to a real value.
  6. A request from another machine is answered by the patched build.
  7. expose_php is Off once you no longer need the header for verification.
  8. Containers were rebuilt from the fixed base image and recreated, not just restarted.
  9. Servers on PHP 7.3 or older are offline, isolated behind the mitigations, or on a paid long-term support build, and have an upgrade date.

Item 4 is the one most often missed. On Ubuntu and Debian the CLI and PHP-FPM read different ini trees, so a shell test can show Off while every website still has the feature on.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) WireGuard VPN Setup: Ubuntu 26.04/24.04, Debian 13, Rocky 10 Debian WireGuard VPN Setup: Ubuntu 26.04/24.04, Debian 13, Rocky 10 Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Fedora Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Install Kali Linux 2026.3 Step by Step with Screenshots Security Install Kali Linux 2026.3 Step by Step with Screenshots Install and Use WPScan WordPress Security Scanner on Linux Security Install and Use WPScan WordPress Security Scanner on Linux

Leave a Comment

Press ESC to close