Cutting Through the php2shell Noise
On October 20, details of php2shell, two pre-auth RCE vulnerabilities in the PHP core discovered by Calif (CVE-2026-109618 and CVE-2026-109619), leaked ahead of coordinated disclosure. Advisories listing affected versions followed quickly, along with a flood of PoCs, many AI-generated and non-functional.
We waited until we had our own data. This post covers what we learned building and testing exploits. In short, php2shell is exploitable but more conditional than some coverage suggests. Our analysis indicates that exploitation is heavily version dependent, and that the precise heap layouts required to exploit under the default display_errors=Off setting make it much more difficult than the non-default display_errors=On scenario. We will delve into why this is the case further into the post. Almost all of the public exploits released for php2shell focus on the dangling “PS” session global variables case, which impacts instances of Apache running mod_php or applications serving PHP code using the “php -S” development server like in the Steal My Bitcoin challenge.
Our analysis focused heavily on the mod_php exploitation path. However, we’re starting to see early signs of exploits targeting the second vulnerability with the serializer, which affects PHP-FPM, such as kengwang/php2shell-fpm-exp. We haven’t yet had the opportunity to fully analyze that exploitation path and confirm the published exploit works. This article will focus on explaining why the mod_php exploitation scenario is exploitable, where the real-world risk is, and what organizations can do to be better prepared for events like this in the future.
Why Exploitation is Possible, But Challenging
While it’s true that LLM powered exploit development workflows are incredibly powerful, it’s also clear from this incident that the blind usage of LLMs to build and release exploits without proper verification has resulted in significant confusion and skepticism amongst the cybersecurity community.
At a high level, a handful of things make this vulnerability hard to exploit reliably:
- Bypassing ASLR and leaking heap pointers. To invoke the zif_system function with attacker-controlled input you first need heap pointers pointing to attacker-controlled data, which means defeating ASLR and achieving an information leak. This is the part we walk through in detail in the next section.
- Version- and build-specific offsets. Even if an attacker is able to leak the PHP version running on the server, other factors like the compiler used, version, flags all impact the binary layout. A working exploit for one build won’t necessarily work against another running the same PHP version.
- Maintaining an offset database tied to specific builds. This requires significant effort on the part of the attacker, as they need to maintain a database of PHP versions but also tie those versions to the specific PHP builds used by specific distributions.
- Precise heap control to trigger the OOM. Triggering the vulnerability in an exploitable way under the default display_errors=Off requires exact control of the heap layout, and it’s highly dependent on both the PHP version and the architecture (ARM or AMD64) used by the interpreter. Porting to a new version isn’t as straightforward as an ASLR bypass, where you simply grab offsets for a different version by pulling down the distro binaries. We cover why in the next section.
- Pairing two requests on the same worker. In the mod_php scenario the attacker needs the server to process two requests on the same worker without that worker handling a request for another client in between “request A” and “request B”.
- Non-default configurations. From our scanning, the vast majority of applications running PHP don’t expose version information, and it’s common for customers to tweak variables like memory_limit within their applications, which breaks exploits that assume default configurations.
One thing that does work in the attacker’s favor is the worker forking model of mod_php. ASLR is not re-randomized on every process fork the way it is with something like OpenSSH, so once you achieve the information leak to determine the base address of the process heap and the location of the libphp library, all subsequent workers spawned by that server until it restarts will use the same heap base, layout, and module base addresses.
This is where historically low-severity findings like an exposed phpinfo page matter. They hand attackers the exact data they need to tailor the exploit to your particular environment and configuration. A low-risk finding is often only low risk until it can be combined with the right vulnerability to achieve a critical or high-risk outcome.
Why Exposing PHPInfo Externally is Dangerous
Exposed phpinfo pages usually show up as low or informational findings, and on their own that’s often fair. For php2shell, though, one exposed phpinfo page answers most of the questions an attacker needs answered before deciding whether a target is worth the effort. The screenshots below are from the same PHP 8.4.26 lab environment we used for the demo.
The top of the page gives the exact PHP version, the architecture, and how PHP was built. Since exploitation is heavily version dependent, the version alone tells an attacker whether their offsets apply. In this case the Build Provider field also shows the target is running the official Docker image, so an attacker can pull the same image and work out offsets locally. The Server API field shows Apache 2 Handler, which means mod_php, the path the public exploits target.
The directive table tells an attacker whether they’re on the easy path or the hard one. Here display_errors is Off, so the display_errors=On shortcut isn’t available and exploitation depends on the leak described earlier.
The memory_limit value matters too, since triggering the OOM condition requires getting the heap right up to the edge of that limit.
The apache2handler section shows which Apache MPM is in use. This server runs prefork with mod_php, so every worker is forked from the same parent and, as covered earlier, shares its memory layout until the server restarts.
Each of these fields is minor on its own. Together they let an attacker skip most of the reconnaissance and guesswork that would otherwise make exploitation slow and noisy. If phpinfo shows up in your scan results or a pentest report, remove it rather than accepting it as a low-risk finding.
Demo: Exploiting php2shell Against PHP 8.4.26
The following demo video shows our exploit achieving remote code execution against PHP 8.4.26 running under Apache with mod_php. The target is a lab environment we built for this research.
Why display_errors=Off Makes Exploitation Difficult
From our perspective, one of the hardest things about exploiting the vulnerability is achieving the information leak required to obtain heap pointers and code section pointers within libphp to achieve remote code execution on the server. In this section, we want to explain how this part of the exploitation chain works as it was the most difficult step required to achieve remote code execution under default configurations of PHP.
The core logic we will be covering is within the “php_session_rfc1867_callback” function. The purpose of this functionality is to support implementing things like a progress bar for file-upload tracking when a user is uploading a large file. The server establishes a PHP session and then tracks file upload progress within that session in a way that can be queried by hitting another PHP file exposed by the application for file upload tracking.
As discussed in the Calif writeup, this allows an attacker to establish a PHP session on the server before module activation has completed. If an out-of-memory error occurs during multipart file-upload parsing this can leave dangling pointers in the session “PS” global variables implemented within the module that are then reused on subsequent requests to the server.
A lot of the initial exploits for the php2shell vulnerability that were released required display_errors=On to be configured on the server. When this setting is enabled, it makes exploitation quite a bit easier for reasons we will discuss by digging into the code of the PHP interpreter.
We start our analysis in the php_session_rfc1867_callback function which is invoked during the parsing of multi-part form data on the server. We focus on the “switch(event)” statement which is the core driver of the logic within the function. To establish a PHP session on the server before initialization the attacker needs to trigger the upload progress tracking state by submitting a form with a “PHPSESSID” field and a field named “PHP_SESSION_UPLOAD_PROGRESS” for upload progress tracking to be enabled.
After progress->sid and progress->key are set we can then satisfy the conditional check at the beginning of MULTIPART_EVENT_FILE_START which allows us to continue onto the check on progress->data being defined which it would pass on the first invocation.
If this is the case then an attacker-controlled string is constructed and pointed to by the “PS(id)” global variable. The “PS” macro in this code is a macro for referencing global variables within the PHP interpreter itself. This makes the PS(id) variable an attacker-controlled variable under certain conditions.
On a subsequent request the “PS(id)” session value can be used in a call to php_url_encode which is then used when generating a “Set-Cookie” response header with PHPSESSID=”PS(id)”. If an attacker can trigger an out-of-memory condition where PS(id) points to stale data that can be reclaimed by an attacker, the attacker can spray the application heap and construct a string object with an attacker-controlled length to leak adjacent heap data on a subsequent request to the server.
This is great, but the code path driving this logic has a conditional that the PS(use_cookies) configuration option is enabled and that PS(send_cookie) is set to one/true. The PS(use_cookie) conditional branch doesn’t really matter too much as it’s a default configuration, but there are only a few specific scenarios within session.c where “PS(send_cookie) = 1” and triggering this condition can be quite difficult.
An attacker can trigger a scenario in “php_session_initialize” where “PS(send_cookie) = 1” if the first byte of their “PS(id)” value is a null byte. This triggers a reallocation of the “PS(id)” string and then “PS(send_cookie) = 1”. The attacker can trigger this condition as we previously established that the attacker-controlled “progress->sid” is copied into “PS(id)” when the MULTIPART_EVENT_FILE_START branch is executed within the main switch statement driving this logic.
A core challenge though is that since “PS(id)” has been reallocated it doesn’t point to attacker controlled data before it is immediately used and then set to zero after a successful call to the “php_session_send_cookie” function.
The image given below shows the php_session_initialize function being invoked and eventually doing a comparison where it frees and then reallocates a new session identifier if the PHPSESSID value supplied within the form data by the attacker is a null byte:
The primary driver of calls to “php_session_initialize” that we control is through calls to the php_session_rfc1867_update function which immediately calls our target function after a brief conditional that doesn’t impact control flow for our purposes:
As an attacker we can trigger calls to this function within our “MULTIPART_EVENT_FILE_START” switch statement case. We previously discussed this switch case and how it can be used to place an attacker-controlled value within the “PS(id)” string value earlier within this section.
From here we can conclude that to trigger an information leak we need the PHP interpreter to reuse a stale “PS(id)” pointer while “PS(send_cookie) = 1” so that when “php_session_send_cookie” is invoked in our subsequent request the interpreter reads an attacker-controlled PS(id) string value and leaks adjacent heap memory.
The key challenge is that this logic is almost entirely executed in a single execution flow when the FILE_START block is executed it copies the attacker supplied PHPSESSID within progress->SID into PS(id) and then calls the rfc1867_update function which then calls the “initialize” function mentioned previously. If the attacker supplies a null byte for the PHPSESSID value then the interpreter generates a new PS(id) value and sets PS(send_cookie) = 1 before immediately calling php_session_send_cookie, setting the Set-Cookie header with the newly generated PS(id) and then returning and setting PS(send_cookie) = 0.
However, as established previously this is an incredibly narrow window as the “PS(send_cookie)” value is only set for a very narrow period between function calls. The only way we’ve found so far to trigger this condition is to cause the “php_session_send_cookie” function to fail as under failure conditions PS(send_cookie) won’t be reset to zero.
If we re-examine the php_session_send_cookie function we can enumerate the failure modes. The main one being if SG(headers_sent) is true or an out-of-memory condition is triggered within the function call itself. Both of these would leave PS(send_cookie) = 1 enabled after the function call chain.
One way to trigger the SG(headers_sent) condition is to trigger response generation earlier in the chain. The main way that we’ve found to do this is when display_errors=On if you trigger an error message earlier in the interpreter execution cycle this will result in headers being sent to the client and SG(headers_sent) being true.
After the failure case triggers in this scenario the parser continues parsing the multipart form field and it’s pretty straightforward to then just trigger an out of memory condition that leaves PS(send_cookie) equal to true and PS(id) pointing at stale pointer data.
Under conditions where display_errors=Off this is much more difficult as we need to exactly time the out of memory error trigger to occur during an allocation within the php_session_send_cookie function such as during the php_url_encode function call. It’s possible to trigger an out of memory condition when this function is called as it allocates data on the heap. The image given below shows how the internal php_url_encode implementation allocates data on the heap when performing url string encoding:
However, triggering this condition is quite complex and requires exact control of the heap layout down to the number of remaining items within the freelists for a given bucket size. The core problem is that allocations from the freelist don’t result in new memory being allocated and no size checks are performed during this step if that occurs.
This means that we need to be exactly on the edge of triggering an out of memory issue as soon as we hit this allocation with all the freelist items exhausted from the bucket size we are allocating from so the allocator needs to request new pages of heap data which then triggers the memory_limit checks and thus triggers the out of memory error.
This is incredibly complex and requires a detailed understanding and mapping of heap utilization during all of the code executed up to the point where this condition is triggered. We’ve been able to trigger this condition in practice, but it requires intense tuning which can be difficult for a variety of reasons. For example, some of the allocations we trigger as an attacker go into a hashtable, and we struggled with the hashtable resize operation triggering the OOM condition before the php_session_send_cookie function could be invoked.
The window where PS(send_cookie) = 1 provides a narrow window to trigger the OOM and trigger the desired state, and is one of the bigger reasons the display_errors=On case is much easier to exploit than the display_errors=Off case.
Worth emphasizing: across an analysis of all our customers’ exposed phpinfo files, we didn’t see any live hosts with display_errors=On. In other words, the easy path largely doesn’t exist in the environments we looked at, and real-world exploitation depends on the much harder display_errors=Off leak described above.
Earlier PHP Versions Use strlen for the Set-Cookie Length
When analyzing customer facing instances we found a few PHP applications using mod_php with older PHP versions like 5.6.40. One thing we noticed when working to adjust our exploit to target PHP 5.6.40 is that certain older versions of PHP use strlen for determining PS(id) length.
This limits the usefulness of the information leak on certain PHP versions as strlen will stop at a null terminator and thus only leak data until it encounters a null byte. This makes things difficult especially considering that on architectures like x86_64 the user-mode pointers we want to leak always contain null bytes in their most significant bytes, so strlen stops before reaching them.
The image given below shows the php_session_send_cookie function taken from the PHP 5.6.40 release source code:
This makes the information leak considerably harder to pull off on these older versions, since a leak that terminates on a null byte returns much less adjacent heap data to work with. We haven’t yet reached a conclusion on whether 5.6.40 is actually exploitable in practice given this constraint, but it’s worth being aware of as a factor that changes the difficulty on older targets.
Thoughts On Target Selection for the Vulnerability
As soon as the php2shell vulnerability dropped we immediately spun up a team to investigate building a scanner to detect vulnerable instances for customers. However, we also have several concurrent active red team engagements running at the moment so one of our focus areas was on identifying ways that we could weaponize the vulnerability to use against customer systems during these engagements to gain a foothold in their environment.
One of the challenges with a remote code execution vulnerability within PHP applications is that the vast majority of externally facing PHP applications are CMS instances like WordPress that are often hosted by third-party hosting providers like WPEngine or Pantheon, or they are hosted on-premises but located within a DMZ isolated from the internal network environment.
Edge Appliances with PHP Management Interfaces
While doing target selection we identified a few widely used edge appliances with management interfaces written in PHP. One of them was Palo Alto Networks PAN-OS. We confirmed in our lab environment that the PAN-OS management web interface is written in PHP and uses mod_php with Apache. PANW also published an advisory confirming PAN-OS is affected and publishing an official advisory on CVE-2026-109618,
Another notable appliance is Citrix NetScaler, whose management interface also runs PHP. Citrix has since published a security advisory for CVE-2026-109618 and CVE-2026-109619 along with guidance on protecting NetScaler management access from the PHP flaws. We haven’t had time yet to dig into this appliance ourselves to confirm exploitability, but it’s another notable example that the community could potentially target for research purposes.
These types of management interfaces also likely receive a relatively small amount of traffic, which makes it easier to land both requests on the same worker compared to a WordPress site that receives lots of traffic.
Exposed Management Interfaces Are More Common Than They Should Be
The key challenge with both of these appliances is that the administrative interface is not exposed to the Internet by default, and so it requires a poor configuration on the part of the customer/client environment to expose this portal to the internet.
However, this is actually surprisingly common if you look at recent examples. For example, at Praetorian back in 2023 we found an unauthenticated remote code execution vulnerability in the F5 TMUI management console. We did an internet-wide notification using data from Shodan to proactively reach out to impacted companies with their management console exposed to the Internet.
The results were pretty bad as a large number of companies had their F5 management console exposed to the Internet on at least one of their appliances, including VPN appliances for several major banks and multinational companies.
Another example is the GoAnywhere MFT vulnerability exploited by ransomware groups, CVE-2025-10035, a deserialization flaw in the product’s License Servlet. The main precondition for exploitation was having the GoAnywhere Admin Console exposed to the Internet, which, despite being an internal management console, was a common configuration.
Perimeter RCE Is Valuable Even Without Internal Network Access
One thing worth calling out is that getting RCE on a host in a customer’s DMZ is often valuable even when that host gives you no path into the internal network. The value isn’t always the shell itself, it’s the trust that the host’s IP address and domain inherit from the organization.
A good example is conditional access. On past engagements we’ve landed RCE on an instance sitting in a customer’s DMZ within their own ASN, and from there we were able to authenticate with credentials we’d pulled from password spraying without ever being prompted for MFA. The reason was that their conditional access policies treated authentication originating from their own IP ranges as trusted and exempted it from MFA. Being on a host inside that address space meant our authentication looked internal, so the MFA requirement that would have otherwise stopped the sprayed credentials simply didn’t apply.
Domain reputation is another angle, and it doesn’t even require internal access. In the past we’ve compromised customer WordPress and Drupal instances hosted on external cloud providers with no connectivity to the internal network at all. Even so, the phishing campaigns we ran off of them were highly effective, because the emails and landing pages were coming from a legitimate customer-owned domain rather than a lookalike we’d registered. The trust the domain carried did a lot of the work for us.
Those same boxes are often worth more than their domain, too. A compromised Drupal or WordPress instance frequently has secrets sitting right on it: GitHub personal access tokens baked into deployment scripts run on the server, credentials left behind in a user’s bash history, or SMTP and Mailgun API keys for the application’s outbound mail. None of that requires internal network access to reach, it’s just sitting on the host we already have code execution on.
Do Language Models Provide Meaningful Exploitation Uplift?
At Praetorian, we are an AI native company and we are constantly trying to find ways to improve our workflows and efficiency using both open-weight and proprietary models from companies. In this section, I wanted to talk a bit about the key challenges we ran into when writing an exploit for the vulnerability and how we overcame them.
We previously discussed some of our research results using LLMs to exploit vulnerabilities in the FreeBSD kernel (part 1 and part 2) and generally found that the models really excelled at tasks like understanding allocator internals, controlling and debugging heap layouts until the exploit worked, and really helped to speed up a lot of the tedious and incredibly time-consuming processes of exploit development. When the php2shell vulnerability was released we became very curious about how useful these models would be at producing an exploit for the vulnerability.
Where the models struggled
One of the big challenges with using language models for exploit development is that they are incredibly prone to cheating. We would often configure or set up a lab environment for the model to write the exploit only to later find out it had modified the lab environment to enable display_errors=On. This would even happen in scenarios where we used things like “/goal” with a judge agent to determine the suitability of the solution.
In another scenario, to keep the model from cheating, we had it target a remote server with ASLR enabled that it couldn’t reconfigure. Instead of finding a reliable information leak, the model went down a rabbit hole trying to brute-force 64-bit ASLR, which isn’t practical, and got stuck for several hours before we had to step in and unstick it.
At one point, a model interacting with a local docker lab environment started using “docker exec” to read the “/proc/1/maps” memory mapping from the procfs inside the docker container instead of leveraging the information leak vulnerability.
The other big challenge is the models’ tendency to assert things that aren’t true. They may initially determine that something is possible and then later decide it isn’t. It’s incredibly hard to guide the agent on the work it needs to do if you also don’t fundamentally understand the vulnerability and how it works.
For simpler exploits, it’s definitely possible for the models with the correct harness to one-shot an exploit for the vulnerability, but for more complex exploits like php2shell it’s generally helpful to also have a human operator with previous experience in exploit development who is capable of understanding what the models are doing as well as understanding the code of the PHP interpreter to help troubleshoot the work being done by the models and understand when they are hallucinating success cases or going down the wrong path.
As many security practitioners experienced the week php2shell dropped, it wasn’t uncommon for someone to publish a PoC where their LLMs cheated and published something that didn’t actually work in practice.
Where the models helped
One of the bigger lifts we got out of using language models was in being able to quickly get up to speed on the structure of the PHP interpreter codebase, navigate different parts of the code, trace function calls, and understand the overall structure of the codebase and the vulnerability. This provided a meaningful lift over the pre-LLM era of exploit development.
We also found that the models, when guided properly, were quite good at understanding the allocation primitives provided to the attacker through the interpreter code, and once they understood the vulnerability they were good at triggering the UAF condition, iteratively debugging with GDB and reviewing source until they could reliably reclaim the object. In other words, the models handled a lot of the heap grooming and heap manipulation that would previously have taken a human operator days or weeks of manual debugging.
We have a lot of interesting work underway related to building LLM powered exploit development and N-day exploitation pipelines that I’m hoping we can share sometime in the future, based on ongoing work at Praetorian as part of our Attack Helix architecture.
A Quick Disclaimer
There may be better or easier ways to exploit the vulnerability that we have not yet discovered and these may come out later on. This analysis is really just our initial triage results. The core thing we wanted to highlight is despite the noise and AI slop being released on the internet, the vulnerability is exploitable. We wanted to cover what we believe is one of the less understood and more difficult aspects of exploiting the vulnerability required to bridge the gap between display_errors=On and display_errors=Off.
Also, we are not giving attackers anything new in this section as 9bie/php2shell-bypass-aslr-exp already has logic that implements this functionality. The purpose of this section is simply to explain how this works so the broader community can have a better understanding of how the exploit functions including defenders and other people not using this vulnerability for offensive actions.
We’ve spent time reviewing other solutions and exploits to the vulnerability like the StealMyBitcoin-PoC solution, but this one appeared to rely on the fact that the CTF environment was running with php -S under default settings which resulted in display_errors=On being enabled within the environment. So far we haven’t found a solution to the ASLR bypass that doesn’t involve triggering an OOM in php_session_send_cookie or rely on display_errors=On for exploitation purposes.
Conclusion
The key takeaway is that for the php2shell vulnerability, while exploitation is possible, it requires detailed information on the version and build used by the target, alongside knowledge of the target’s PHP configuration, such as tweaks to memory_limit, which are common.
At this point, we have built out several scanners for php2shell, ranging from behavioral scanners that try to trigger the OOM condition on the server and gauge the follow-on response to determine whether mod_php is in use, to deterministic scanners that search for exposed phpinfo files or version information returned by servers to identify vulnerable systems.
The gist of this is that while exploitation is possible, we don’t expect php2shell to see the kind of easy, internet-wide mass exploitation we saw with Shellshock or Log4Shell, where a single reliable exploit could be sprayed across the internet.
Our analysis in this article focused on the variant impacting mod_php, as while many modern deployments use things like nginx with PHP-FPM, it’s still very common to find high-value appliances or applications running Apache with mod_php.
In terms of next steps for our research, we plan on continuing to refine our scanners for the vulnerability while working to better understand and weaponize the FPM variant discussed in the second part of the php2shell blog post from Calif. Beyond the early proof-of-concept we mentioned earlier, we expect the community will have more complete and validated public exploits for the FPM variant over the coming weeks.
More broadly, php2shell is a reminder that the findings we rate as low severity today, like an exposed phpinfo page or management interface, are often exactly what make the next critical vulnerability exploitable against you.