Back to more research

How exploitable is PHP2Shell? - A practical attack chain against PAN-OS

How exploitable is PHP2Shell? - A practical attack chain against PAN-OS
Benjamin Marr
Benjamin Marr
Security Engineer

Another overhyped vulnerability?

When PHP2Shell (CVE-2026-109618, CVE-2026-109619) dropped and the initial pre-patch reports circulated, my first reaction was that this could be another vulnerability which receives huge amounts of hype, but then turns out to be incredibly difficult to translate to real-world applications.

We’ve been here a few times before - anyone remember vulns like Spring4Shell, which was highly anticipated but ended up a damp squib?

Since PHP2Shell was released, there has been a lot of discussion online around its impact. It’s the most talked about vulnerability in the past year at least, and probably significantly longer than that, but discussion of how bad it really is has seen a split within the cybersecurity community.

On one hand, there were good reasons to be skeptical. Most of the early public PoCs didn’t work, or made contradictory claims. A HTTP 500 response was regularly being presented as evidence of remote code execution, and all public demonstrations were built for very specific PHP versions and environments (minimal number of workers, non-production configs). The vulnerability itself is complicated and unreliable when under load and there was a lack of evidence of mass exploitation. There were also some early attribution reversals. Unit 42 initially linked some compromise activity to PHP2Shell, then retracted that when it couldn’t confirm that these CVEs were part of the initial access method. Sophos also corrected an early assessment, although it later published a separate successful-exploitation claim.

On the other hand, we had people demonstrating with evidence that it can be exploited on specific systems. LiveOverflow seemed to take this as a personal challenge to prove some people wrong (I respect that level of stubbornness) with his videos and tweets. Trail of Bits put out a clear proof of concept video, other users on X have been benchmarking how quick frontier LLMs can create an exploit for it, and someone set up a vulnerable website with a bitcoin wallet for someone to steal as a reward, which was exploited quickly after release despite it being a non-standard setup (php-cli). Cloudflare even launched a CTF to collect real-world bypass data and strengthen its WAF protections.

The point that stuck with me was that attackers no longer necessarily need a universal exploit for mass exploitation. If a product ships with the same PHP build, config, and web stack thousands of times (think: WordPress packed in Docker, or Palo Alto devices to name just two), an LLM can help grind through the target-specific work once and produce something reusable - and then the masses could be attacked.

So with discussion aside, it was clearly going to be a good learning opportunity - so I got stuck in. I had an old PAN-OS VM available, I wanted to learn more about the vulnerability, and storm Bolero was doing its best to rearrange my Halloween decorations in the garden. So I thought this would be a great time to see how far I could get, and what I could learn.

How is PHP2Shell actually exploited?

In my initial post over on our regular blog, I covered the two CVEs and the affected versions so I won’t repeat that information here. Instead, we’ll jump into what the exploit does at a lower level.

For a bit of background information that is important, PHP has individual workers that it spreads requests across to deal with load, and the crux of successful exploitation revolves around their lifecycle. To make this more digestible, we’re going to treat the two bugs as a singular chain rather than splitting them out. The bug makes more sense if you follow a single PHP worker across the two requests. At the core of the problem, most of the memory used by a request is thrown away when that request finishes. Some PHP state, however, belongs to the worker and survives for the next request. 

The first request of this chain deliberately runs PHP out of memory while it is halfway through updating that longer-lived state. PHP does not unwind everything neatly. It calls zend_bailout() and jumps out of the current request:

zend_error_noreturn(E_ERROR, format, limit, size);

/* ... */

zend_bailout();

That ends the request and frees its allocations, but it does not reverse every update already made to the worker. The worker can therefore be left holding a pointer to memory which has just been freed. 

Then, a second request has to land on that same worker and reuse the freed memory with controlled data. When PHP follows the old pointer, it is no longer looking at the original object. It is looking at whatever replaced it. Depending on which stale value we target, that can make PHP disclose memory, or follow attacker-controlled metadata during cleanup.

This is exactly why an out-of-memory (OOM) error or HTTP 500 is not evidence of code execution. The OOM is only the setup - the useful part to an attacker is getting a later request onto the same worker, reclaiming the right memory allocation, and getting PHP to consume the replaced data.

In this chain, I first use a stale session ID to make PHP return a slice of memory in the cookie of the response. This reveals the target memory addresses needed for the next stage, where I replace a second stale session value with a fake array that will make PHP call its own command-execution function during cleanup.

The idea is simple enough. Making all of those allocations line up is not. The exact layout changes with the PHP build, configuration, SAPI, extensions, request routing, and whatever else that worker has recently handled.

Why PAN-OS?

PAN-OS was the perfect fit for me to demo PHP2Shell in action, because I already had access to a VM within our lab for security research. Our target’s management interface used Apache prefork with PHP 7.4.6 loaded through mod_php, along with files-backed sessions. It also exposed PHP’s upload-progress functionality, which was central to the session-lifecycle exploit path described by Calif. Additionally, the management plane of a Palo Alto device is a very interesting target in itself; it sits in a privileged position on the network and has a critical impact if compromised.

Initial steps

The first step was to recreate the important parts of the PAN-OS PHP environment inside a Docker container. This gave me something I could instrument, fuzz, and repeatedly crash without spending half my life restoring the VM (or waiting for it to reboot). More importantly, it let me work through what each part of the request was doing and understand how to trigger the bug reliably.

Getting the OOM crash was straightforward, but it was only the beginning. The next question was: how the hell do I get a pointer back out? A few people had hinted that they had found a disclosure technique, but there was not much public detail at the time. Tracing where the stale session ID was eventually used pointed towards PHP’s Set-Cookie response. If I could replace the freed session ID with a fake string, PHP would helpfully return the contents to me as a new session cookie.

I managed to get controlled data into that response, but there was a catch. My request supplied the session ID as another multipart field, and parsing that field disturbed the stale files-handler memory I was trying to preserve. I thought I had a leak working, but this was because I’d added a debugger guard that skipped the cleanup code causing the crash. Once I took the guard away, the worker died before it could send the cookie back. At this stage it was very close, but no leak as of yet.

I had a breakthrough when I realised that PHP’s upload-progress handler could obtain its session ID from the inbound Cookie request header instead. These are two separate uses of the cookie: the request cookie selects the session, while the response cookie carries the leaked data back to us. 

Moving the input session ID out of the multipart body removed the troublesome allocation and allowed both session handlers to close normally. After that, I could get real pointers back into the cookie, without having to rely on the debugger propping up the worker.

Turning a session cookie into a memory disclosure

At this point I knew the worker was surviving the out-of-memory request with a stale state, but I still needed to find something useful to do with it. A crash was easy enough. Getting memory back in a response was the interesting part.

After the request failed, the worker still held onto the session ID’s address and length, but the memory at that address had already been freed. I then traced where PHP used those values and found the ordinary session-cookie path:

e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)));

The worker still thought that the freed memory address held the session ID from the failed request. So I could use the next request to put my own data there, including a fake string header that claimed the session ID was much longer than it really was. When PHP tried to send the session cookie, it trusted that length and kept reading into nearby memory. By setting an oversized length in that fake string header, I could make PHP read past the session ID and into nearby heap memory.

PHP would then URL-encode those bytes and return them in Set-Cookie as if it were sending a perfectly normal session ID.

Knowing that it should work and making it work were two very different things. I still had to discover which request allocations would reclaim the old string without destroying the surrounding session state. PHP groups its memory allocations by size, so sending my data in the next request wasn’t enough on its own. I had to get it into the space left by the old session ID without overwriting the files handler nearby.

I kept changing the size, the nesting, and the order of the multipart fields, trying to get the next request to reuse the freed session-ID memory without crashing. Eventually PHP sent back a large cookie containing a chunk of heap data and pointers into the PHP module. One of those pointers belonged to a function PHP uses when cleaning up arrays. Because I know that functions position within the module, I could work out where the module was loaded in memory for that run:

PHP module base = leaked callback - callback offset

The exploit used four requests in sequence:

All four need to reach the same PHP worker for the chain to hold together. The relationship between the response and the final side effect looked like this:

The actual markers from that run were as follows, although I’ve deliberately left the reusable symbol offsets and exact cookie positions out:

Getting code execution

With the heap and PHP module addresses in hand, I turned to PS(http_session_vars), PHP’s internal reference to the session array. The second out-of-memory request left that reference pointing at freed memory too. When the next request handled session upload progress, PHP tried to destroy the old value before replacing it:

if (!Z_ISUNDEF(PS(http_session_vars))) {

    zval_ptr_dtor(&PS(http_session_vars));

}

A2 left the session-array reference pointing at freed memory. In B2, I filled that space with a fake reference and array, which PHP followed when it tried to clean up the old session value. I pointed the fake array’s cleanup callback at zig_system, PHP’s internal handler for system(). When PHP cleaned up the array, it ran my command without needing a ROP chain.

The first successful run wrote a tiny marker file as the unprivileged web worker (touch /tmp/pwned). Using this technique, I successfully managed a reverse shell out to an attacker controlled host.

At this stage, I had code execution against the stock Apache/PHP service on the PAN-OS VM. The successful attack chain flow went as follows: A1 produced the expected 500, B1 returned the memory disclosure, A2 produced the second stale reference, and B2 closed without a response. The proof file (and reverse shell) was created by the nobody worker, so to gain root, a privilege escalation would be required to get there which is outside the scope of this post.

The full output is a bit much for a blog post, so here are the key lines from the loopback run:

‍

Keen observers of the output above will notice that the target was localhost. For this proof, I ran the exploit on the appliance and connected directly to Apache’s loopback-only listener. An external user reaches the same PHP service through nginx on the management interface, which introduces the current villain…

nginx ruins the fun

The PAN-OS management interface uses nginx as a reverse proxy in front of Apache. This proxy changed both the request reaching PHP and the response returned to the client, which broke the attack chain.

Firstly, nginx buffered chunked request bodies and forwarded them to Apache with a Content-Length header. This removed one of the convenient OOM methods that worked when talking directly to Apache.

The other problem was the session cookie. The original memory disclosure via the cookie was large, but the stock nginx configuration only allocated a small buffer for the first part of the upstream response. As documented for proxy_buffer_size, an upstream response header which exceeds that buffer is considered invalid.

Apache happily returned the large cookie over loopback. nginx swallowed it and returned a 502 to the external client. Well, that’s not ideal.

In an attempt to get around this, I shortened the forged session ID until the response fitted through the proxy. This worked and still disclosed two heap pointers, but the first useful PHP module pointer appeared much later in the full response, thus the bit we needed was cut off and not returned.

What this meant was, we did not yet have remote code execution through the route an attacker would use if exploiting from the internet.

The two attack paths are easier to see in a flow chart:

Where this leaves us

The full chain was demonstrated against the local Apache service on the device. However, via the nginx reverse proxy, we’re currently blocked due to nginx rejecting those large headers. The remaining problem is not quite “bypass nginx”. I need to change the heap layout so that a genuine PHP-module pointer appears earlier in the disclosure, inside the smaller response nginx will accept, or find another compact way of leaking it. I will keep chipping away at that and update this post if I have a breakthrough.

This has turned into a great learning experience and, at this point, a fun CTF challenge. I have found very few detailed examples of this style of exploitation against PHP 7.x, so working through the allocator, session lifecycle and Apache worker behaviour has been just as valuable as getting the final marker. And if you want some further reading, I suggest reading the post by the researcher who found the vulnerability.

Using an LLM for investigations like this made a massive difference to me. It did not magically produce an exploit, and I don’t think it will for targets like this one (at least not yet). It needed a lot of ‘rubber ducking’ and steering and every result still needed to be checked. But it did give me a way to work through unfamiliar PHP internals, allocator behaviour, and source paths at a pace I simply could not have managed on my own. Without it, this is probably a vulnerability I would have read about and moved on from due to time pressure. I certainly would not have been able to explore it in this depth, or reach this point so quickly. For the record, I used ChatGPT Sol 5.6 and 6 mostly (with Daybreak). Despite having CVP, Claude models kept refusing, which meant that they weren’t usable.

The most realistic targets are valuable, widely deployed products where the same PHP build and configuration are repeated across thousands of systems. That could be anything from a managed WordPress stack or a supported PAN-OS release which people are actually running, rather than the older 11.0.0 image I happened to have in my lab. An attacker does not need one exploit which works everywhere. They only need one target-specific chain to be worth the effort.

I started this research skeptical of the hype. Once I began digging into the vulnerability, I moved in the other direction, and I have probably landed somewhere in the middle. Patch affected PAN-OS releases (with focus on any device running PHP which exposes a PHP based service to the internet) and keep those management interfaces restricted to trusted administrative networks. Exploitation being difficult is not a reason to leave a critical vulnerability unpatched.