PHP2Shell, the pair of critical PHP bugs fixed on October 21, 2026, is the kind of vulnerability where php -v can mislead you in both directions. A fully patched Ubuntu 24.04 box still says 8.3.6. An updated host can keep serving requests from old workers. And the CLI you ran the check with may not be the PHP your web server loads. If you run PHP on the web, the question to answer today isn't "did we update?" but "what is the process answering requests actually running?"
CISA added both CVEs to its Known Exploited Vulnerabilities catalog on October 26, with a federal deadline of October 29, so this has moved past a routine patch cycle.
What PHP2Shell is and who is exposed
Both bugs are rated CVSS 9.2 and come from the same place: PHP stopping abruptly at a bad moment and leaving internal state behind.
CVE-2026-109618 is the out-of-memory bug. PHP used longjmp() to bail out on OOM, which could interrupt it while request data was only partly initialized, and shutdown would then treat that data as valid. The advisory lists multipart parsing paths that let a remote client force the OOM, including requests with no usable declared content length, which can get past the initial post_max_size check.
CVE-2026-109619 is a use-after-free across requests. A fatal error can leave request-owned state pointing at freed memory, and a later request on the same persistent worker uses it. Session upload progress is one of the confirmed routes, and it's on by default and works without the application ever calling session_start().
The PHP security advisory covers every installation serving web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The project rates mod_php highest, because a demonstrated exploit fires before any application code runs. FPM isn't a safe place to stand either. The OOM advisory reports no complete FPM exploit, but the session upload progress path was validated on a normal dynamic pool, and the PHP Foundation's writeup says RCE was confirmed on FPM through application-level code paths.
The fixed upstream versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. The project no longer ships releases for 8.1 and older, though it did publish minimal source backports for 7.4, 8.0 and 8.1 for people who build from source.
Why the version number can mislead you
If your PHP comes from a distribution, the number to compare is the package version. Debian's fixes follow the upstream numbers, but Ubuntu backports the patch and keeps the old base version, so its USN-8948-1 notice looks nothing like the upstream list:
| Release | Package | Fixed version |
|---|---|---|
| Ubuntu 26.10 | php8.5 | 8.5.9-0ubuntu2.1 |
| Ubuntu 26.04 LTS | php8.5 | 8.5.4-0ubuntu1.5 |
| Ubuntu 24.04 LTS | php8.3 | 8.3.6-0ubuntu0.24.04.13 |
| Ubuntu 22.04 LTS | php8.1 | 8.1.2-1ubuntu2.28 |
| Debian 13 (trixie) | php8.4 | 8.4.27-1~deb13u1 |
| Debian 12 (bookworm) | php8.2 | 8.2.35-1~deb12u1 |
So a scanner that compares 8.3.6 against 8.3.36 flags a patched Ubuntu server, and a person who reads the same number may waste a day on it. Ubuntu's older releases are fixed too, but through Ubuntu Pro; the CVE page lists each one. The Debian rows come from the Debian tracker.
The other trap runs the opposite way. A worker that started before the update can go on running the old code, so an updated package proves nothing until FPM, Apache or your FrankenPHP container has restarted. An image built before October 21 has to be rebuilt and redeployed, not just restarted.
Check the SAPI that serves traffic
Run the check through the web server, not the shell. The CLI and the web SAPI can use different binaries and different config files. This script reports what the serving process sees. Put it behind a restricted path, load it once, and delete it.
<?php
// Reports what this SAPI is really running. Serve it from a restricted path, then delete it.
header('Content-Type: text/plain');
$fixed = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
$branch = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
printf("sapi: %s\n", PHP_SAPI);
printf("version: %s\n", PHP_VERSION);
if (!isset($fixed[$branch])) {
echo "verdict: branch gets no upstream fix; check your vendor for a backport\n";
} elseif (version_compare(PHP_VERSION, $fixed[$branch], '>=')) {
echo "verdict: upstream version is fixed\n";
} else {
printf("verdict: below %s; vulnerable unless your distro backported the fix\n", $fixed[$branch]);
}
printf("hard limit directive: %s\n", ini_get('memory_hard_limit_percent') === false ? 'missing' : 'present');
foreach (['session.upload_progress.enabled', 'memory_limit', 'post_max_size', 'upload_max_filesize'] as $key) {
printf("%-32s %s\n", $key, var_export(ini_get($key), true));
}
We ran it on PHP 8.3.35, one release below the fix, first from the shell and then through PHP's built-in server, standing in for a web SAPI with its own configuration by passing the interim settings on its command line. Same binary, two different answers:
sapi: cli
version: 8.3.35
verdict: below 8.3.36; vulnerable unless your distro backported the fix
hard limit directive: missing
session.upload_progress.enabled '1'
memory_limit '128M'
post_max_size '8M'
upload_max_filesize '2M'
sapi: cli-server
version: 8.3.35
verdict: below 8.3.36; vulnerable unless your distro backported the fix
hard limit directive: missing
session.upload_progress.enabled '0'
memory_limit '-1'
post_max_size '32M'
upload_max_filesize '2M'
The hard limit directive line is a useful extra signal, because memory_hard_limit_percent only exists as of the fixed releases. Read it as a hint only. We didn't run a patched build or a distro backport, so "missing" on a backported package may not mean what it means on an upstream one. There, trust the package version.
If you can't patch today
The advisory is blunt that these settings reduce exposure and "do not make an installation safe". CISA's alert goes further: if you can't patch immediately, or you run 8.1 or earlier, take the system offline until it's patched or upgraded. If you're buying a few hours, the main settings are:
session.upload_progress.enabled=0, set where it applies before request parsing, followed by a restart of every worker.memory_limit=-1, enforced so applications can't override it, pluspost_max_sizeof 8M, or 32M if you need uploads, withupload_max_filesizebelow it.- On 8.5,
fatal_error_backtraces=0.
Don't reach for RLIMIT_AS or RLIMIT_DATA to contain the unlimited memory setting. The advisory warns they cause the very allocation failure that reaches the bug. A container or cgroup limit is the suggested boundary instead.
Where you set these matters more than it looks, and ini_set() is the wrong place for both. This run starts PHP with memory_limit=-1 and upload progress on, then tries from inside the script:
<?php
// Two mitigations, tried the way application code would try them.
var_dump(ini_set('session.upload_progress.enabled', '0'));
var_dump(ini_get('session.upload_progress.enabled'));
ini_set('memory_limit', '256M');
var_dump(ini_get('memory_limit'));
php -d session.upload_progress.enabled=1 -d memory_limit=-1 ini-set-demo.php
bool(false)
string(1) "1"
string(4) "256M"
The first call fails and changes nothing, because session.upload_progress.enabled is INI_PERDIR, and upload progress runs before your script would get the chance anyway. The last line is the sneakier failure: memory_limit is INI_ALL, so any plugin or framework that sets its own limit quietly restores the finite limit the mitigation removed. That's why PHP recommends php_admin_value for it. The advisory also rules out FPM .user.ini for the upload progress setting.
What changes after you patch
The OOM fix replaces the longjmp() with a different memory model, and it's the part of this release most likely to surprise you later. memory_limit is now a soft limit: an allocation that crosses it usually completes, and the familiar fatal error fires at the next safe point. A new hard limit sits above it, by default twice memory_limit up to 1G and memory_limit plus 1G beyond that. At the default 128M, a process can reach 256M.
Cross the hard limit and the manual says the process exits immediately with status 137. No shutdown functions, destructors or output handlers run, and the message goes straight to stderr, never to error_log. Since 128 plus a signal number is how shells report a process killed by a fatal signal, and SIGKILL is 9, a 137 here looks a lot like the kernel's OOM killer. If a worker starts dying with no PHP log line after this update, check the process's stderr before you go looking at the host. We've traced this behaviour to the manual and didn't run it on a fixed build.
Tidy up once you've patched, too. Remove any memory_limit=-1 you added, because with it there's no hard limit either, and a real limit is what you want from now on. Keep session.upload_progress.enabled=0 if you don't use upload progress bars. The advisory suggests that even on fixed versions, and there's a further reason: CVE-2026-115560, an unlikely but separate RCE vector that needs an application to switch session.serialize_handler to php at runtime, isn't fixed by these releases. PHP says the fix and a full advisory will come with the next regular release, and as of November 1 nothing newer has appeared on php.net.
No comment for this article.