TL;DR
Two major vulnerabilities have been found in the PHP interpreter itself. There are routes to update, and there are some blockers for older distros and language versions. In this post i explain what to do, what not to do, and how you can fix this.
If you are still unsure what to do with all of this, or you can’t make sense of the whole issue, send this blog post to your agent. Tell it to read it, understand it, and fix your situation. There is enough context in here for any reasonably good agent to work it out.
What Happened
On October 21 PHP shipped an emergency release for two critical vulnerabilities in the interpreter itself. Not in WordPress, not in some plugin. In PHP. If you run a PHP site on a version before 8.2.35, 8.3.36, 8.4.27 or 8.5.12, an unauthenticated person on the internet may be able to run commands on your server as your web user.
Both are scored CVSS v4.0 9.2, and the nickname that stuck is PHP2Shell. This post goes through what the bugs are, why they are dangerous, how to patch, and what to do if your distro stopped shipping PHP updates years ago.
The short version
| CVEs | CVE-2026-109618 and CVE-2026-109619 |
| Severity | Critical, CVSS v4.0 9.2 (both) |
| Impact | Unauthenticated remote code execution as the web worker user |
| Affected | PHP 8.2 before 8.2.35, 8.3 before 8.3.36, 8.4 before 8.4.27, 8.5 before 8.5.12. Older branches (8.1 and below) are reported as affected too, and will never get an upstream fix |
| Fixed in | 8.2.35, 8.3.36, 8.4.27, 8.5.12 |
| Needs | A network path to any PHP endpoint. No login, no upload form, no vulnerable plugin |
| Real fix | Update PHP and restart every PHP process. The PHP team says updating is the only complete mitigation |
Two things made this one ugly. The bugs sit in parts of PHP that every single request goes through, so it does not matter what your application does. And details leaked a day early, so people were being attacked before the fix was out.
What the problem actually is
The two bugs are different, but they are related, and both come down to the same thing: PHP leaves its internal state half-built or stale when a request dies in the wrong place, and then trusts that state later.
CVE-2026-109618, the out-of-memory one
PHP enforces memory_limit. When a request goes over it, PHP does not unwind politely. It uses longjmp() to jump straight out of whatever it was doing and abort the request.
The problem is that longjmp() skips everything in between. If PHP was in the middle of setting something up, and that thing was already reachable from request memory but not fully initialised yet, it stays half-built. The failed allocation does not even have to be related to the half-built structure. Any allocation failing at that moment is enough.
Then request shutdown runs, and shutdown reads that structure like it is valid.
An illustration of the pattern, not real PHP code:
/* simplified, illustrative only */
struct thing *t = emalloc(sizeof(*t)); /* t is now reachable from request state */
request_state->current = t; /* published... */
t->buffer = emalloc(big_size); /* ...but not initialised yet.
if THIS allocation hits memory_limit,
the engine longjmp()s out right here */
t->len = big_size;After the jump, request_state->current points at a struct whose fields are garbage. Request shutdown walks it, reads a pointer out of it, and frees or calls through that pointer. That is a use-after-free, and with the right heap layout, an attacker steers it into code execution. The advisory lists it under CWE-416 (use after free), CWE-824 (uninitialised pointer) and CWE-755 (improper handling of exceptional conditions).
How does an attacker make a request hit the memory limit when the request is inside post_max_size? The advisory lists a few multipart parsing problems. A multipart body with no usable declared length can slip past the initial size check. A single part can carry a huge number of headers, and PHP’s in-memory representation of those is far bigger than what was on the wire. Empty file fields do not count against max_file_uploads but still build metadata arrays. So the request looks small and legal, and PHP runs itself out of memory while parsing it.
Here is why it matters which SAPI you run:
| SAPI | Exposure for -109618 |
|---|---|
Apache mod_php | Worst case. Shutdown runs even when request startup failed, so the invalid state is consumed before any application code runs. Full RCE was demonstrated with default memory limits |
| PHP-FPM | The child exits when request startup fails, so the direct path is closed. Source analysis still says it is susceptible, and the PHP Foundation says RCE was confirmed on FPM through application-level code paths |
| FrankenPHP | Calls shutdown unconditionally like mod_php, so likely susceptible |
| LSAPI | Models FPM. Application-specific exploits likely but unvalidated |
CVE-2026-109619, the stale state one
PHP workers are long-lived. FPM or mod_php handles thousands of requests in one process, and PHP is supposed to reset all per-request global state between them. It keeps globals for errors, sessions, objects and extensions, and those globals point at memory owned by the current request.
The bug: some of those globals do not get reset when a request ends badly. A fatal error, an out-of-memory, an execution timeout can abandon an operation before its matching cleanup runs. The request heap then gets freed, and the global still points into it.
/* simplified, illustrative only */
static struct error_state *G_last_error; /* lives as long as the worker */
void handle_request(void) {
G_last_error = request_alloc(...); /* points into THIS request's heap */
do_work(); /* fatal error here skips the reset below */
G_last_error = NULL; /* never reached */
} /* heap freed. pointer still set */The next request lands on the same worker. Before PHP finishes initialising that request, some early code runs and touches G_last_error. That code reads, frees, or branches on a pointer into memory that was freed and probably reallocated to something else. Another use-after-free.
The advisory calls the two halves a producer and a consumer. The producer leaves the stale pointer. The consumer, a different request on the same worker, uses it before initialisation resets it. Two confirmed consumers:
- INI diagnostics. A warning or deprecation raised while applying per-request INI settings (
.htaccessphp_valueon mod_php, orPHP_VALUEparams sent from a frontend to FPM) touches the stale error state. It does not need to be displayed or logged, sodisplay_errors=0and@do not help. - Session upload progress. PHP’s upload-progress feature runs session handling before normal initialisation, so it picks up stale session or decoder state. This is on by default and does not need your code to call
session_start().
A confirmed producer on PHP 8.5 is fatal_error_backtraces: with it on, fatal error reporting stores backtrace state that is never reset at shutdown.
Both bugs end up at the same place. A use-after-free on the heap, in an unauthenticated code path, reachable before the application does anything.
Why this is an attack surface
Who can use it: anyone who can send an HTTP request to a PHP endpoint. No account, no cookie, no user interaction. Both CVSS vectors read network, no privileges, no user interaction. The one thing that keeps them at 9.2 and not 10 is that attack complexity is rated high, because exploitation is not a single clean request.
What they get: code execution as the web worker account, www-data or whatever your pool runs as. That sounds contained until you remember what that account can read. Your application config, database credentials, API keys in .env, session files, uploaded content, and usually write access to the web root, which is how a one-time compromise turns into a persistent backdoor.
How it works, at the level that matters for defence and no further:
- The attacker sends requests that push PHP into a bad state, either by running a request out of memory or by hitting a fatal error at a chosen point. Multipart bodies are a convenient way to do that because PHP parses them before your code runs.
- For -109618 on mod_php, that bad request is enough. Shutdown consumes the broken state immediately.
- For -109619, a second request on the same worker consumes what the first left behind. The attacker does not control which worker they land on, so they just send more requests. The advisory says more workers and unrelated traffic raise the number of attempts needed, but do not remove the condition.
- A tuned memory layout turns the memory corruption into code execution. This is the part that takes skill. It is also why the attack takes more than one try, and why researchers say it works against default configs on mod_php.
Two practical consequences:
- Application-level auth does not save you. The malicious request is handled by PHP before your framework boots. A login wall, a WAF rule on your admin path, a hardened controller, none of it runs yet.
- A site with no upload form is still exposed. The multipart parser runs for any POST to any PHP file, whether the script reads
$_FILESor not. A static-looking brochure site running PHP is in scope.
Exploitation in the wild started within hours of the leak. Hosting partners reported attacks to Patchstack, and Cloudflare said it was seeing early attempts. Treat the window between the release and your patch as exposed, and check for signs you were hit (see below).
How to update
Find out where your PHP comes from first, because that decides what “update” means. Run php -v, but also check what your web server actually runs. FPM and mod_php are separate binaries from the CLI, and the CLI can be patched while the web path is not.
Distro packages
Distros backport the fix and often keep the old version number. Ubuntu 24.04 still calls its PHP 8.3.6. The package revision is what changed, so compare package versions, not php -v.
Ubuntu fixed package versions, from Canonical’s notice:
| Release | Package | Fixed in |
|---|---|---|
| 26.04 | php8.5 | 8.5.4-0ubuntu1.5 |
| 24.04 | php8.3 | 8.3.6-0ubuntu0.24.04.13 |
| 22.04 | php8.1 | 8.1.2-1ubuntu2.28 |
| 20.04 | php7.4 | 7.4.3-4ubuntu2.29+esm5 (Ubuntu Pro) |
| 18.04 | php7.2 | 7.2.24-0ubuntu0.18.04.17+esm14 (Ubuntu Pro) |
Debian ships the new upstream release instead of backporting. From the Debian security tracker (DSA-6574-1 for trixie, DLA-4848-1 for bookworm):
| Release | Package | Fixed in |
|---|---|---|
| Debian 13 (trixie) | php8.4 | 8.4.27-1~deb13u1 |
| Debian 12 (bookworm) | php8.2 | 8.2.35-1~deb12u1 |
Debian 11 (bullseye) is not in the tracker’s fixed list. It ships PHP 7.4, and per a third-party write-up its LTS ended on 31 August 2026, so check the tracker for your release before you assume anything. The sury.org repo below is the other route.
On Red Hat, advisories went out the same evening (Oct 21, 15
to 20 UTC). Per Red Hat’s CVE page the fixes are RHSA-2026 and 87071 for RHEL 10 (php and php8.4), RHSA-2026 for the RHEL 9 default php, 87072 and 87079 for the RHEL 9 php.2 and php.3 modules, and 87073 and 87080 for the RHEL 8 php.2 and php.4 modules.# what do you have
dpkg -l 'php*' | awk '/^ii/{print $2, $3}'
# update only PHP, never installs something new
sudo apt-get update
sudo apt-get install --only-upgrade 'php8.3*' libapache2-mod-php8.3Swap 8.3 for your version. On RHEL, Alma and Rocky it is sudo dnf upgrade 'php*'.
If you use Ondřej Surý’s PPA or packages.sury.org, the fix is out. It landed on the day of the release, about 45 minutes after the 14
UTC announcement. Launchpad shows 8.5.12, 8.4.27 and 8.3.36 published for Ubuntu 22.04 and 24.04, and the sury.org pool has 8.5.12, 8.4.27 and 8.3.36 builds for Debian 12 and 13 and Ubuntu 22.04, 24.04 and 26.04. Same restart rules apply. Checkapt-cache policy php8.4-fpm shows the new version before you assume you are patched, some PPA mirrors lag.
Remi’s repo for the RHEL family said it would not ship the fix on release day, and told people to move to distro packages or build from source. That changed. His modular repo now has 8.5.12, 8.4.27, 8.3.36 and 8.2.35 for EL 8, 9 and 10, uploaded on Oct 21 at about 15
(server time, timezone not stated on the listing). I did not check whether Alma or Rocky have rebuilt Red Hat’s advisories yet, so on those two rundnf upgrade 'php*' and confirm the version it lands on.
Restart, this is the step people skip
A running PHP process keeps the old code in memory after the files on disk change.
| How PHP runs | What to do |
|---|---|
| PHP-FPM | sudo systemctl restart php8.3-fpm |
Apache mod_php | sudo systemctl restart apache2 (or httpd). A reload or graceful is not enough, it keeps the old libphp loaded |
| FrankenPHP | New FrankenPHP release or image. PHP is built into the binary, apt will not touch it |
Docker php: images | Pull the new image, rebuild yours with --pull, recreate containers |
| Queue workers, Octane, Supervisor jobs | Restart them |
To check nothing still runs the old binary, look for deleted-file mappings:
sudo grep -lE ' /(usr|lib|opt).*\(deleted\)$' /proc/[0-9]*/mapsEmpty output means everything is running the new files. On Debian and Ubuntu, sudo needrestart -b -r l does the same check.
Check you were not already hit
If you were exposed before patching, assume someone looked. Things worth checking: shell processes spawned by the web user (ps for sh/bash with a PHP parent), PHP workers dying with out-of-memory errors during request startup, unusually large form submissions in your access logs, and new or modified .php files in writable directories. If you see signs, patching alone does not clean the box. Rotate whatever secrets the web user could read.
If you cannot update right now
This is the stopgap you were thinking of. It exists, but it is much weaker than the first round of advice made it sound, and i want to be straight about that in the post.
session.upload_progress.enabled = 0 was the mitigation that circulated first, because a leaked write-up pointed at PHP’s session upload-progress code. It does cut one real path. The advisory for -109619 says disabling it stops the demonstrated early session producer and consumer. It then says plainly that it does not mitigate the other lifecycle paths. Datadog tested it and wrote that it does not protect you. It also does nothing for -109618, the out-of-memory one.
So treat it as one layer. Do all of these, then patch:
; php.ini, or the FPM pool via php_admin_flag. Restart workers after
session.upload_progress.enabled = 0
; PHP 8.5 only, removes the confirmed producer for -109619
fatal_error_backtraces = 0Apply them where PHP reads them at startup (php.ini, pool config), not in .htaccess or .user.ini, and check the effective value from a request served by the real SAPI, not from the CLI:
php-fpm8.4 -i | grep session.upload_progress.enabledThen reduce the multipart surface in front of PHP. If the site does not take uploads, reject multipart requests at the web server or proxy. Match “multipart” loosely and case-insensitively in Content-Type, because exact-string matches get dodged.
# nginx, for a vhost that takes no uploads
if ($content_type ~* "multipart") { return 415; }This breaks every upload form, media library and attachment on the vhost. Use it only where you know there are none, or use an allowlist for your own admin IP.
Other things the advisories list, all of them reduce risk and none of them replace patching:
- Keep
post_max_sizeconservative and enforce the decoded body size at the frontend too. - Lower
max_input_nesting_leveland cap input variables, multipart parts and uploads. - Set
pm.max_requests = 1if you can afford the performance hit. It means a worker never serves a second request, which breaks the producer-then-consumer chain for -109619. Not for busy sites. - Stop attackers from reaching fatal errors on paths they can hit (upload, import, error pages, plugins).
On memory_limit = -1: the -109618 advisory calls this the primary configuration mitigation, because it removes the predictable per-request limit that the confirmed exploit depends on. It is also a footgun. You are letting a single request eat all the RAM on the box, so a plain denial of service becomes trivial. It is also incomplete, since a failing mmap() can still hit the same longjmp() path. If you do it, put a per-worker memory ceiling in front of it, and know that the advisory says not to use RLIMIT_AS or RLIMIT_DATA for that, because those make mmap() fail and trigger the bad path. Honestly, for most people the better answer is to take the site off the internet for the hour it takes to patch.
If you are behind Cloudflare, they pushed WAF rules for the known vectors to all plans, including free. That is a stopgap too, and it only works if your origin accepts traffic from Cloudflare alone.
If your distro stopped getting PHP updates
This is the awkward case. Say you are on Ubuntu 18.04 without Pro, or an old Debian that is out of LTS. The distro will not ship a fix, and upstream only fixes 8.2 and later. There is no patched 7.x or 8.1 coming from php.net.
You have four real options:
- Upgrade the OS. Often the cleanest. Ubuntu 24.04 ships PHP 8.3 and 26.04 ships 8.5.
- Pay for backports. Ubuntu Pro (free for up to 5 machines, or 50 for community members, per Canonical) covers 18.04 and 20.04. Third-party LTS vendors exist too.
- Use the sury.org repo, if you are on Debian or Ubuntu. Ondřej Surý also published builds for the end-of-life branches, 8.1.34, 8.0.30, 7.4.33, 7.3.33, 7.2.34, 7.1.33 and 5.6.40. I pulled the source packages for 8.1, 8.0, 7.4, 7.3, 7.2, 7.1, 7.0 and 5.6 and read them. Every one has a changelog entry “Backported from 8.2.35: [CVE-2026-109618, CVE-2026-109619]: Avoid state corruption via bailout” and ships a patch of that name. The 7.4 patch is authored by Arnaud Le Blanc, one of the PHP core developers who wrote the upstream fix, and its header calls it a “minimal mitigation for EOL version”. It touches 17 files across the allocator, the hash tables, the VM, session.c and rfc1867.c (the multipart parser), about 230 lines added. That is a real backport of the same fix. It is also labelled “minimal”, and it is a patch carried outside the PHP release process, so do not assume it got the same testing as the supported branches. Treat it as a bridge. It is one packager carrying a patch for a branch PHP abandoned, and the next bug will not get this treatment.
- Build a supported PHP from source and run it next to the system one. This is the part below.
Be clear-eyed about option 4. You are taking on the job the distro used to do. Every future PHP security release is now your problem. Do it to get off a cliff edge, then plan the real migration.
Also be clear about what you are building. You cannot compile a fixed 7.4 or 8.1 from php.net because it does not exist. You build 8.4 or 8.5, which means your application has to run on it. PHP 8.2 loses security support on 31 December 2026, so skip it. Test your app on 8.4 or 8.5 in staging first.
Building PHP from source on Debian/Ubuntu
I ran these exact steps in a clean Ubuntu 20.04 container (EOL without Pro) on 2026-10-22. configure, make and make install all passed, php -v reports 8.4.27, php -m lists every extension from the configure line, and php-fpm -t says the config test is successful. The build took about 5 minutes. I did not run make test, and i did not start the service under systemd, so those two are on you. Ubuntu 16.04 and Debian 9 are older still and i did not try them. The usual blocker there is system libraries that are too old, OpenSSL especially. If configure complains, build in a container on a newer base image and copy the result over, or upgrade the OS.
Build dependencies:
sudo apt-get update
sudo apt-get install -y build-essential autoconf bison re2c pkg-config \
libxml2-dev libssl-dev libsqlite3-dev libcurl4-openssl-dev \
libonig-dev libzip-dev libpng-dev libjpeg-dev libwebp-dev \
libicu-dev libreadline-dev zlib1g-dev libsodium-devPackage names shift between releases. If one is missing, search for it with apt-cache search.
Download the release and verify it. The sha256 for 8.4.27.tar.xz on php.net is dcf93923b0e38e0d5252b25b796520b5a47761a08b3488b6d0b5d1115764d3f0 (i checked the downloaded file against it). For any other version, get the hash from the releases page:
cd /usr/local/src
curl -LO https://www.php.net/distributions/php-8.4.27.tar.xz
echo "dcf93923b0e38e0d5252b25b796520b5a47761a08b3488b6d0b5d1115764d3f0 php-8.4.27.tar.xz" | sha256sum -c -
tar xf php-8.4.27.tar.xz && cd php-8.4.27Configure. Install into its own prefix so it never overwrites the distro PHP:
./configure \
--prefix=/opt/php-8.4.27 \
--with-config-file-path=/opt/php-8.4.27/etc \
--with-config-file-scan-dir=/opt/php-8.4.27/etc/conf.d \
--enable-fpm --with-fpm-user=www-data --with-fpm-group=www-data \
--enable-mbstring --enable-intl --enable-opcache \
--with-openssl --with-curl --with-zlib --with-zip --with-sodium \
--with-mysqli --with-pdo-mysql \
--enable-gd --with-jpeg --with-webp \
--enable-bcmath --enable-pcntlMatch this list to what your old install actually had. Run php -m on the old PHP and compare. A missing extension is the usual reason a migrated app falls over.
make -j"$(nproc)"
make test # optional but worth the time on a production box
sudo make installConfig, then a systemd unit:
sudo mkdir -p /opt/php-8.4.27/etc/conf.d
sudo cp php.ini-production /opt/php-8.4.27/etc/php.ini
sudo cp /opt/php-8.4.27/etc/php-fpm.conf.default /opt/php-8.4.27/etc/php-fpm.conf
sudo cp /opt/php-8.4.27/etc/php-fpm.d/www.conf.default /opt/php-8.4.27/etc/php-fpm.d/www.conf
sudo cp sapi/fpm/php-fpm.service /etc/systemd/system/php84-fpm.serviceYou do not need to edit ExecStart. The service file is generated during the build from a template, and the template fills in the install prefix, so it already points at /opt/php-8.4.27/sbin/php-fpm and the config under that prefix. What you do need to change is the listen address. The default pool in www.conf listens on 127.0.0.1:9000, which is the same port your old FPM may be using. Set listen in the new www.conf to a different port or a socket path, and point your nginx or Apache vhost at it.
One more catch: --enable-opcache builds opcache, but php.ini-production ships with zend_extension=opcache commented out. Uncomment it in the new php.ini, or you lose opcache and the site gets slower for no obvious reason. Then:
sudo systemctl daemon-reload
sudo systemctl enable --now php84-fpm
/opt/php-8.4.27/bin/php -vThen put the hardening from the stopgap section into the new php.ini as well. Disabling session.upload_progress.enabled is still worth doing after you are patched. The PHP advisory itself says that feature is rarely used and rarely tested, and attackers are going to keep poking at it.
Keep the old PHP stopped but installed for a week so you can roll back. Remove it only after you have confirmed the site behaves on the new build.
Keep your systems updated
It is always good hygiene to keep your system up to date. There are open-source Ansible playbooks on GitHub for keeping servers patched, and you can do it by hand too. It doesn’t take that long, but if you maintain a lot of servers, a playbook is a good idea.
The basic idea is you patch your software whenever there is an update. Keep an eye on the vulnerabilities and CVEs that turn up every day in the languages and systems you use. Do not ignore software updates. Whenever there is an update there is a reason behind it. If there is a breaking change, the maintainers usually mention it in the release notes or changelog, so read that. Keeping your packages, language and libraries up to date is usually the best security practice you have.
My Thoughts
People have been calling PHP dead for over two decades, and a good chunk of the internet still runs on it. The amount of news coverage and noise around this issue says the same thing: PHP is still one of the most used languages on the web, if not the most used.
So i don’t think the answer is moving away from PHP. It should be adapting it to the new generation of AI-driven development, and securing it with everything we can, so it stays a viable option for people who just want to get something done quickly.