PHP2Shell, the critical PHP RCE (CVE-2026-109618 and CVE-2026-109619)
Table of Contents
Today, October 21st 2026, PHP shipped emergency patches for a pair of flaws that had been sitting in the code for fourteen years and that a lot of people are going to remember for a while. They are called PHP2Shell, they are CVE-2026-109618 and CVE-2026-109619, and they allow remote code execution without authentication against a huge number of PHP servers. This is not a plugin or a framework bug, it lives in the PHP core itself, so the surface is massive.
I am going to walk you through what they are, why they happen and, above all, how to protect yourself, which is the urgent part. I spun up the researcher’s lab locally to show you the flaw with my own eyes, but let me be clear from the start, this is not a recipe for going around popping other people’s servers. This is already being mass exploited on the internet, so the serious play is to understand it and harden up, not to hand out the weapon.
β οΈ If you run anything PHP facing the internet, jump straight to the mitigation section and patch before reading on. Everything else I will tell you calmly afterwards.
𧨠What PHP2Shell is
These are two separate vulnerabilities that share a name and a severity.
- CVE-2026-109618, RCE through memory corruption in an out-of-memory (OOM) condition. PHP runs out of memory during request startup and, when it bails out, it leaves the internal state broken.
- CVE-2026-109619, RCE through stale global state. The worker process that serves requests keeps leftovers from a previous request that should already have been freed.
Both carry a CVSS score of 9.2 (critical), and some outlets even mention 10.0. The truly nasty word here is pre-authentication. An attacker does not need a user, a password, or to touch your application logic. With a single crafted HTTP request they can get to running commands with the web process privileges, usually www-data.
To give you a sense of scale, VirusTotal estimates over 5 million exposed PHP servers, and Cloudflare already confirmed it was seeing real attacks even before the patches landed. The UK NCSC has urged organizations to update immediately or take affected systems offline. So no, this is not one to leave for Monday.
π― Who is affected
This is where your hair stands up. The vulnerable code is identical since PHP 5.4.0, released in 2012, up to 8.5.11. That is fourteen years of versions carrying the same flaw.
- Vulnerable versions, from 5.4.0 to 8.5.11, including every 7.x branch and 8.0, 8.1, and so on.
- Patched versions, released today, 8.2.35, 8.3.36, 8.4.27 and 8.5.12. PHP also published minimal source backports for 7.4, 8.0 and 8.1, which are already out of support.
- Server APIs (SAPIs) affected, basically all the ones that serve web. PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed.
The PHP security team itself warns that the highest risk is with mod_php, because there an unauthenticated request can trigger the flaw before a single line of your application runs. With PHP-FPM the impact is a bit different because the pool child dies on the failed startup, but do not relax, the other chains still apply.
π§© Why it happens, the four chains
The researcher who published the analysis describes four different paths to the same place. I will summarize them at a conceptual level, which is what really helps you understand the risk and decide what to turn off. The common thread of almost all of them is a function almost nobody looks at, session.upload_progress, which is enabled by default.
Chain A, session file injection plus LFI
session.upload_progress exists so a site can show you a progress bar while you upload a file. For that, PHP keeps upload data in the session file, and among it the name of the file you upload, raw, unsanitized. If that name contains PHP code, that code ends up written to disk inside the session file.
On its own that executes nothing. But if the application also has a Local File Inclusion, meaning somewhere it does an include() of a path you control, then you can include the session file and run that code. The author rates it as the most reliable chain, 100 percent, because it does not depend on luck with memory. All it needs is an LFI in the application, which sadly is not rare.
Chain B, use-after-free in var_destroy
This one lives in the deserializer, in ext/standard/var_unserializer.re. When unserialize() destroys the objects of a payload, the destructor loop calls an object’s __destruct without turning on the serialization lock (BG(serialize_lock)). If that destructor calls unserialize() again from inside, a memory table that had already been freed gets reused, and you have a use-after-free. The author confirmed it with AddressSanitizer at var_unserializer.c:851. To use it you need an application that calls unserialize() with data you control.
Chain C, session handler mismatch
PHP can store sessions with different formats (serialize_handler). If one folder writes the session with the php_serialize format and another reads it with the php format, the second one splits the content at the | character and deserializes whatever comes after it as an object. Combined with the unsanitized filename from chain A, you can slip a serialized object into the session and make its __destruct fire, even when the session decode returns an error. If the code has a class with a juicy __destruct, there is your execution.
Chain D, OOM corruption with no application dependency
This is the subtlest one and the scariest, because it needs no LFI, no unserialize, nothing from your code. A well crafted multipart POST forces PHP to run out of memory right during request startup. On the OOM bailout, a longjmp() skips the engine initialization (init_executor), and on Apache with mod_php the request shutdown runs anyway on a half initialized global state. That stale state is what turns into execution with the right heap shaping. It works on mod_php and on FrankenPHP. On PHP-FPM the child dies, which is why the risk there is lower.
The patch, by the way, goes to the root. It replaces that longjmp() with a delayed soft bailout and a hard _exit(), so it never keeps running on invalid memory. It touches 300 files in the commit, which tells you how deep the problem was buried.
π§ͺ The lab
The researcher published a repository with a Docker setup to reproduce it locally. I brought it up on my Kali to see it first hand. We clone and prepare the files.
git clone https://github.com/ikow/php2shell-poc.git
cd php2shell-poc/lab
bash setup.sh
There is an important detail I ran into that you should know. The repo’s docker-compose.yml uses the floating tag php:8.2-fpm, and since the patches came out today, that tag already pulls the patched 8.2.35, so the lab as is is no longer vulnerable. To actually see the flaw you have to pin the vulnerable version, 8.2.34.
docker run -d --name php2shell-fpm --network-alias php \
-v $PWD/webroot:/var/www/html -v $PWD/php.ini:/usr/local/etc/php/php.ini \
php:8.2.34-fpm php-fpm
We also bring up an nginx in front on port 18080 and check the environment with a harmless page.
curl -s http://127.0.0.1:18080/index.php
PHP 8.2.34
SAPI fpm-fcgi
upload_progress 1
OK
There you go, PHP 8.2.34 and upload_progress set to 1, meaning enabled. The perfect ground to understand chain A.
π The primitive in action
I am not going to build the full chain to a shell here, partly because the author does not release that part either and partly because this is being exploited right now. But I will show you the primitive, which is what truly explains the danger and motivates the mitigation.
The idea is to send a multipart POST with three pieces. The PHPSESSID in the body (which already breaks cookie isolation and lets you fixate the session without authenticating), the magic field PHP_SESSION_UPLOAD_PROGRESS, and a file whose name carries PHP code. The repo ships a verification script that does exactly that.
python3 poc/verify_session_injection.py --target http://127.0.0.1:18080
What is interesting is not the script, but what ends up written to disk. We read the session file that was created with the SID the attacker chose.
docker exec php2shell-fpm cat /tmp/sessions/sess_verifysession00000000000000b
upload_progress_x|a:5:{s:10:"start_time";i:1792606038;s:14:"content_length";i:289;s:15:"bytes_processed";i:289;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:1:"f";s:4:"name";s:32:"<?php system('id');echo 'RCE';?>";s:8:"tmp_name";s:14:"/tmp/phptOmaek";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1792606038;s:15:"bytes_processed";i:1;}}}
Look at the s:32:"<?php system('id');echo 'RCE';?>". That is the filename I sent, stored raw, unsanitized, in a file on disk with a predictable path (/tmp/sessions/sess_<SID>) and a SID I chose myself, all with no user or password. From there, the only thing the attacker is missing to turn this into execution is an include() of that path somewhere in the application. That is why chain A is so reliable, because the hard part (dropping attacker code at a known path) is handed over by PHP out of the box.
The same script also sets up chain C, placing a serialized object after a | in the name, which a reader using the php handler would deserialize.
π‘οΈ Detection
While you patch, which is what really closes this, it helps to know what to look for.
- In access logs, POST requests with
Content-Type: multipart/form-datathat include thePHP_SESSION_UPLOAD_PROGRESSfield, especially if they carry odd filenames with<?php,|O:or similar. - Suspicious inclusion requests pointing at
/tmp/sessions/sess_*or at session paths, a sign someone is trying to close chain A. - Spikes of 500 errors and worker crashes with huge multipart bodies or without
Content-Length, which is the shape of chain D. - WAF. Cloudflare already deployed rules against the known methods, though its own CTO makes clear it is a temporary patch. If you have a WAF, turn them on, but do not get comfortable, because they do not replace the patch.
π§ Mitigation and patching
Here is what truly matters. I order it from the definitive to the temporary.
What actually fixes it, update PHP
The only permanent close is to run a patched version. If you are on a supported branch, update now.
# Debian / Ubuntu
sudo apt update && sudo apt upgrade php*
# check the version afterwards
php -v
The versions to reach are 8.2.35, 8.3.36, 8.4.27 or 8.5.12 or higher. If you are on PHP 7 or on 8.0 and 8.1, which no longer have official support, migrate to a patched branch or apply your distribution’s backport. And remember to restart the workers afterwards, because otherwise the old process stays alive with the vulnerable code loaded.
Temporary mitigations while you update
If you cannot patch right away, the PHP team recommends these containment measures. None of them is a replacement for the patch, but they lower the risk a lot.
For the session side, we close chain A and much of chain C.
session.upload_progress.enabled = Off
session.use_only_cookies = On
session.use_strict_mode = On
A warning that saves you grief. Turning this off with ini_set() from the code does not work, nor does a .user.ini, because the flaw acts before that gets applied. It has to go in the system php.ini or in the FPM pool config with php_admin_value, and you have to restart the workers.
For the OOM side, chain D.
memory_limit = -1
post_max_size = 8M
Set it with php_admin_value in the FPM pool or in the Apache config so the application cannot override it. The memory_limit = -1 sounds counterintuitive, but the idea is that it is precisely the memory limit that triggers the corrupt OOM, so removing it avoids that path, and you move the memory control to the container or the cgroup. Lower post_max_size to 8M if you do not upload files, or 32M if you need them. And only on PHP 8.5, there is an extra mitigation.
fatal_error_backtraces = Off
The mitigation, live
To show you this is not just talk, I applied session.upload_progress.enabled = Off in the same lab, restarted and repeated the earlier POST. The result is that the session file stays at zero bytes, the name with PHP code is no longer written anywhere.
docker exec php2shell-fpm ls -la /tmp/sessions/
-rw------- 1 www-data www-data 0 sess_mitigado0000000000000000000b
Zero bytes. The primitive is gone. That is the difference between a server that hands over the first half of the attack and one that does not. Even so, I insist, this covers the session chains, but the engine OOM corruption only closes fully with the patch.
π Closing
PHP2Shell is one of those flaws we will remember, fourteen years hidden in a function almost nobody looks at and enabled by default, in a language that runs half the internet. The good part is that the defense is clear. Update to a patched version, restart the workers and, in the meantime, turn off session.upload_progress and tie down memory. If you run public facing PHP, do it today, because this is already out in the wild.
I brought it up locally only to understand it and show it to you responsibly. As always, knowing the flaw is what lets you defend against it. See you in the next one.
π References
- Official PHP security advisory
- GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- BleepingComputer, PHP ships emergency patches for critical PHP2Shell flaws
- SecurityWeek, PHP2Shell, Mass Exploitation Looms for Leaked Critical PHP Flaws
- Help Net Security, PHP2Shell critical PHP vulnerabilities patched
- Research repository by Qi Deng (ikow)
