October 25, 2026
The Cleanup That Never Ran: PHP2Shell Explained
A vulnerability in PHPโs core runtime has put years of server deployments at risk. Disclosed on 21 October 2026, PHP2Shell combines twoโฆ

By Sameera Madushan
10 min read
A vulnerability in PHP's core runtime has put years of server deployments at risk. Disclosed on 21 October 2026, PHP2Shell combines two security flaws to make remote command execution possible without authentication, using standard HTTP requests alone.
Unlike vulnerabilities that depend on insecure application logic, PHP2Shell operates at a lower level. An application does not need to contain an obvious coding mistake, expose a file-upload feature, or allow unauthenticated access to sensitive functionality for the underlying runtime to be at risk. The critical factor is whether the server runs a vulnerable PHP version.
The historical reach of the issue makes it especially significant. The affected code dates back to PHP 5.4.0, released in 2012, leaving a long history of PHP releases exposed to the same underlying weakness.
The disclosure covers two vulnerabilities: CVE-2026โ109618 and CVE-2026โ109619. Each carries a CVSS v4.0 score of 9.2, placing them in the critical severity category. Researchers from Wiz, OpenAI, and Calif reported their findings privately to the PHP security team, which released fixes on the disclosure date.
Administrators should upgrade to a patched release: PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35, depending on their current branch. Framework updates alone are not sufficient to address a vulnerability rooted in the PHP runtime.
The Feature Behind the Vulnerability: Upload Progress
Imagine ordering a package online. While the package is being delivered, the tracking system records its status: the parcel has been dispatched, how far it has travelled, and whether it has reached the destination. This information lets you monitor progress without waiting for the delivery to finish.
PHP has a similar mechanism for file uploads: session.upload_progress.
When a user uploads a large file, PHP can track its name, total size, and the number of bytes received so far. It stores this information in the user's session data, accessible through $_SESSION, allowing the application to display upload progress to the user.
To activate this tracking for a particular upload, the incoming multipart form request includes a field named PHP_SESSION_UPLOAD_PROGRESS, which is the default value of the session.upload_progress.name configuration setting. The feature is enabled by default in PHP.
So far, this sounds harmless. The important question, however, is who processes the tracking information, and when?
Imagine a building with a reception desk. Every visitor must eventually pass through reception before reaching the office they want to visit. You might expect the receptionist to check their identity before allowing them to proceed. But suppose the building's parcel-handling system starts processing every delivery before the receptionist even arrives. Someone could interact with that system before the usual security checks take place.
PHP's request lifecycle has a similar ordering problem.
When a multipart upload arrives, PHP begins parsing its contents and updating upload-progress information before executing the requested PHP script. These operations happen inside the runtime itself, through internal callbacks. They do not wait for the application's code to start.
This distinction matters because application-level security checks, such as login verification and permission checks, cannot protect processing that has already occurred before those checks run.
Think of it this way: locking the office door does not secure a process that takes place in the reception area before the door is even reached. Likewise, securing an application's routes does not automatically protect every operation performed by the PHP runtime before the application begins executing.
This early processing window is the starting point for understanding PHP2Shell. The upload-progress feature was designed to provide useful feedback, but its position in PHP's request lifecycle makes the way it handles incoming data security-critical. The vulnerability arises from how that processing can be abused, not simply from the existence of upload-progress tracking itself.
The Real Bug: When PHP Runs Out of Memory
The upload-progress feature is not the vulnerability by itself. The problem lies in how PHP handles a fatal error when it runs out of memory while processing an upload. To understand why this matters, we need to look at what happens when PHP is forced to stop unexpectedly.
The first excerpt, from Zend/zend_alloc.c, is the code that runs when a request hits its memory limit. It reports the familiar "Allowed memory size exhausted" error and then calls zend_bailout(). The second excerpt, from Zend/zend.c, shows what that call does. It sets a few flags and ends with a single line, LONGJMP(*EG(bailout), FAILURE);. That one line is the emergency exit.
PHP limits how much memory a request can consume through the memory_limit setting. When a request exceeds it, the engine cannot continue normally, so it uses longjmp() to jump out of the current execution path in one step.
Think of it as an emergency exit in a building. Normally, when someone leaves a room, they switch off the equipment, put things away and close the door behind them. During an evacuation, everyone leaves at once, and some of those steps never happen.
PHP's bailout makes the same trade-off. It can escape a fatal condition, but it can skip cleanup that would ordinarily take place.
Where the vulnerability begins
If PHP frees a block of memory but does not clear a pointer that still refers to it before the bailout, that pointer becomes stale. In C, this is called a dangling pointer.
Imagine a hotel where the front desk keeps a card that reads "this guest's luggage is in room 412." The guest has to leave in a rush, and the room is cleaned out and given to a new guest. But nobody removes the card from the desk. The next staff member who trusts the card and opens room 412 walks into a room that now belongs to someone else.
A dangling pointer causes the same problem. The card is the pointer, the room is the block of memory, and the hurried departure is the bailout. The memory has been freed and may since have been reused for something else, yet the pointer still sends PHP to the old address.
This class of flaw is known as a use-after-free (CWE-416). The disclosure also references CWE-824, which covers access to a pointer that hasn't been properly initialized.
Why the bug can outlive a failed request
A dangling pointer in a request that has just died would normally be harmless. The request is over, so who cares what its pointers point to? What changes the picture is how PHP's process model works. Under Apache's prefork configuration with mod_php, a single worker process behaves less like a visitor who leaves the building and more like a warehouse picker who never clocks out between orders. It finishes one request and immediately starts the next, in the same memory, without restarting. Anything left on the cart from an order that was abandoned mid-pick, including a pointer to memory that's technically free but still has an address, is still sitting there when the next order begins. The next request to land on that same worker doesn't get a clean cart. It gets whatever was left behind, and if an attacker controls what gets loaded into that freed space, they control what the worker finds.
This is also why PHP2Shell is tracked as two CVEs rather than one. They are separate bugs in different parts of PHP, but they share one root cause: state left behind when PHP bails out of a fatal error. The PHP fix commit, titled "Avoid state corruption via bailout," addresses both. CVE-2026โ109618 covers the upload-progress path, and CVE-2026โ109619 covers stale global state, including the fatal-error backtrace and OPcache.
CVE-2026โ109618
Imagine a shipping label printer configured to print one short tag, a name, maybe 32 characters, and then stop. Now imagine someone tampers with the printer's "characters remaining" counter so that instead of stopping, it keeps feeding and printing everything stacked on the roll behind that one label, including other staff's slips, stray notes, and whatever happens to be sitting there. The courier just picks up what comes out of the printer and leaves with it.
That's the shape of the memory disclosure in this bug. The stale pointer here refers to the session ID string PHP is about to hand back to the browser. After the bailout, PHP still trusts the old pointer and length as if they described a live string, so it doesn't stop at 32 characters. It reads on through the worker's memory and sends the contents back in the PHPSESSID value of the Set-Cookie header, which the attacker receives in full. The attacker also controls how much memory is read. This works even if the application never calls session_start(), which is why the leak is application-agnostic. The memory can include other requests' cookies, tokens, API keys, or anything else sitting nearby.
The public exploit uses this leak twice. The first read discloses the base address of PHP's memory heap. The second reveals a pointer whose offset into libphp is known, which gives away the base address of the PHP library itself. Together, those two addresses are exactly what ASLR is designed to withhold. They are not passwords, just a map of where things actually sit in this worker's memory. Once an attacker has that map, "random" addresses stop being random.
The same corrupted state that leaks memory can also be steered into code execution. When PHP finishes with an internal value, it often runs a cleanup routine stored as a function pointer alongside that value, called a destructor. Normally this is invisible and uneventful, the equivalent of a shift supervisor handing the cleanup crew a slip that says "power down the equipment, nothing special."
The attacker reoccupies the freed memory with a forged internal structure (a HashTable) whose destructor pointer has been overwritten to point at system(), at the exact address the leak just disclosed. When the engine later runs its routine cleanup and reaches for that slip, it reads the forged one without question, because nothing checks whether a work order has been tampered with, only whether it's shaped like one. Instead of "power down," the slip now says "run this command," and the crew carries it out. system() executes attacker-controlled bytes as its argument, running as the web server user, with no external tooling and no second vulnerability, built entirely out of mechanisms PHP already trusts.
CVE-2026โ109619
The second CVE is a different bug with the same root cause: PHP leaves state behind when it bails out of a fatal error. This one is about stale global state.
Think of a shared whiteboard in a workshop. At the end of each job, the room is cleared and the materials thrown out, but the whiteboard still says "current job: see the box in bay 3." The next shift starts work, glances at the board, and goes to bay 3. That box now holds whatever someone else put there.
In PHP, certain pieces of state belong to the worker process rather than to a single request, so they survive after the request's memory has been freed. One example is the backtrace PHP keeps from the most recent fatal error. If the next request on the same worker touches that pointer early, before PHP resets it, PHP follows it into memory the attacker has refilled. That gives the attacker controlled memory corruption, which can be developed into code execution. Wiz demonstrated this against Drupal. This path is why fatal_error_backtraces = 0 is a mitigation on PHP 8.5.
The CVE also covers a second bug, a type confusion in OPcache. OPcache keeps compiled PHP code in memory shared between workers. A memory failure partway through updating that shared state can leave workers disagreeing about what each slot holds, so one worker ends up treating one kind of object as another. Wiz built an unauthenticated RCE chain against WordPress from this.
Every step of these attacks is an ordinary multipart POST, and the target page doesn't need to do anything special. The attack lives inside PHP's own memory management, below anything the application or its framework can see, so application logs may contain little context. That is also why an in-process WAF is too late to help. Since disclosure, edge providers such as Cloudflare and Fastly have shipped rules that inspect traffic before it reaches PHP.
Detection, Mitigation, and the Real Fix
The instinct to respond to a vulnerability like this by "updating the website" misses where the flaw actually lives. A WordPress, Drupal, Joomla, or Laravel update changes the application sitting on top of PHP. It does nothing to the interpreter underneath. Patching the application layer here is like repainting a wall while the crack runs through the foundation beneath it. The surface looks addressed, but the structure hasn't changed.
The only complete fix is updating PHP itself to 8.5.12, 8.4.27, 8.3.36, or 8.2.35, whether that comes from your distribution or from your container's base image, and then restarting every worker so no process keeps running the old code in memory. Two caveats apply. Distributions often backport the fix without changing the version number, so check your vendor's advisory or changelog, not just php -v. And PHP 8.1 and older are end-of-life and received no official fix, so those systems need to move to a supported branch or a vendor-maintained build.
Until the patch lands, there are stopgaps that narrow the opening without sealing it. The PHP team is explicit that updating is the only safe choice. Their main configuration mitigation for CVE-2026โ109618 is to set and enforce memory_limit = -1 and restart every worker, since the exploit depends on forcing an out-of-memory error. That removes PHP's memory ceiling, so a single request can consume all of a server's memory, and it should be treated as a short-term measure. Setting session.upload_progress.enabled = 0 and restarting workers blocks the session paths confirmed so far, but not other variants. On PHP 8.5, fatal_error_backtraces = 0 closes one of the avenues used by CVE-2026-109619. Rejecting multipart/form-data at the network edge blocks the delivery path used by most published exploits, at the cost of breaking legitimate file uploads. Running the PHP process with least privilege doesn't stop the bug, but it limits what an attacker walks away with if the chain completes.
One thing worth saying plainly is what doesn't help. A web application firewall running inside PHP or the application is not an effective solution here. Earlier in this piece, the point about locking the office door was that it doesn't secure a process that happens in the reception area before the door is ever reached. That is exactly the problem with an in-process filter. It only sees traffic once PHP has parsed the request and handed control to the application, and much of the damage from PHP2Shell happens during that parsing step. Real protection has to sit either inside the patched runtime or in a layer in front of PHP. Several edge providers, including Cloudflare and Fastly, have shipped rules for this, which is why filtering at the network edge can help while an in-process filter cannot.
For detection, the attack does leave marks, even if the application layer can't see them. The information disclosure path produces abnormally large PHPSESSID values in Set-Cookie response headers, standing out clearly against the short, fixed-length IDs PHP normally issues. The delivery requests often look like unusually large multipart POST bodies with very high field counts, and many published exploits carry the PHP_SESSION_UPLOAD_PROGRESS field. The memory exhaustion that drives the chain also shows up as out-of-memory errors, repeated HTTP 500 responses, and PHP worker crashes. If an exploit succeeds, you may also see unexpected child processes spawned by PHP workers. None of these alone is proof. Together, they are a pattern worth alerting on for as long as unpatched servers remain in your environment.