monstertov@php2shell

CVE-2026-109618 · CVE-2026-109619 · CVE-2026-115560

· monster research · monstertov · tov.monster

Introduction

a video popped up on my feed showing a php exploit running commands against websites. at first i assumed it was another proof-of-concept that only worked on a very specific setup. those come around all the time. but this one was different: the claims were about php itself, not just a broken wordpress plugin or a badly written application.

so i started looking into it. a few hours later, websites were going offline. not just random small sites either. the dutch NCSC took its own website offline, as it confirmed in its advisory. DigiD, MijnOverheid, Overheid.nl and the Tweede Kamer website were among the government services affected by precautionary shutdowns. the dutch government confirmed it.

hospitals were affected too. Ikazia Ziekenhuis explained why it temporarily took its website offline, and ZGT published its own update. reports also named maastricht UMC+, jeroen bosch ziekenhuis, bovenij, and others. here's the report covering those hospital outages. at that point it looked like serious shit. government services, hospitals and even the national cyber security centre were suddenly taking their websites offline over the same php issue.

for a moment i genuinely thought the whole internet was about to be vulnerable to the same thing. seeing all those shutdowns so close together felt very different from a normal security announcement. to quote int0x80, "all the cool stuff happens in memory", lets see if that is true here.

one thing worth making clear: those sites were mostly being taken offline on purpose while operators assessed or patched the risk. their websites disappearing was not, by itself, proof they'd been hacked. that distinction matters, and that seems to be a big part in the aftermath of all this.

Where the Original Research Came From

the initial vulnerabilities, now commonly called PHP2Shell, were uncovered by security researchers, not by me. Wiz Research published its technical breakdown, while Calif published its own detailed research. calif's work was done in connection with OpenAI's Patch the Planet initiative; wiz independently found and reported the issues as well. the php maintainers, those teams and other industry partners helped coordinate the fixes.

on 21 october 2026, emergency fixes were released for CVE-2026-109618 and CVE-2026-109619, both rated 9.2 critical. the problems concern corrupted internal request state and memory that php can continue to reference after it has been freed. in the right circumstances, that can lead to memory disclosure or remote code execution (rce) without logging in.

then, on 23 october, php published an informational advisory for CVE-2026-115560, another upload-progress-related issue under a very specific session-serializer configuration. unlike the first two, it wasn't fixed by the 21 october emergency versions and was planned for a later regular bugfix release. php's 23 october notice explains the conditions.

this post is about reproducing and comparing those techniques on my own lab servers, not claiming the original cve discoveries. the focus was on what actually worked, what produced rce, and what stopped working once the setup changed.

The Lab Setup and Results

the testing grew into a ten-chain research toolkit. each chain was checked in controlled docker environments with reproducible php versions and web server configurations. after that, eight additional production-like server profiles were set up on my own lab servers to test whether the same results survived different builds and configurations. all testing stayed on my own systems.

the short version: all ten chains were tested in the relevant lab conditions; chain j remained inconclusive. three worked on patched php releases when their additional application requirements were present, and one allowed repeated command execution without another crash after the initial setup. the same cascade, however, produced no successful rce across the eight production-like lab profiles without the original calibration.

that last part is not a contradiction. it became one of the most useful findings of the whole investigation.

Lab Environments

environmentphpserver / sapiconfigurationpurpose
stock-control8.4.26apache mod_php, one workerimage defaultsdeterministic chain checks
prod-control8.4.26apache mod_phpphp.ini-productionproduction-style ini
patched-control8.4.27apache mod_php, one workermatched to stockvendor fix comparison
wall-747.4.33apache mod_phpimage defaultsold-version boundary
wall-82/81/838.2.34 / 8.1.31 / 8.3.xapache mod_phpimage defaultsleak transfer tests
unserialize-lab8.5.11apache mod_phpdedicated test applicationapplication-level unserialize chain
115560-lab8.5.12 source buildapache mod_phpfive profiles, including negative controlsserializer mismatch
fpm-lab8.4.4php-fpm + nginxrequest buffering offstall-based uaf primitive

all docker images were pinned by digest so the results wouldn't silently change when an image got updated. the compose setup can be brought up with docker compose up -d.

The Actual Bug, Without Too Much PHP Internals

the original cves concern interrupted initialization or cleanup and stale global state. the upload-progress counter scenario below is one path i investigated, not a complete explanation of both cves. php has had a feature called session.upload_progress since version 5.4. it's enabled in a standard configuration and lets applications track file-upload progress inside a session. the relevant defaults look like this:

session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"

when a multipart post includes the PHP_SESSION_UPLOAD_PROGRESS field before a file part, php can start tracking the upload before the application's own php script runs. internally, the rfc1867 multipart parser keeps pointers to counters inside a session hashtable:

progress->post_bytes_processed = zend_hash_str_find(
    Z_ARRVAL(progress->data), "bytes_processed", ...);
progress->current_file_bytes_processed = zend_hash_str_find(
    Z_ARRVAL(progress->current_file), "bytes_processed", ...);

those cached pointers would become unsafe if the storage containing the counters were freed while the parser still referenced it. a normal session update alone doesn't establish that this happened, because the progress data retains a reference. the sequence below illustrates the condition being investigated, not a confirmed execution trace.

FILE_START      cache counter zval* addresses
FILE_DATA       update counters in valid slots
update()        reinitialize / replace session data
next FILE_DATA  reuse cached pointers after storage was freed

seeing that sequence in source code isn't enough on its own. the question is whether the particular arData block containing a cached counter actually gets freed and then written through again. the lab tests focused on observable behavior rather than assuming that every suspicious pointer was exploitable.

All Ten Chains, A Through J

Chain A: Session Injection + LFI

php stores an uploaded filename in the session file without treating it as executable code. if a website also has a local file inclusion (lfi) bug that includes that session file as php, the filename can become executable content. the tiny proof in the lab used <?php system('id'); ?> as the filename.

chain a worked on 14 versions from php 7.4.33 through 8.5.12, including patched ones. the big catch is the additional lfi. a normally configured application without that inclusion bug isn't compromised just by receiving the filename. this is why patching the interpreter and fixing application bugs are separate jobs.

timing matters too. with session.upload_progress.cleanup enabled, php removes the progress entry when the upload finishes. the inclusion therefore needs to happen while the payload is still present, or the setup needs another condition that preserves it.

Chain B: Unserialize UAF

this one needed an application that called unserialize(user_input) and performed a property write. a hashtable resize in that path could free arData while it was still in use. in the lab, the chain used a crash oracle, taking about 2,200 requests to recover the heap and library bases, before a forged structure redirected a destructor toward system().

on php 8.5.11, before the fixes for CVE-2026-109618 and CVE-2026-109619 in php 8.5.12, chain b returned uid=33, confirming rce in the lab. this required the unsafe application deserialization path; it wasn't a generic exploit against every website running that version.

Chain C: auto_prepend_file Without Repeated Crashes

memory corruption chains often get one command through and then kill the worker. chain c tested whether command execution could remain usable after the initial trigger.

this chain used an initial uaf event to change PG(auto_prepend_file) so it referenced a php shell already written under /tmp. php's prepend feature then loaded that file on later requests. the setup still caused one crash, but after it, the following commands ran consecutively without another worker crash:

uname -a
whoami
ls /

on php 8.4.26, chain c produced repeated command execution after setup without crashing the worker again. that was a much more stable result than a one-shot proof-of-concept screenshot.

Chain D: Serializer Confusion, CVE-2026-115560

this one is especially relevant because the affected pattern could remain present on php versions patched on 21 october.

upload progress writes attacker-influenced information into a session using its configured serialization format. the application has to start with php_binary or php_serialize, then switch session.serialize_handler to php at runtime before calling session_start(). the php format interprets | as a session record separator, so bytes written under the earlier format can be parsed in a different way. with a suitable application's __destruct() gadget, the mismatch can lead to object injection and a webshell.

on php 8.5.12, both vulnerable serializer profiles returned uid=33, confirming rce in the lab. two negative controls were also tested: disabling upload progress and leaving the serializer unchanged. neither negative control was exploitable, which is exactly what the mechanism predicts.

as of 23 october 2026, php describes this as an unlikely rce path because the configuration combination is unusual, and recommends disabling upload progress where that condition could exist until a fix arrives. that's different from saying every updated php website is still easily exploitable.

Chain E: Fiber UAF

php fibers, introduced in 8.1, add another way for an application's execution to be paused and resumed. with a specific application-side fiber pattern, a class-string callable can be freed too early during suspension, leaving a stale reference.

chain e worked on php 8.2.35, a patched release. again, the application needs to use fibers in the relevant way.

Chains F, G and H: Stock, Pure and 78lab Memory Chains

these three implementations all followed the same broad idea. a multipart request forces an out-of-memory failure at a carefully chosen allocation. that can leave PS(id) referencing freed memory. a follow-up request handled by the same worker may then leak heap or library data through the Set-Cookie header. once enough memory addresses are known, forged data can redirect a destructor toward zif_system.

enginetest configurationresult
stockimage defaults; 128m/8m limits, display_errors=Off, multi-workerphp 8.4.26, reproducible, uid=33
purephp.ini-productionphp 8.4.26, uid=33
78lablab-calibratedphp 8.4.26, verified

the same leak shapes were also tested against php 8.3.x, 8.2.34, 8.1.31 and 7.4.33. each received more than 20 probes, with zero successful leaks. across five independent implementations, the same boundary kept appearing: the technique tuned for php 8.4/8.5 didn't transfer to earlier versions.

that doesn't make older php safe. it means this particular public memory-leak route wasn't portable to them.

Chain I: Reverse-Shell Stager

one memory chain left only a 14-byte command carrier, too short for a typical reverse-shell command. the stager used a temporary upload file as the source of a longer script while its upload request remained stalled. the small command carrier only needed to invoke that existing file:

bash</tmp/php*

that's 14 bytes. the point of this lab test was the command-length restriction and the temporary file's lifetime, not a claim that arbitrary uploads give command execution by themselves.

Chain J: Stall-Based UAF on PHP-FPM

this one was the most interesting result because php-fpm changes what happens to the earlier out-of-memory chains. on fpm, an out-of-memory failure during startup kills the worker. the state that those chains need disappears with it.

chain j took a different approach: a streamed multipart upload paused long enough to trigger another progress update without exhausting memory. the test ran on php 8.4.4 with php-fpm and nginx, with fastcgi_request_buffering off so the request could reach php in pieces.

phase 1 sent (6000B)
after stall:  bytes_processed=5308
chunk 1:     bytes_processed=10510   last visible update / re-init
chunk 2:     bytes_processed=10510   no new session update
chunk 3:     bytes_processed=10510
chunk 4:     bytes_processed=10510
after end:   bytes_processed=0       cleanup

chunk one triggered the update; later chunks kept being processed without another visible session update. the worker survived, but these counters alone do not prove a stale-pointer write because normal progress updates are throttled. full rce would require confirming the invalid write and reclaiming the relevant 320-byte slab with a controlled allocation. that stage wasn't completed, so these measurements do not establish a proven primitive or complete php-fpm-rce.

there was also a weird bug in a reference poc for this technique: it used underscores in a php session id, even though php doesn't accept those characters in a session id. that broke its own monitoring. the test used valid session ids, so that monitoring issue didn't interfere with the measurements.

The Part That Didnt Work: Production-Like Setups

after testing the ten chains in their intended controlled environments, the full cascade was tested against eight production-like server profiles running on my own lab servers. those setups deliberately varied the builds and configurations to see how well the results transferred.

lab target profilewhat happened
php 8.4.26 / apache mod_php, multiple server profilesbuild offsets and configuration no longer matched the calibrated chains
php 8.2.34 / apache mod_php on freebsdmemory-leak shape didn't transfer
php 7.4.x fleet profilesmemory chains did not initialize successfully
wordpress / php-fpmout-of-memory chains lost their state; no matching application sinks for the other chains

the result was zero successful rce executions across those eight profiles.

so why did the easier containers work? there wasn't one magic answer.

The SAPI Difference

apache mod_php can keep a worker around after a failed request, allowing another request to encounter stale internal state. php-fpm can terminate the worker on the same startup failure, removing that exact opportunity. this blocks the specific out-of-memory chains i tested, not every vulnerability in fpm. chain j showed behavior worth investigating where streaming was enabled. calif's published research also demonstrates full rce on php-fpm through a different, post-startup decoder-state path.

Build-Specific Calibration

memory exploits care about details like the offsets of zif_system and zval_ptr_dtor, the heap layout, filler counts, spray shapes and forged structure lengths. recalibration was needed even between docker builds and manually compiled equivalents. different compiler versions, distributions, architectures and backports make it worse. none of the public chains used for comparison provided a general remote auto-calibration method.

ASLR and Worker Pinning

address space layout randomization (aslr) means forged structures need runtime addresses. the leak stage can help, but only if it lands first. and the request that prepares corrupted memory and the request that consumes it may have to reach the same apache worker. on a five-worker pool, that can become something like a one-in-five race per try under simple assumptions. a single-worker lab hides that problem pretty well.

Older Versions Aren't Automatically Easier

this particular leak did not reproduce on the older builds i tested. that doesn't establish a general php 8.4 boundary: public lab reports describe heap and library leaks on php 8.3.33 too. build differences and calibration remain important limits when comparing these results. application-level bugs, like lfi and unserialize sinks, are still their own risk. but an exploit tuned for 8.4 doesn't magically work across the enormous older php fleet.

The Third CVE Has Narrow Conditions

CVE-2026-115560 also wasn't universal. the target must start with php_binary or php_serialize, switch to php at runtime before session_start(), and have upload progress enabled. the negative controls mattered because they checked those exact assumptions.

So Was the Internet Actually Getting Hacked?

at the time of writing, 23 october 2026, there's a difference between widespread risk, visible scanning or attempted exploitation, and confirmed successful compromise. the dutch NCSC said it was investigating a report of possible exploitation abroad, but that success had not been confirmed. its updated advisory says exploitation is complex, while still urging everyone to patch.

that matches the lab results: the memory bugs are serious, and code execution is demonstrable on particular builds. but a working proof-of-concept with known offsets isn't automatically a universal exploit against an unknown server. the eight harder lab profiles made that difference pretty clear.

and the sites that went offline on 21 october? for the examples above, the shutdowns were reported as precautionary security measures, not evidence that those services had already been taken over. thats worth remembering whenever a vulnerability suddenly dominates the news.

What Website Owners Should Do

update php immediately for the first two critical cves. the minimum patched upstream versions released on 21 october were:

branchpatched version
php 8.58.5.12
php 8.48.4.27
php 8.38.3.36
php 8.28.2.35

older end-of-life branches need vendor-maintained backports, the exceptional php source backports for 7.4, 8.0 and 8.1, or migration to a supported version. those source backports do not restore ongoing support. and as of 23 october, these versions do not yet fix CVE-2026-115560; that issue has its own narrow conditions and the php project's temporary guidance.

if your application doesn't need upload progress, disable it in the effective web php configuration:

session.upload_progress.enabled = Off

that might mean php.ini, an apache setting, or an fpm pool setting such as php_admin_flag. restart the service actually executing php after making a configuration change, making sure every old worker is replaced. an apache restart won't restart a separate php-fpm pool.

also check the php version through the web sapi. php -v in a terminal can report a different version from the one serving website requests. don't assume they're the same installation.

neither session.upload_progress.cleanup = On nor nginx's fastcgi_request_buffering on is a replacement for patching. those can change the conditions needed for certain chains, but they are hardening measures, not fixes. applications also need an audit for unsafe unserialize() calls, local file inclusion, and runtime changes to session serialization. where possible, unexpected multipart uploads can be blocked on routes that shouldn't accept uploads, instead of breaking legitimate uploads site-wide.

Conclusion: Serious, But Not the Hack-the-World Vulnerability I Expected

when i first saw that video, i thought it was going to be another interesting exploit demo. when the NCSC, dutch government sites and hospital websites started disappearing a few hours later, i honestly wondered whether the whole internet was about to become vulnerable overnight.

after the ten-chain tests and another eight production-like profiles in my own lab, the picture looks a lot less dramatic than it did on day one. still serious, though.

PHP2Shell is serious. two critical cves got emergency patches, and a third related cve was disclosed on 23 october with a specific configuration-dependent route that still needed a fix. some of the lab chains can execute commands, some survive the initial patch because they need additional application bugs, and the php-fpm research shows there's more to investigate.

but based on what i could reproduce, this isn't the simple "hack the world" vulnerability i initially imagined. the gap between a calibrated laboratory exploit and one that reliably works against unknown servers is big. different builds, aslr, workers, php versions and application configurations all matter. that's good news for defenders, but definately not a reason to leave vulnerable php installations online without updates.

what worries me more is what comes next. the original researchers used ai to help uncover and prove problems hidden in php for years, and this code is getting a lot more attention now. i wouldn't be surprised if more serious variants, or entirely new cves, turn up over the coming days. that's a possibility, not a prediction that another internet-wide disaster is guaranteed.

for now: patch the known issues, remove unnecessary exposure, and keep watching the research. the most useful part of the investigation wasn't just seeing uid=33 in a lab. it was finding out exactly where those results stopped translating to other environments.

Original Research and Outage Reports