Post

Why php2shell can't be widely exploited

What limit the php2shell exploitation in practice

Why php2shell can't be widely exploited

Introduction

PHP2Shell is about two bugs in PHP core: CVE-2026-109618 and CVE-2026-109619. Both can lead to unauthenticated RCE.

The first is an OoB during initialization. PHP leaves partially initialized request data behind, then shutdown treats it as valid. The second leaves pointers in global state after the request heap has gone away. A later request on that worker reaches the stale pointer before normal initialization resets it.

PHP says to treat all installations as at risk and update them. But even with the update, researcher found a way to exploit the PoC

AI killed responsible disclosure

When Calif released a PoC video on X, several people started prompting to AI how to find an exploit. After consuming the GDP of an entire small country, the result was thousands of non working exploits and a load of rubbish. This is what happens when you ask a LLM to do certain task without giving it any instructions or explaining what you’re trying to do.

So, quite apart from the fact that these people have no idea what they’re doing, they have completely disregarded responsible disclosure and caused unnecessary panic for several hours.

I was one of the key figures on X 1 2 3. What I did was combine common sense and practical knowledge to Qwen 27B; all the posts I wrote turned out to be true.

If you want to check the real authors posts:

Calif’s disclosure attributes the original research to Ryan Austin, also known as lcfr_hax, during an OpenAI Patch the Planet engagement. Calif says it began the audit on 30 July, found the first path on 1 August, reported the first bug to PHP on 11 August, shared an RCE proof of concept on 14 August and reported the second bug on 21 August.

Wiz’s disclosure describes a coordinated effort involving Calif, OpenAI, the PHP team and infrastructure providers. Wiz demonstrated pre-authentication RCE against representative WordPress, Drupal and Joomla deployments, as well as remote disclosure of PHP worker memory. Its write-up also makes an important point: differences in PHP versions, configuration, extensions, application behavior and memory layout can break an exploit’s assumptions, requiring testing and adaptation for each target.

PHP2Shell this is the coordinated website published by OpenAI, Wiz, Calif and more companies that took part in a project called Patch the planet.

This is an accurate timeline:

  • On 20 October, PHP announced that two critical CVSS 9.2 vulnerabilities would be patched the following day. Public reports were already describing the issue as RCE.
  • On 21 October at approximately 13:30 UTC, PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
  • The two GitHub advisories were published at approximately 13:34 UTC.
  • Detailed technical articles appeared around the same window.

It would be inaccurate to say that PHP published full exploitation details without a mitigation. The process had been coordinated since August, distributions and major infrastructure providers received advance material, and fixed upstream releases accompanied the advisories. The operational problem was narrower: the RCE countdown and framing were public before everyone could patch, while downstream availability was uneven. PHP explicitly warned that rpms.remirepo.net would not have release day packages, and PHP 8.1 and older required a vendor backport, a source backport or an upgrade.

Criticizing that exposure window is reasonable, and I would support that.

Root causes

Now let’s talk about the vulnerabilities.

PHP implements a recoverable memory limit with a non local exit. When an allocation would exceed memory_limit, the engine raises a fatal error and uses machinery based on longjmp() to escape the current native call stack.

That is fine only if the function has not published half finished state. Here, it has.

CVE-2026-109618: an unfinished HashTable entry

In one HashTable insertion path, PHP increments nNumUsed and nNumOfElements before it allocates and installs the new key. If that allocation fails, teardown believes there is one more live bucket. Its key and value were never initialized, so stale allocation bytes get interpreted as a Bucket and go through normal destructor handling.

The SAPI matters here. Apache mod_php parses POST and multipart data in php_request_startup(), before the target script runs. After a startup failure, mod_php still performs request shutdown and can consume the malformed table in that same request. PHP-FPM exits the child on this startup failure path instead. PHP’s advisory still considers post startup FPM contexts source susceptible, but reports no complete FPM exploit for this CVE.

The vulnerable code is in PHP core. The startup time exploit path is not identical across SAPIs.

HashTable OOM and SAPI shutdown difference

CVE-2026-109619: a pointer that outlives its request

A PHP worker also has globals that survive across requests. Some temporarily point into the current request heap. Those pointers have to be reset before ZendMM destroys that heap.

This bug needs two requests on the same surviving worker.

The confirmed state includes Session upload progress data and, in PHP 8.5, EG last_fatal_error_backtrace. The advisory describes other error, exception, object and extension state whose reachability depends on the deployment.

mod_php has confirmed paths through PHP 8.5 fatal backtraces and the application independent Session upload progress lifecycle. FPM exits a child when startup fails, so it needs a different producer: the validated FPM Session chain publishes BG(unserialize) during a suitable application operation after successful startup, then uses upload progress as an early consumer on a later request to that worker.

There is a naming wrinkle in the public reports. Wiz groups an upload progress unserializer UAF and worker memory disclosure under CVE-2026-109618. PHP’s stale state advisory describes the Session upload progress lifecycle as a confirmed CVE-2026-109619 case. I use the CVE numbers for the root cause families, not as exclusive labels for every primitive in a chain.

Stale global state across two requests

Reaching the OOM state

An OOM anywhere is usually just a failed request. The attacker needs the first failing allocation to be the one that leaves useful state behind.

Calif published several ways to create that pressure:

  • On frontends that pass a multipart stream to PHP without a usable declared length, the initial Content-Length based post_max_size comparison can be bypassed. Calif specifically demonstrates Apache chunked HTTP/1.1 and lengthless HTTP/2.
  • Multipart parts may contain enough headers that PHP’s in memory representation is much larger than the wire representation.
  • Empty file controls are not counted as normal uploaded files, but PHP still constructs the usual $_FILES metadata.
  • Deeply nested field names force PHP to create repeated HashTables, Buckets and string keys for each metadata branch.

So a body below post_max_size can grow past memory_limit inside PHP. Changing the count, depth and sizes of those fields moves the allocation that crosses the limit. That control is useful, but it is also sensitive to the build and to whatever else allocated memory earlier in the request.

Multipart input amplification to a chosen OOM

The session path across two requests

Session upload progress runs during multipart parsing, before ordinary module initialization and before the application. A qualifying file part can initialize Session, copy the progress session ID into PS(id) and publish PS(http_session_vars) while PG(modules_activated) is still false.

Keep-alive can help if the frontend and SAPI preserve connection affinity. It is not proof of worker affinity.

Session upload progress across two requests

The memory leak is a Set-Cookie response header. The target script does not need to echo anything or call session_start().

On the modern 64 bit builds illustrated by the disclosure, PS(id) is interpreted as a zend_string.

That turns the stale string into an OoB heap read in a response header. Wiz describes an attacker sized disclosure of live worker memory and suggests looking for unusually large or malformed PHPSESSID cookies during investigation.

Useful bytes might include a heap pointer, a ZendMM main chunk pointer, a live HashTable or a destructor pointer. Nothing guarantees a particular object will be adjacent to the reclaimed slot. The exploit has to identify what came back. It also has to keep the forged length intact, survive cleanup, and get the header through the frontend.

Stale session string disclosed through a cookie

A leak is not an ASLR bypass yet

This is the point why most public PoCs sucks. They get bytes, then assume the addresses they need.

If the exact build is already known, one semantically identified code pointer and its RVA can establish the PHP image base. Against an unknown build, multiple identified code pointers can narrow or identify a profile. If:

\[P_1 = B_{php} + RVA_1\]

and:

\[P_2 = B_{php} + RVA_2,\]

then ASLR cancels from the difference:

\[P_2-P_1 = RVA_2-RVA_1.\]

That delta identifies a build only when both pointers are semantically known and the result uniquely matches a sufficiently complete build corpus. Two unexplained canonical addresses are not a fingerprint. One heap pointer and one code pointer are also insufficient by themselves because their difference still contains the independently randomized relationship between the image and heap mappings.

Please note that this does not mean that the vulnerabilities cannot be exploited, but that it is hard to create a universal exploit.

A functional private exploit exists, but its ASLR strategy is weak.

Known pointers and PHP image base under ASLR

What would be the solution?

I see three possible routes:

1) Find an arbitrary read: The cookie gives bytes from a particular heap region. A read that can be aimed at chosen valid addresses could inspect the running process, find the PHP image and resolve the structures the exploit needs. It would still need a way to avoid unmapped pages instead of blindly scanning until the worker crashes.

2) Leak another identified pointer: A code pointer whose meaning is known, plus its RVA in the exact PHP build, gives the image base. With two or more identified code pointers, their offset difference can help match an unknown build against a set of profiles.

3) Brute force: If the build is known and the candidate range is small, repeated attempts can test addresses using a crash or response as an oracle. That needs stable mappings and enough requests. When a worker dies, the stale state dies with it; the producer and consumer have to run again in the replacement worker.

Reaching RCE

Once the required heap and code addresses are known, the final stage is conventional PHP memory corruption engineering.

The public sources establish RCE and the stale state and memory corruption primitives, but they do not publish enough of the final dispatch chain to verify simplistic claims such as assigning HashTable.pDestructor directly to zif_system. A destructor callback and a PHP internal function handler do not automatically share a compatible signature. Any real tail must account for the actual calling convention, object lifetime and allocator valid cleanup.

This is not simply “overwrite RIP with system.” A fake object that reaches a desired callback but is then freed from an interior address can still abort. A one bit type or refcount difference can suppress the call. An allocation of the correct size at the wrong time can consume the forged carrier before it is used.

Linux and Windows are separate exploit backends. Linux builds generally require ELF and System V ABI reasoning. Windows uses PE images, the Microsoft x64 ABI, different thread safety state and Control Flow Guard. Adding Windows addresses to a Linux profile table does not make the exploit portable.

Valid object graph and compatible dispatch required for RCE

The Wiz state on exploitation

Wiz’s PHP2Shell article makes two distinct claims:

  • Its private php2shell combined the vulnerabilities and memory disclosure to achieve RCE in the tested containers.
  • Differences in PHP version, configuration, application behavior, extensions, and memory layout can invalidate exploit assumptions.
  • Moving between deployments required repeated testing, debugging, and adaptation.
  • AI made that per target adaptation faster, it did not eliminate it.

Deployment-specific exploit assumptions

Do not trust public PoCs

As I state on one of my X post here, please do not trust any slop PoC that you see online. We live in an age where anyone can create an exploit, but most of these people don’t understand what they’re doing, and will try to sell you a non working PoC just for public attention.

One example is Jackalsyn PoC, which not only caused unnecessary panic but also created a meme coin in an attempt to pull off a rug pull.

Non working PoC from jackalysyn

There is at least one public lab chain that implements the real PS(id) cookie disclosure and reaches command execution, but its own documentation restricts it to a calibrated PHP 8.4.26 Apache image, a single worker prefork configuration, specific PHP settings and offsets obtained from the target container.

In the wild reporting is still patchy

Telemetry and reports shared with me show attack attempts. Some appear to replay non functional public scripts. Others are configuration specific probes, OOM triggers or variants of older Session and LFI techniques. An incoming multipart request copied from a GitHub repository is evidence of scanning, not proof that the sender achieved PHP2Shell RCE.

There are also rumors that a known working chain is in criminal hands. I have confirmed that the underlying private exploit can work in its intended environment, but I have not independently verified broad criminal deployment or large scale successful RCE.

AWS’s MadPot telemetry recorded an attempted PHP2Shell exploit on 17 October, four days before public disclosure, and more attempts on 21 October. AWS calls the later traffic broad exploitation, but says it is unclear whether the observed payloads would have worked against real systems. I would review logs from at least 17 October; I would not count each request as a successful RCE.

Conclusion

Exploit unreliability is not a mitigation. Attackers do not need one script to compromise every PHP server; they need profiles for enough common builds. The original teams demonstrated that adaptation is possible, and public work is rapidly closing individual gaps.

Update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or install a verified vendor backport. PHP 8.1 and older no longer receive normal upstream releases. PHP supplied minimal source backports specifically for 7.4, 8.0 and 8.1; other end-of-life branches require vendor support or an upgrade.

If an update is temporarily impossible, set session.upload_progress.enabled=0 in configuration applied before request parsing and restart every worker. On PHP 8.5, also set fatal_error_backtraces=0 administratively and restart every worker. These measures block confirmed CVE-2026-109619 paths only. Administratively enforcing memory_limit=-1 is the separate, incomplete mitigation for CVE-2026-109618. None of these measures is a complete substitute for the patches.

PHP2Shell is serious because the primitives are in the engine, before or below the application. It is not currently Internet-wide point-and-click RCE because a reliable exploit must still understand the exact process it is corrupting. Both statements can be true at the same time.

Patch and partial mitigation map

This post is licensed under CC BY 4.0 by the author.