All posts

PHP's October RCE: Patch First, Then Check What Your Version Number Hides

October 21, 2026Michał Woliński18 min read
PHP's October RCE: Patch First, Then Check What Your Version Number Hides

On 21 October 2026 the PHP project published two critical vulnerabilities, CVE-2026-109618 and CVE-2026-109619, together with PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Both are rated 9.2 under CVSS 4.0, both allow unauthenticated remote code execution, and for mod_php the project says an exploit was demonstrated where the request triggers the attack before any application code runs. Your framework, your WAF rules in PHP and your input validation never get a vote.

The advice is short: update. It is also the part that will go wrong today, in predictable ways. People check the PHP in their shell instead of the PHP that serves traffic. People trust a signal that does not mean what it seems to: a distribution version string that says “vulnerable” on a patched server, or an image tag that says “latest” on an unpatched one. And people copy interim settings that, on the oldest branches, are skipped without a word. This article walks through each, with a check script that runs on everything from PHP 5.6 to 8.5, and the interim settings for anyone who cannot restart production this afternoon.

Everything below was run on 21 October 2026 against the official php Docker images for 5.6, 7.4, 8.4 and 8.5, and against Ubuntu 24.04 and Rocky Linux 9 packages. Where something changed during the day, the time is given. I did not reproduce the exploit; this is about knowing where you stand and closing the door.

What was published

The changelog files the fix under Core as one change, Avoid state corruption via bailout. It is in the engine, so there is no extension to unload and no feature to switch off that would remove it.

  • CVE-2026-109618, RCE via OOM corruption. PHP leaves a failing request with longjmp(), the bailout. If memory runs out while request data is only partially initialized, request cleanup can later treat that half-built data as valid. The memory exhaustion is reachable from the outside: the advisory lists multipart requests without a usable Content-Length, multipart parts with unbounded header counts, and empty file controls that slip past max_file_uploads while still allocating metadata.
  • CVE-2026-109619, RCE via stale global state. A worker keeps global state (error, session, object and extension state) that points into memory owned by one request. That state has to be reset before the request memory is released. A fatal error or an out-of-memory bailout can skip the reset, and a later request on the same worker can read the dangling pointer before normal initialization clears it. Three confirmed paths: session upload progress, fatal error backtraces on PHP 8.5, and INI diagnostics emitted before request initialization.
BranchAffectedFixed inNote
8.5< 8.5.128.5.12also has the fatal_error_backtraces path
8.4< 8.4.278.4.27
8.3< 8.3.368.3.36
8.2< 8.2.358.2.35end of life on 31 December 2026
8.1, 8.0, 7.4assume yesno releasesource backport branches in php-src
7.3 and older, 5.6assume yesnothingyour vendor, or isolation (see below)

For 8.1, 8.0 and 7.4 the security team published minimal source backports (PHP-8.1-CVE-2026-109618-109619 and the same names for 8.0 and 7.4). That is unusual for branches years past end of life, and says something about how they rate the severity. There is no such branch for 7.3, 7.0 or 5.6.

Who is exposed, and in what order

The php.net advisory scopes the risk to PHP serving web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The order matters if you have more than one server to patch.

  1. Apache with mod_php first. The project rates it highest. For the OOM bug the reason is mechanical: mod_php runs the request shutdown procedure even when request startup failed, so the half-initialized state is guaranteed to be processed. Full RCE over HTTP was validated against mod_php with default PHP input and memory limits, which is what the official images ship with: they have no php.ini at all. The official php:*-apache images are mod_php.
  2. FrankenPHP next. The OOM advisory says it shows a similar pattern.
  3. PHP-FPM is not off the hook. FPM exits the child process when request startup fails, which blunts the OOM path. The stale-state path is different: the session upload progress RCE was validated against FPM as well.
  4. php-cgi and LiteSpeed are in scope; the advisories add nothing specific about them, so treat them like FPM.

The CLI is not on the advisory’s list, and I would not go further than that. A queue worker that never parses an HTTP request is not reached the same way, but the version it runs is usually the same package as the web SAPI, so it gets updated in the same step anyway.

Check the PHP that serves traffic

php -v answers a question nobody asked. The CLI binary and the FPM or Apache module come from different packages, can be different builds, and often read different configuration: on Debian and Ubuntu, /etc/php/8.3/cli and /etc/php/8.3/fpm are separate trees. Even in the official Apache image, where both use the same php.ini directory, the module sees the server’s php_admin_value lines and the CLI does not. The only answer worth having comes from inside the SAPI that answers HTTP.

This script reports the SAPI, the version against the fixed release for its branch, and the settings the advisory names, together with where each value came from. It is deliberately written in PHP 5.6 syntax, because the servers that most need checking are the old ones; my first version used match and died with a parse error on 7.4, exactly the branch it claimed to report on.

<?php // php-cve-check.php: CVE-2026-109618 / CVE-2026-109619 triage for the SAPI that runs this file // Deliberately PHP 5.6 syntax: no strict_types, no ??, no match, so it runs on the oldest branches too. $fixed = array('8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'); // No release, but the security team published source backports for these (php-src branches). $backported = array('7.4', '8.0', '8.1'); $settings = array( 'memory_limit', 'post_max_size', 'upload_max_filesize', 'max_input_vars', 'max_input_nesting_level', 'session.upload_progress.enabled', 'fatal_error_backtraces', ); $branch = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; if (in_array($branch, $backported, true)) { $status = "branch {$branch} has no release, only a source backport: vulnerable unless your build includes it"; } elseif (false === array_key_exists($branch, $fixed)) { $status = "branch {$branch} has no upstream fix at all: vulnerable unless your vendor patched it"; } elseif (version_compare(PHP_VERSION, $fixed[$branch], '>=')) { $status = 'patched'; } else { // Distributions backport the fix and keep the old number (Ubuntu 24.04 ships it as 8.3.6). $status = "below {$fixed[$branch]}: vulnerable unless your vendor backported the fix"; } $lines = array( sprintf('%-32s %s', 'sapi', PHP_SAPI), sprintf('%-32s %s', 'version', PHP_VERSION), sprintf('%-32s %s', 'status', $status), ); $all = ini_get_all(null, true); foreach ($settings as $name) { if (false === array_key_exists($name, $all)) { $lines[] = sprintf('%-32s %s', $name, '(not in this version)'); continue; } // global == local: set at startup (php.ini, FPM pool). Otherwise the value changed per request // or per directory, and only the server configuration is early enough to count. $global = $all[$name]['global_value']; $local = $all[$name]['local_value']; $source = $global === $local ? 'startup' : "per-request, startup value {$global}"; $lines[] = sprintf('%-32s %-8s %s', $name, var_export($local, true), $source); } if ('cli' !== PHP_SAPI) { header('Content-Type: text/plain'); } echo implode("\n", $lines), "\n";

Do not drop it into a public document root on a day like this; it discloses the exact version and limits. On FPM you do not have to. cgi-fcgi (package libfcgi-bin on Debian and Ubuntu, fcgi from EPEL on RHEL and its rebuilds) talks to the pool directly, and the file can live anywhere the pool can read it:

SCRIPT_FILENAME=/tmp/php-cve-check.php REQUEST_METHOD=GET \ cgi-fcgi -bind -connect 127.0.0.1:9000 # or the pool's socket, e.g. /run/php/php8.3-fpm.sock

For mod_php there is no such side door: put the file in the document root, request it once, delete it, or put it behind the same allow-list as your status page. Run against php:8.5.11-fpm — a pinned image one patch release below the fix, so the example stays reproducible after the moving tags move on — it prints:

sapi fpm-fcgi version 8.5.11 status below 8.5.12: vulnerable unless your vendor backported the fix memory_limit '128M' startup post_max_size '8M' startup upload_max_filesize '2M' startup max_input_vars '1000' startup max_input_nesting_level '64' startup session.upload_progress.enabled '1' startup fatal_error_backtraces '1' startup

That is the vulnerable version with both settings-controlled stale-state paths switched on, which are the defaults.

Two signals lie: the distro version and the image tag

The version check in the script is an upstream check. Whether it is right depends on where your PHP came from, and the two most common sources fail in opposite directions.

Distribution packages: patched, but the number may say vulnerable. Linux distributions received the patch in advance. Ubuntu backports it into the version it already ships: both CVEs are fixed on 24.04 LTS in 8.3.6-0ubuntu0.24.04.13. Installed in a clean ubuntu:24.04 container, that build reports itself as 8.3.6, thirty patch releases below 8.3.36, and it is patched:

$ dpkg-query -W -f='${Package} ${Version}\n' php8.3-cli php8.3-cli 8.3.6-0ubuntu0.24.04.13 $ php -r 'echo PHP_VERSION, PHP_EOL;' 8.3.6 $ zcat /usr/share/doc/php8.3-cli/changelog.Debian.gz | head -4 php8.3 (8.3.6-0ubuntu0.24.04.13) noble-security; urgency=medium * SECURITY UPDATE: Remote RCE via state corruption issue - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via

Debian went the other way and shipped the upstream releases: trixie has 8.4.27-1~deb13u1 (DSA-6574-1) and bookworm 8.2.35-1~deb12u1 (DLA-4848-1), so there the version string tells the truth; forky and sid were still vulnerable. Ubuntu fixed every supported branch — 22.04 (php8.1, 8.1.2-1ubuntu2.28), 24.04 (php8.3, 8.3.6-0ubuntu0.24.04.13) and the 26.x releases (php8.5) — and by that evening had its end-of-standard-support branches covered under Pro ESM as well: php5 on 14.04 as 5.5.9+dfsg-1ubuntu4.29+esm18, and php7.0, php7.2 and php7.4 on 16.04, 18.04 and 20.04. Note the numbers there: 14.04 ships php5 at 5.5, and the patched ESM build still reports 5.5.9 — the version string saying “ancient” on a server that is covered.

For distribution PHP the authority is therefore the package changelog, not PHP_VERSION. Both commands below were run; the second shows what “not yet” looks like:

# Debian, Ubuntu: the doc directories of the php packages are links to php*-common $ zcat /usr/share/doc/php*-common/changelog.Debian.gz | grep -m1 CVE-2026-109618 - CVE-2026-109618 # RHEL and rebuilds: Rocky Linux 9 ships PHP 8.0 by default, and has no entry yet $ rpm -q php-common php-common-8.0.30-8.el9_8.aarch64 $ rpm -q --changelog php-common | grep -c CVE-2026-109618 0

That is why the script says “vulnerable unless your vendor backported the fix” instead of a flat verdict. A scanner that only compares version strings will flag every patched Ubuntu server red today, and someone will start “fixing” a server that is already fixed, while a RHEL 9 server with no fix at all reports a branch that no upstream release will ever cover.

Official images: the tag said latest, the binary did not. The other direction is containers, and the gap stayed open for hours. When I first checked around 16:30 CEST, Docker Hub had no patched tags, and the moving tags php:8.5-fpm and php:8.5-apache, rebuilt in the early hours of 21 October, still contained 8.5.11. Around 17:40 CEST the tags 8.5.12-*, 8.4.27-*, 8.3.36-* and 8.2.35-* appeared, but as empty image indexes, with no image for any platform yet — which is what pulling one looked like at that point:

$ docker pull php:8.5.12-apache Error response from daemon: no matching manifest for linux/arm64/v8 in the manifest list entries: no match for platform in manifest: not found

At 18:25 CEST, a fresh docker pull php:8.5-apache still gave 8.5.11. The images landed around 21:00 CEST: the 8.5.12-* tags filled in across platforms, and the moving php:8.5-apache and php:8.5-fpm were rebuilt onto 8.5.12 in the same minute — their per-platform digests now match the pinned 8.5.12 tags exactly. For roughly five hours, then, “the patched tag exists” was not the same as “there is a patched image”, and a fresh pull of the moving tag got you a recently built image with the same vulnerable engine. The window closed; the next one, on the next release, opens the same way. Two practices outlast it:

  • Pin the exact version (php:8.5.12-fpm) in your own base image. During a window like that one the pipeline fails loudly instead of shipping 8.5.11 quietly, and it starts working the moment the image lands.
  • Prove it inside the running container, with the script above, not by the age of the image or the name of the tag.

Two more sources the advisory calls out. rpms.remirepo.net had no updates at first, and the advisory pointed anyone who could not wait to distribution packages or a source build; by that evening Remi had caught up and carried php-common-8.5.12 for EL9. And on 8.1 and older the upstream version number will never reach a fixed value; the script names the backport branch where one exists, and otherwise says there is no upstream fix at all.

If you cannot update today

Both advisories are explicit that only the update is a complete fix. For the stale-state bug: “There is no complete generic configuration mitigation.” The settings below reduce exposure to the confirmed paths and buy you the hours until the update lands. They do not make a server safe, and the script will keep saying so.

The one rule that applies to all of them: set them where nothing per request can undo them, and restart every worker. That means php.ini, the FPM pool, or the Apache server configuration, and never ini_set() or .user.ini.

For FPM, one pool file. The last line exists only on PHP 8.5; older FPM does not ignore it but logs ERROR: Unable to set php_admin_value 'fatal_error_backtraces' for every child it starts, which is a stream of noise in the one log you will be watching closely this week. Leave it out on 8.4 and older:

; /usr/local/etc/php-fpm.d/zz-cve-2026-1096xx.conf ; Interim exposure reduction for CVE-2026-109618 / CVE-2026-109619. Not a fix: update PHP. [www] php_admin_value[memory_limit] = -1 php_admin_value[post_max_size] = 8M php_admin_value[upload_max_filesize] = 2M php_admin_flag[session.upload_progress.enabled] = off ; PHP 8.5 only. Older FPM logs "Unable to set php_admin_value" for every child: leave it out there. php_admin_flag[fatal_error_backtraces] = off

For mod_php, the same in the server configuration, with one trap that cost me a test run. The module is called php5_module on 5.x, php7_module on 7.x and php_module from 8.0. A block guarded by the wrong name is skipped without a warning: with only <IfModule php_module>, the 5.6 and 7.4 containers kept memory_limit at 128M and upload progress on, and Apache started cleanly. Those are exactly the branches with no fix, where the interim settings are all you have. So the file carries all three:

# /etc/apache2/conf-enabled/zz-cve-2026-1096xx.conf # Interim exposure reduction for CVE-2026-109618 / CVE-2026-109619. Not a fix: update PHP. # The module name depends on the branch, and a wrong <IfModule> is skipped without a word. <IfModule php5_module> php_admin_value memory_limit -1 php_admin_value post_max_size 8M php_admin_value upload_max_filesize 2M php_admin_flag session.upload_progress.enabled off </IfModule> <IfModule php7_module> php_admin_value memory_limit -1 php_admin_value post_max_size 8M php_admin_value upload_max_filesize 2M php_admin_flag session.upload_progress.enabled off </IfModule> <IfModule php_module> php_admin_value memory_limit -1 php_admin_value post_max_size 8M php_admin_value upload_max_filesize 2M php_admin_flag session.upload_progress.enabled off # PHP 8.5 only; 8.0 to 8.4 ignore it silently. php_admin_flag fatal_error_backtraces off </IfModule>

Run through the script on 5.6, 7.4, 8.4 and 8.5, this file gives memory_limit -1 and upload progress 0 on all four, fatal_error_backtraces 0 on 8.5, and nothing in the Apache error log on any of them.

What each setting is for, because two of them look wrong at first sight:

  • memory_limit = -1 removes the trigger, not the bug. The OOM path needs a predictable, attacker-reachable allocation failure, and a finite memory_limit is exactly that. Unlimited removes the controllable failure; a real allocation failure can still reach the same code. It also removes your per-request memory guard, so pair it with a container or cgroup memory limit, preferably one that kills only the affected worker. The advisory warns against substituting RLIMIT_AS or RLIMIT_DATA: those make allocations fail, which is the vulnerable condition.
  • Small bodies, few parts. post_max_size of 8M without uploads, 32M with them, upload_max_filesize below it; the values above equal PHP’s defaults, and setting them with php_admin_value stops a per-directory setting from raising them. The advisory also recommends reducing max_input_nesting_level and bounding input variables, which is why the script prints both, though it gives no numbers. Because multipart requests without a usable Content-Length can get past the initial size check, the real limits belong at the proxy: decoded body size, field count, multipart part count, header count and nesting depth, including for requests without a Content-Length. If the application never takes multipart, refuse multipart at the edge.
  • session.upload_progress.enabled = off closes the confirmed session paths, including the one validated against FPM. Few applications use upload progress; check yours before turning it off.
  • fatal_error_backtraces = off on PHP 8.5 closes the fatal-backtrace path, which the advisory says is always missed during shutdown. The setting does not exist before 8.5.
  • pm.max_requests = 1 is the bluntest stale-state mitigation for FPM: no worker ever serves a second request, so there is nothing stale to read. On the 5.6 container four requests got four different PIDs. OPcache survives it, because its cache lives in memory shared by the whole pool, but every request now pays for forking a worker, so measure your throughput before you reach for it.

The advisory adds two that live in your code rather than your configuration: remove invalid or deprecated INI settings applied per request (in .htaccess, php_value, or PHP_VALUE FastCGI parameters), because the diagnostics they emit before initialization are one of the consumers; hiding them with display_errors or @ does not help. And fix attacker-reachable fatal errors and timeouts, which are what skips the reset in the first place.

What the check can and cannot tell you

With the pool file mounted, the FPM containers print what you would hope: every value set, every value from startup. And php_admin_* holds against the application, which is the point of it; on 5.6, 8.4 and 8.5, under both FPM and mod_php, ini_set() returns false and the value stays:

bool(false) string(2) "-1" bool(false) string(1) "0"

The trap is the obvious shortcut. Put session.upload_progress.enabled = 0 in a .user.ini in the document root, and ini_get() reports 0. A check that only reads values would call the server mitigated. The advisory says not to rely on .user.ini or ini_set(): the setting has to apply before request parsing, and it names php.ini, the FPM pool and the server configuration as the places that do. That is why the script compares the startup value with the current one:

session.upload_progress.enabled '0' per-request, startup value 1

Here is the honest limit of the check. On mod_php, the correct Apache configuration above shows the same per-request marker, because Apache applies php_admin_value per request too, and the advisory accepts server configuration as early enough. From inside PHP, a safe Apache setting and an unsafe .user.ini look identical. So startup proves the setting is in place before any request, and per-request means you have to know which file it came from. On FPM there is no ambiguity: pool settings report startup, and php-fpm -tt dumps the parsed pool so you can see them.

PHP 5.6 and the branches nobody patches

PHP 5.6 reached end of life at the end of 2018 and is still running in more places than anyone admits: the invoicing system nobody dares to touch, the shop on a hosting plan that was never migrated, the intranet behind a VPN that is not quite as private as it seems. For those servers the honest summary is short, and worse than for any other branch.

  • There is no upstream fix. Upstream has releases for 8.2 to 8.5 and backport branches for 7.4, 8.0 and 8.1. A PHP-5.6-CVE-2026-109618-109619 branch does not exist, and neither does one for 7.0. A vendor may still cover a specific build — Ubuntu patched its end-of-support packages under Pro ESM — but the oldest one there is php5 at 5.5 on 14.04, not 5.6; if what you run is upstream PHP 5.6, no one is shipping you a patch.
  • Whether 5.6 is exploitable is not published. The advisories give affected ranges only for supported branches, and nobody has said anything about 5.6 either way. The ingredients are there: the bailout is as old as the engine, and session upload progress has existed since 5.4. I did not try to exploit it. Treat it as vulnerable; a 5.6 server that is reachable from the internet should not be waiting for someone to find out.
  • What works on 5.6, verified on php:5.6-apache and php:5.6-fpm: the script runs and reports no upstream fix at all; the Apache file above takes effect through its php5_module block; the FPM pool file without the 8.5 line applies everything from startup with nothing in the log; php_admin_* refuses ini_set(); and pm.max_requests = 1 gives every request a new worker. fatal_error_backtraces does not exist there, so that path is not one you have to close.

Configuration on 5.6 is a stopgap with no end date, because no update is coming to end it. The order of what actually reduces risk:

  1. Take it off the internet. A 5.6 application that only staff use belongs behind a VPN or an authenticating proxy. The attack is unauthenticated for PHP; it is not unauthenticated for a proxy that refuses the request before PHP sees it.
  2. Filter at the proxy as described above, and on 5.6 be stricter: refuse multipart entirely if the application has no uploads, and cap body size and header count hard.
  3. Apply the interim settings with the right module name, plus pm.max_requests = 1 if it runs on FPM. A legacy application usually has the traffic to afford it.
  4. Ask your vendor, if there is one. Some distributions and commercial extended-support vendors still maintain 5.6 and 7.x builds; whether they cover these CVEs is a question for them, and the answer will be in the package changelog, not in PHP_VERSION.
  5. Plan the exit with a date on it. Every engine bug found from now on lands on a branch that will never get a fix. The cost of moving off 5.6 did not change this week; the cost of staying did.

The same applies, slightly less sharply, to 7.0 through 7.3. 7.4, 8.0 and 8.1 have the backport branches, which help if you build PHP from source and do not help if you install packages from a vendor that has not picked them up.

Why a shared-nothing runtime has shared state

PHP’s architectural promise is shared-nothing: every request starts clean, so a bad request cannot poison the next one. That is the reason most PHP code can be careless about global state in a way long-running Java or Node services cannot. The second CVE is a reminder of what the promise covers. It is a promise about your code. The engine itself keeps state in the worker, and the clean start depends on a reset that runs at the end of every request.

The bailout is how both bugs get past that reset. A fatal error or an exhausted memory limit does not unwind the stack the way an exception does. It jumps straight out, and any cleanup that was supposed to happen on the way back simply does not run. The fix’s title, Avoid state corruption via bailout, is the whole story in five words. Anything that reuses a worker across requests (FPM children, Apache processes, FrankenPHP) relies on that reset running every time, and mod_php is the worst case because it runs shutdown even when startup never finished.

The practical lesson is not new, but it is rarely acted on: attacker-reachable fatal errors and timeouts are not just bad error handling, they are an input to the engine. A request that reliably exhausts memory or hits max_execution_time on purpose was a denial-of-service concern yesterday. Today it is part of an RCE chain.

What this does not tell you

  • Whether you were hit. The advisory warns that stale state can be consumed “without a predictable log entry.” There are no published indicators of compromise. Unexplained worker segfaults and memory errors around unusual multipart traffic are worth a look in your logs, but a clean log proves nothing.
  • Whether an extension adds a path. The confirmed producers are session upload progress, fatal backtraces and early INI diagnostics. The advisory is explicit that an application, extension or server path may publish or consume stale state too. That is the reason no configuration counts as a fix.
  • Whether the script itself is right on your platform. It knows upstream versions and reads INI values. It does not know your vendor’s backports, and on mod_php it cannot tell a server setting from a per-directory one. Use it to find the servers that need attention, not to sign off on them.

Patch, then prove it

  1. Update to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or to your distribution’s patched package. On 8.1, 8.0 and 7.4, your LTS vendor or the backport branch. On 5.6 and the rest of 7.x, isolation first and an exit date.
  2. Restart every worker. A package upgrade that does not restart FPM or Apache leaves the old engine running.
  3. Prove it through the SAPI that serves traffic. Run the check through FPM with cgi-fcgi, or once over HTTP for mod_php, not php -v. For distribution packages, check the changelog for the CVE, not the version string. For containers, look at the version inside the running container, not at the tag or the build date.
  4. Until then, mod_php and FrankenPHP first: the pool or server settings above with the right module name, a proxy that refuses oversized and malformed multipart bodies, and a memory limit enforced by the container rather than by PHP.
  5. Afterwards, keep the proxy limits and the review of attacker-reachable fatal errors. They were worth having before this week.

The defaults are what got demonstrated, the moving image tags are what most deployments pull, the version string is what most scanners read, and the <IfModule> name is what nobody checks. The update itself is the easy part.