October 23, 2026
PHP2Shell: How a Progress Bar Became Unauthenticated RCE on 14 Years of PHP
23 Oct 2026

By Rotimi Ishola
8 min read
On 21 October 2026, PHP shipped an emergency release across every supported branch to fix two critical vulnerabilities โ CVE-2026โ109618 and CVE-2026โ109619, both scored 9.2 โ that let an unauthenticated attacker run commands with nothing but ordinary HTTP requests. The flaw lives in the PHP runtime itself, underneath whatever framework or CMS sits on top, and the vulnerable code has shipped since PHP 5.4.0 in 2012 โ fourteen years of releases reached at once. This is how a feature built to draw upload-progress bars became remote code execution, and what actually stops it.
Defender's quick checklist
Patch now_ โ PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35, then restart every worker. The only complete fix._
Can't patch yet? Set
session.upload_progress.enabled = 0andopcache.enable = 0; on PHP 8.5 alsofatal_error_backtraces = 0. Restart workers โ none is complete alone.
Watch logs for oversized
PHPSESSIDvalues inSet-Cookie, multipart POSTs carryingPHP_SESSION_UPLOAD_PROGRESS, and PHP worker crashes / out-of-memory errors.
Won't help_ โ a mod_php in-process WAF; the attack finishes during request parsing, before your app runs._
The feature it abuses
Every serious vulnerability abuses something that was built to be useful, and this one is no exception โ so it helps to meet the feature before the bug.
When a browser uploads a large file, the page has no clean way to know how far the upload has got. PHP answers this with session.upload_progress. While PHP is still reading a multipart/form-data body off the wire, it writes a running description of the upload โ its name, its total size, and how many bytes have arrived so far โ straight into the user's $_SESSION. A second request can read that data and report progress back to the browser.
The feature is enabled by default, and it switches on for a given upload whenever the request contains a form field whose name matches the session.upload_progress.name setting โ which defaults to PHP_SESSION_UPLOAD_PROGRESS. Nothing about this is exotic; it is a convenience that has sat quietly in the engine since 2012.
When it runs: the pre-execution window
The detail that matters is not what the feature does but when it does it.
Parsing the request body is one of the first things PHP does. It happens long before the engine sets up the request environment, and long before it compiles and runs the script the URL points at. The progress updates are written from inside those body-parsing callbacks โ in the engine, the handler is php_session_rfc1867_callback โ and it fires before the per-request startup that application code lives behind.
That timing has a consequence the designers never meant to expose. Reaching this code needs no session_start() in the target page, no valid login, and no application logic at all. Any URL that accepts a POST is enough โ even a script that does nothing but print a single line. The application that owns the server is still asleep while all of this happens.
This pre-execution window is the whole foothold. It is also why the next point should make any defender sit up.
Why patching your application does nothing
This is the part most people get wrong, and it is the sharpest reason to care. The bug is not in WordPress, Magento, Laravel, or your own code. It lives in PHP's session upload-progress handling, inside the engine's session extension.
Your framework is simply the thing running on top of a vulnerable runtime. You can have a perfectly audited application, zero known CVEs in your dependencies, and a clean security scan, and still be fully exploitable โ because the attacker never touches your application. They reach the engine through a POST to any endpoint and trigger a fault in memory the worker should have released. Reviewing your controllers, sanitising your inputs, and locking down your routes does not help, because none of those layers are involved.
The lab makes this impossible to argue with. The target server's entire application is a single line:
<?php echo "OK\n"; ?><?php echo "OK\n"; ?>No login, no upload, no database, nothing to exploit in that code โ and it is still vulnerable to unauthenticated RCE, because the attacker never reaches the code at all. A WordPress or Laravel site is no better protected simply because its own code is sound.
The only real fix is a patched PHP. Everything else in this post narrows the window; it does not close it.
The bug: a bailout at the wrong moment
The feature is not the vulnerability. The vulnerability is what happens when PHP runs out of memory while that feature is mid-work โ and to see it you need to know how the engine handles a fatal error.
PHP caps how much memory one request may use with the memory_limit setting. When a request tries to exceed it, the engine cannot simply continue, so it performs what it calls a bailout: a longjmp() straight back to a safe recovery point, abandoning whatever it was doing. This is the mechanism behind the familiar "Allowed memory size exhausted" error. A normal request tidies up after itself โ freeing memory and clearing the pointers that referred to it โ but a bailout skips all of that. It is an emergency exit, not an orderly shutdown.
The flaw is a matter of timing. If the out-of-memory condition strikes after the engine has freed a block of memory but before it has cleared the pointer that still refers to it, the bailout leaves that pointer behind, aimed at memory that is no longer valid โ a classic dangling pointer. The room frames this as CWE-824 (Access of Uninitialized Pointer); the vendor advisories mostly file it as CWE-416 (use-after-free). Both describe the same defect from slightly different angles, and it is worth a sentence in your own write-up acknowledging that, because a sharp reader will check the NVD entry and see 416.
On its own, a dangling pointer in a request that has just died would be harmless โ the request is over. What makes it dangerous is the server model. Under Apache's prefork mod_php, one worker process serves request after request without restarting between them. The stale pointer does not vanish with the failed request; it stays in the worker's memory, and the next request to land on that worker meets an engine reaching through a pointer it never reset. An attacker who controls what gets allocated into that freed space in the second request controls what the pointer now points at.
Two CVEs from one defect
From that single defect the researchers built two distinct attacks, which is why PHP2Shell carries two CVE numbers. Both are rated 9.2 critical on CVSS v4.0, and PHP's own changelog addresses them together with one line โ "avoid state corruption via bailout" โ which names the root cause precisely.
The memory-disclosure half of CVE-2026โ109618 matters even if you only skim this: it means a single unauthenticated request can hand an attacker secrets belonging to other users of the same server, with no code execution required at all.
What the chain achieves โ and what this write-up leaves out
At a high level the full remote-code-execution chain moves through three stages. First, the memory-disclosure bug leaks bytes of the worker's memory back to the attacker. Because that memory contains pointers, the leak reveals where PHP and its libraries are loaded โ which defeats ASLR, the randomisation that normally keeps an attacker from knowing those addresses. With the layout known, the second bug turns the corrupted engine state into control over execution flow, and the result is command execution as the web-server user. That is the shape of it, and understanding the shape is what lets you reason about detection and defence.
What this write-up deliberately does not include is the operational half: the exact requests, the memory-grooming, the steps to defeat ASLR, or any runnable proof-of-concept. This was disclosed on 21 October 2026 and is being exploited in the wild right now, with a large share of servers still unpatched. During that window a clean, copy-paste reproduction works just as well against someone's production server as against a lab box, so publishing one does more harm than good. That is the same restraint responsible disclosure asks of everyone, and the strongest advisories written in this window stay on the impact-and-defence side on purpose. If you solved the room, keep your own exploitation notes private until the patch has had time to land widely.
The good news for a write-up is that the interesting part โ why a progress-bar feature can become engine-level RCE โ is entirely in the half that is safe to explain, and that is the half that makes readers understand the bug rather than just copy it.
Detecting exploitation attempts
You cannot see the memory corruption from outside, but the attempt leaves marks. Hunt for these in your access and error logs:
- POST requests carrying the upload-progress field. Look for a
PHP_SESSION_UPLOAD_PROGRESSform field in multipart bodies. Only the default field name is a reliable signal โ the name is configurable viasession.upload_progress.name, so a tuned attacker can rename it, but most automated scanning uses the default. - A session ID supplied in the POST body rather than a cookie. The feature accepts a session identifier from the multipart form data regardless of
session.use_only_cookies, so aPHPSESSIDappearing in the body is a strong fixation tell. - Bursts of PHP worker crashes. Treat 5xx responses from PHP workers, and segfault entries in the error log, as a high-confidence signal of an attempted exploit โ the technique crashes workers when it misses.
- Repeated large POSTs to endpoints that take no upload. The attack can be aimed at any POST-accepting URL, so unusual multipart traffic to routes that have no upload feature is suspicious on its own.
Public Suricata rules for the PHP2Shell session.upload_progress chains were published within a day of disclosure, so if you run network IDS, pull the latest ruleset rather than writing signatures from scratch.
Mitigation that actually works
PHP's own position is blunt: there is no complete mitigation, and updating is the only safe choice. A PHP core developer warned publicly that disabling upload progress on its own is not enough, because it closes only one of several paths. So patch first; treat everything below as a stopgap for the hours or days until you can.
For PHP-FPM, remember the pool reads its own configuration, not the command-line php.ini โ put these in the pool file (for example pool.d/www.conf) and restart. And if you are on an end-of-life branch (5.x, 7.x, 8.0, 8.1), there is no official fix at all: your only safe move is to upgrade to a supported, patched release.
One defence that doesn't help: in-process WAFs
A web application firewall that runs as a PHP module inspects traffic only after PHP hands control to the application โ but this attack completes its work during request parsing, before the application ever runs. An in-process filter never gets the chance to see it. Protection has to sit in the patched runtime itself, or at a layer in front of PHP such as a reverse proxy or a network WAF.
Timeline and the takeaway
The lesson of PHP2Shell is not really about one feature. It is that trust boundaries do not stop at your application. A feature that runs in the engine, before your code wakes up, is part of your attack surface whether you use it or not โ and when a bug lands there, fourteen years of otherwise-careful applications inherit it at once. Patch to a fixed release, apply the stopgaps until you can, and watch your logs for the POST-and-crash pattern in the meantime.
Sources
- PHP 8.5.12 release announcement โ the official security release fixing both CVEs
- Sansec: PHP2Shell analysis and mitigations
- Upwind: initial assessment of the two RCEs
- ma.ttias.be: early mitigation steps
- Sevalla: protecting apps from CVE-2026โ109618 / 109619
Thanks for reading. If this helped, a clap and a follow are appreciated โ I write up security labs and projects regularly. Questions and corrections welcome in the comments.
Follow me for more: