PHP2Shell: Check, Patch and Restart Your PHP Servers
On 21 October 2026 the PHP project released PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. They fix two critical bugs, CVE-2026-109618 and CVE-2026-109619, which are known together as PHP2Shell. Both have a CVSS v4.0 score of 9.2. In the worst case, one request from an attacker with no login can run code on your web server.
The release came out one day early because the CVE numbers leaked on 20 October. One of the firms that found the bugs also published a video of its attack by mistake. It was deleted, but copies spread and proof of concept code started to appear. Do not wait for your normal update day.
This post explains the two bugs in plain words. Then it shows how to find the PHP that really serves a site, update it, restart it, and what to do if you cannot update today. I tested everything in Docker containers with the official php images, Debian 13 and Ubuntu 24.04, on Bash 4.4.20 and 5.2.37.
What the two bugs are
CVE-2026-109618: out of memory. When a request reaches memory_limit, PHP stops it at once and jumps out of the running code. If this happens at the wrong moment, some internal request data is only half built. The cleanup code then reads that data as if it were complete. An attacker can force this with a crafted multipart request that uses far more memory inside PHP than its own size. With Apache and mod_php, this happens while PHP reads the request body, before any script runs. The researchers got code execution there with default settings.
CVE-2026-109619: stale state. One PHP worker serves many requests, one after another. After a fatal error at the wrong point, some global data in the worker can still point to memory from the previous request. That memory is already freed. The next request on the same worker can use it before PHP resets it. The confirmed paths go through the session upload progress feature and, on PHP 8.5, the fatal_error_backtraces setting.
In both cases the attacker runs code as the web server user, such as www-data. The researchers could also read worker memory, which can hold passwords and API keys. The details are in the advisories GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp.
Who needs to update
Every PHP installation that serves web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is highest with mod_php, because the attack works before your code runs. PHP-FPM is not safe either. The PHP team also confirmed code execution there, through paths in application code. Command line scripts are not on the list, but update the CLI package with the others anyway.
| Branch | Fixed in | Note |
|---|---|---|
| 8.5 | 8.5.12 | |
| 8.4 | 8.4.27 | |
| 8.3 | 8.3.36 | |
| 8.2 | 8.2.35 | End of life on 31 December 2026 |
| 8.1 and older | No release | Vendor packages or source backports |
For PHP 7.4, 8.0 and 8.1 the PHP team made small backport branches in the php-src repository, for people who build from source. Everyone else on an old branch needs a fixed package from their vendor, and a plan to move to a supported branch.
Find the PHP that serves the site
php -v is not enough. It shows the command line binary, and the site may use a different one through an FPM pool or an Apache module. On servers with several PHP versions, it can even be a different branch.
The surest way is to ask the web server. Put a small file with a random name in the document root:
<?php
header('Content-Type: text/plain');
printf("%s %s\n", PHP_VERSION, PHP_SAPI);
foreach (['memory_limit', 'post_max_size', 'session.upload_progress.enabled'] as $name) {
printf("%s = %s\n", $name, ini_get($name));
}Then request it with curl. The first test server is nginx with an older php:8.3-fpm-alpine image. The second is php:8.4-apache, pulled after the release:
$ curl -s http://127.0.0.1:8081/check-5f2c9a.php
8.3.35 fpm-fcgi
memory_limit = 128M
post_max_size = 8M
session.upload_progress.enabled = 1
$ curl -s http://127.0.0.1:8082/check-5f2c9a.php
8.4.27 apache2handler
memory_limit = 128M
post_max_size = 8M
session.upload_progress.enabled = 1The first line is the version and the SAPI: fpm-fcgi means PHP-FPM and apache2handler means mod_php. The other lines are settings that the temporary fixes below change. Delete the file when you are done.
A Bash check for the fixed versions
Version numbers are easy to misread across many servers. This function knows the first fixed release of each branch:
# php_patched VERSION
# Report whether a PHP version string includes the fixes for
# CVE-2026-109618 and CVE-2026-109619 (PHP2Shell, October 2026).
# Returns 0 if patched, 1 if not, 2 on bad usage.
php_patched() {
local version=${1-} branch patch fixed
if [[ ! $version =~ ^([0-9]+\.[0-9]+)\.([0-9]+) ]]; then
printf 'php_patched: not a PHP version: %s\n' "$version" >&2
return 2
fi
branch=${BASH_REMATCH[1]}
patch=${BASH_REMATCH[2]}
case $branch in
8.5) fixed=12 ;;
8.4) fixed=27 ;;
8.3) fixed=36 ;;
8.2) fixed=35 ;;
*)
printf '%s: no upstream release with the fix, ask your vendor\n' "$version"
return 1
;;
esac
if (( patch >= fixed )); then
printf '%s: patched\n' "$version"
else
printf '%s: NOT patched, update to %s.%s or later\n' "$version" "$branch" "$fixed"
return 1
fi
}The regular expression takes the branch and the patch number from the start of the string. It ignores the rest, such as -1~deb13u1 in a Debian package version. The return code is 0 for patched, 1 for not patched and 2 for bad input. So the function works in if tests and loops, and ${1-} keeps it safe under set -u.
Feed it the first word of the check file output:
$ source php_patched.sh
$ php_patched "$(curl -s http://127.0.0.1:8081/check-5f2c9a.php | awk 'NR == 1 {print $1}')"
8.3.35: NOT patched, update to 8.3.36 or later
$ php_patched "$(curl -s http://127.0.0.1:8082/check-5f2c9a.php | awk 'NR == 1 {print $1}')"
8.4.27: patchedIt works for container images too. The 8.3 image on my machine was pulled three weeks before the release:
$ for image in php:8.5-cli-alpine php:8.3-fpm-alpine php:8.1-fpm-alpine; do
> php_patched "$(docker run --rm "$image" php -r 'echo PHP_VERSION;')"
> done
8.5.12: patched
8.3.35: NOT patched, update to 8.3.36 or later
8.1.34: no upstream release with the fix, ask your vendorAn image keeps the PHP it was built with. Pull the base image again and rebuild every image built before 21 October.
One warning: the function trusts the upstream version number. Debian 13 moved to the new upstream release, so the number is correct there. Ubuntu and the Red Hat family often keep the old number and add the fix as a patch. Ubuntu 24.04 still reports 8.3.6 after the fix:
$ apt-cache policy php8.3-fpm | head -3
php8.3-fpm:
Installed: 8.3.6-0ubuntu0.24.04.13
Candidate: 8.3.6-0ubuntu0.24.04.13
$ zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php8.3-common/changelog.Debian.gz
- debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
- CVE-2026-109618
- CVE-2026-109619Here php_patched 8.3.6 would be wrong. On these systems, search the package changelog for the CVE numbers instead. On the Red Hat family, use rpm -q --changelog php-common | grep CVE-2026-109618.
Update, then restart
Install the fixed packages from your distribution. On Debian 13 they come from trixie-security:
$ apt-cache policy php8.4-fpm
php8.4-fpm:
Installed: 8.4.27-1~deb13u1
Candidate: 8.4.27-1~deb13u1
Version table:
*** 8.4.27-1~deb13u1 500
500 http://deb.debian.org/debian-security trixie-security/main amd64 Packages
100 /var/lib/dpkg/status
8.4.24-1~deb13u1 500
500 http://deb.debian.org/debian trixie/main amd64 PackagesIf you use the Remi repository, note that on release day the PHP team warned it would not have updates that day. Check that the new packages are really installed.
Then restart PHP. The PHP-FPM master and its workers keep the old code in memory until they stop. With mod_php the same is true for the Apache processes. A package manager puts a new file in place of the old one. A process that still uses the old file sees it as (deleted) in /proc/PID/maps. This script lists those processes. The pattern matches the FPM binary, the Apache module and the extension files:
# php-stale.sh: list processes that still map a deleted or replaced PHP file
for maps in /proc/[0-9]*/maps; do
if grep -qE '/[^ ]*php[^ ]* \(deleted\)$' "$maps" 2>/dev/null; then
pid=${maps#/proc/}
pid=${pid%/maps}
ps -o pid=,user=,args= -p "$pid"
fi
doneRun it as root, because the workers belong to another user. This is a Debian 13 test container just after the upgrade:
$ sudo bash php-stale.sh
6205 root php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf)
6206 www-data php-fpm: pool www
6207 www-data php-fpm: pool wwwAfter a restart the script printed nothing:
sudo systemctl restart php8.4-fpm # PHP-FPM on Debian and Ubuntu
sudo systemctl restart apache2 # mod_php on Debian and Ubuntuneedrestart on Debian and dnf needs-restarting on the Red Hat family do the same job for all services.
If you cannot update today
Only the update is a complete fix. The settings below block some known attack paths while you wait. Set them in the server configuration, not in .htaccess, .user.ini or ini_set(), and restart every worker.
For a PHP-FPM pool:
; /etc/php/8.4/fpm/pool.d/zz-php2shell.conf
[www]
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1For Apache with mod_php:
# /etc/apache2/conf-enabled/php2shell.conf
<IfModule php_module>
php_admin_flag session.upload_progress.enabled off
php_admin_value memory_limit -1
</IfModule>After a restart, the check file on the FPM test server shows the new values:
$ curl -s http://127.0.0.1:8081/check-5f2c9a.php
8.3.35 fpm-fcgi
memory_limit = -1
post_max_size = 8M
session.upload_progress.enabled = 0These are admin values, so the application cannot change them back. On both test servers, ini_set('memory_limit', '128M') returned false.
session.upload_progress.enabledoff: blocks the confirmed session path of CVE-2026-109619. If you do not use the feature, keep it off after the update too.memory_limit-1: removes the memory limit error that an attacker can trigger for CVE-2026-109618. But now one request can use all the memory of the server, so limit the service instead, for example with systemdMemoryMax=. Do not useulimit -vorulimit -d, because they cause the kind of failure the attack needs.post_max_size: the PHP team suggests 8M for sites without uploads and 32M with uploads, withupload_max_filesizebelow it.fatal_error_backtraces: on PHP 8.5, set it to 0.
The full list is in the PHP security advisory. If none of this is possible soon, take the site offline until it is updated.
After the update
Put a normal memory_limit back. Then look for signs of an attack since 20 October. A failed attempt can crash a worker. A crash looks like this in the PHP-FPM log and the Apache error log. I made these two lines by killing a worker by hand:
WARNING: [pool www] child 6 exited on signal 11 (SIGSEGV) after 125.666146 seconds from start
AH00052: child pid 17 exit signal Segmentation fault (11)A successful attack often leaves a new PHP file behind:
$ find /var/www -name '*.php' -newermt 2026-10-20 -printf '%TY-%Tm-%Td %TH:%TM %p\n'
2026-10-21 23:12 /var/www/example.com/wp-content/uploads/2026/10/cache-a1b2.phpYour own deploys and plugin updates show up here too, so check each file. A PHP file in an uploads folder is a strong warning sign. A clean result is not proof, because an attacker can reset a file time. If you find signs of an attack, treat the server as compromised and change the passwords and keys in its configuration files.