PHP2Shell End to End: From a Malformed POST to the Crown Jewels
This post is a step-by-step walkthrough of Exploit-DB 52704, the PHP2Shell proof-of-concept published by OffSec. Every packet, log line, figure, and terminal screenshot below was pulled from two runs on range
JMP(10.3.0.0/16): the full run on 2026-10-24 (pcapJMP-20261024-024850, 306,043 packets) and a focused credential-recovery run on 2026-10-25 (pcapJMP-20261025-credentials-run, 48,761 packets). Both captures, the screenshots, and the sensor digests are on Zenodo record 23641810. Nothing here is illustrative; it all happened on the range.
The PHP group recently disclosed CVE-2026-109618 together with CVE-2026-109619, jointly defined as “PHP2Shell”. The flaw is in the PHP engine, not in any application, so every web-serving configuration is in scope: Apache mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed, and others. It is pre-authentication and pre-application-code, which means the engine is compromised before a single line of the application runs, and no application-level input validation can prevent it.
The memory-disclosure half leaks whatever the worker held for other requests: passwords, session tokens, API keys, even TLS private keys. CVSS 9.2 from the PHP group and Unit 42; watchTowr scored the pair 10.0.
Several exploits are under active development and will continue to evolve. This walkthrough runs the OffSec-published PoC (Exploit-DB 52704) through the whole exploit chain, in the order it happened, on a range built to model a small company: employees browsing a storefront while an attacker floods it with malformed multipart bodies, a credential read off the wire in a response body, and a database hop through a cron file drop to a vault that only the database server could reach.
Five tools (Teleseer, Wireshark/tcpdump, Suricata, Zeek, Wazuh, with Elastic/Kibana as the aggregation pane) model a typical cybersecurity defense strategy.
The walkthrough structure follows this sequence: vulnerability theory, the environment, the attack step by step on screen and on the wire, what each sensor caught with gaps included, then remediation.
Why a memory exhaustion in upload handling can be remote code execution
Three pieces have to line up for PHP2Shell. None of them is dangerous alone, which is one of the reasons these vulnerabilities went unnoticed in a stable release line.
The first piece is the session upload-progress feature.
PHP can track the progress of a file upload so a progress bar can poll it while the upload is still streaming.
The trigger is unauthenticated and request-controlled: a multipart/form-data POST containing a special field keyed by session.upload-progress.name tells the engine “this upload belongs to a session”, and the engine writes progress state for it while parsing the rest of the body.
On a stock install this only arms for requests that have an active session.
The cyber range’s storefront in our fictional company arms every request because session.auto_start = 1 gives every anonymous visitor one.
The second piece is a memory overflow condition in what happens inside the parser.
The progress bookkeeping allocates memory, and allocation can fail.
Feed the upload parser a multipart body with a gigantic progress value (the public PoC uses roughly 12 MB inside a 15.6 MB request against an 8 M memory_limit) and PHP hits out-of-memory inside the rfc1867 upload handler, mid-parse.
CVE-2026-109618 is the memory-corruption consequence of exactly that moment: the OOM path unwinds through longjmp() out of the C parser, and the Zend VM’s own state and the partially parsed request data are left dangling, some pointers still aimed at freed memory.
A longjmp() is normal in PHP (it is how fatal errors escape the engine); what makes this a vulnerability is where the engine’s bookkeeping is when the escape happens.
The third piece is weaponization of the request.
With a dangling pointer and control over what repopulates the freed chunk (that is the heap-grooming part: many requests that allocate predictable sizes), the PoC can make the engine call a function it should never call with arguments the attacker picked.
The public chain (Exploit-DB 52704) targets zif_system (the C function behind system()), reached via the destructor slot zval_ptr_dtor.
Both live inside libphp.so at build-specific offsets, which is why the PoC needs --zif-offset and --dtor-offset for the exact image.
Chained in one sentence: an unauthenticated multipart POST arms upload-progress on every request, an oversized progress value drives the parser out of memory mid-longjmp, the dangling memory is re-groomed to make the destructor slot call zif_system, and a shell runs as www-data before the application ever executes.
A caveat before we walk through the exploit.
The range weakens php.ini on purpose (session.auto_start, session.upload_progress.enabled, memory_limit = 8M against post_max_size = 64M), in the same spirit as Log4Shell’s trustURLCodebase.
A stock install is harder to arm.
We deliberately weaken things here to get a quick hit on the memory overflow.
The memory corruption in the exploit is one-shot per Apache master: worker rotation breaks the grooming geometry, so the exploit restarts the container and clears /tmp/sess_* between attempts, and every failure produces a visible burst of connection resets on the wire.
Those resets show up in the captures as reset floods and “SURICATA HTTP multipart invalid header” events.
The exploit’s published defaults did not match what is demoed here.
The PoC also needs a base address, and that is where the memory-disclosure half shows its utility: a leak stage reads a chunk base and the libphp base off the responding error text before the corruption stage fires.
The range: Cascade Gear Co.
The scenario models an outdoor-goods company, Cascade Gear Company, with one public site and a sensitive backend behind it. The range was built with Ludus and Claude Code; this post picks up where that series left off. Claude configured the range tooling and reconfigurable architecture, but refused to assist with this particular CVE and exploit - even Opus 4.8 balked at it, even though it usually works well with cyber scenarios. So all the work with this lab - wargaming the exploit, administering the playbook, configuring the range, working the captures and screenshots via isolation tunnels, and orchestrating the whole baseline and exploit - was done with Qwen3.8-Flash-Next-Uncensored running locally on a Mac Studio Ultra M5 with 256GB of unified RAM. It worked remarkably well and was fast.
| Zone | Hosts | Purpose |
|---|---|---|
| DMZ (vlan20) | JMP-sthelens |
the storefront: php:8.4.26-apache (last 8.4.x before the 8.4.27 fix) in Docker behind Apache mod_php |
| Internal (vlan10) | JMP-lassen (MariaDB: orders, customers, and the vault credentials), JMP-hood, JMP-baker (employees with traffic generators) |
order data and the people browsing it |
| Protected (vlan40) | JMP-whitney (MariaDB: vault, cardholder rows) |
the crown jewels, reachable from lassen only |
| SOC (vlan30) | JMP-wazuh (manager+indexer+dashboard), JMP-suricata, JMP-zeek, JMP-elastic (Kibana) |
the five sensors |
| Attacker edge (vlan95) | JMP-kali, JMP-isp01 (fake internet: DNS + web noise) |
the attacker’s seat, with the ISP hop so routing matches how the internet would actually reach the DMZ |

The router enforces the relevant firewall posture: the attacker can reach the storefront’s 80, the storefront can reach the order DB’s 3306, and JMP-lassen alone can reach the vault’s 3306. Kali cannot reach any database directly. That constraint is what makes the credential recovery method below damaging: stolen credentials that cannot open a connection are worth little, and the run proves they open the right one through a detour.
Employees contribute ambient noise on purpose.
JMP-hood and JMP-baker browse /, /products, /cart every few seconds and emit LDAP and proxy chatter toward the fake ISP, so every sensor log in this post is shown doing its job inside noise rather than in an empty room.
The flaw in context
The storefront is deliberately unremarkable.
From roles/vuln_php2shell/files/storefront/index.php:
1
2
3
4
5
6
7
8
9
$DB = [
'host' => getenv('ORDERS_DB_HOST') ?: '127.0.0.1',
...
'pass' => getenv('ORDERS_DB_PASS') ?: '',
];
// Connect on every request so the credentials are live in the worker's heap.
function db_query(array $DB, string $sql) {
$mysqli = @mysqli_connect($DB['host'], $DB['user'], $DB['pass'], $DB['name']);
Credentials come from the container environment and are live in every request’s memory. This enables the exploit to grab credentials and other vital information such as the process environment.
The engine weaknesses, from roles/vuln_php2shell/files/php.ini:
1
2
3
4
5
6
memory_limit = 8M
; The PoC's leak POSTs run ~64 MB; above post_max_size PHP skips POST
; parsing entirely, so the upload-progress surface never arms.
post_max_size = 64M
session.auto_start = 1
session.upload_progress.enabled = 1
auto_start is the enabling condition: it gives every unauthenticated request the active session that upload-progress requires.
The app never calls session_start() itself.
The attacker’s kit
One tool, php2shell.py (the Exploit-DB 52704 PoC with lab modifications) with its forged system() call that accepts a 14-byte attacker-injected command.
1
2
3
python3 ~/php2shell.py -u http://10.3.20.10/ --profile bookworm \
--zif-offset 0x465370 --dtor-offset 0x65eb60 \
-c '<=14-byte command>' --fetch <path> --retries 6
The exploit’s stages map to the theory: a heap-leak stage, a libphp-base leak, then the W3 corruption attempt; --retries re-arms through worker rotations.
For anything longer than 14 bytes, the attacker stages a script on the victim first and invokes it inside the cap.
The container mounts /opt/cascade (host-side, web-served under /o), so one request can drop a file and a later 11-byte command can run it.
The operator views matter too: the attacker works in a real Kali desktop over KasmVNC, and the screenshots below are from that desktop and reflect what an operator at the keyboard would see.
The attack, step by step
Recon and the flood
The attacker’s first contact is a plain GET that returns an Apache/2.4.69 banner, then the exploit launches.


On the wire (24 Oct capture), the exploit is immediately visible: 230 multipart POSTs to / from the attacker, 115 flows carrying bodies over 10 MB, visible in Zeek conn.log as anomalous orig_bytes for a page that answers in 5 KB.


The leak stages print chunk and libphp bases, then W3 fires.
Each failed attempt kills a worker process, producing 571 short-duration resets in Zeek and 9 SURICATA HTTP multipart invalid header events on the sensor.
That is good news for defenders, as it means this attack is pretty noisy.
Successful exploitation yields a shell result as www-data, and the proof comes back over HTTP:

The “shell” here is whatever commands they can run in the 14-byte command payload.
The storefront continues to serve normally, making the compromise invisible to legitimate visitors:

Reading the credentials off the wire
The shell has a 14-byte command budget, so the attacker asks it for exactly one thing: the process environment.
-c 'env>/out/p' writes the container environment to a file under the web-served /o mount, and --fetch /o/p reads it back through the front door.
The attacker’s screen ends like this:

The distilled readout is four variables the storefront uses to talk to its database:

Now the part defenders care about: the credential is not just observed, it is transited.
In the 25 Oct credential-run capture, frame 17013 is the attacker’s GET /o/p and frame 17040 is the storefront’s HTTP 200 carrying it, in cleartext, back through the DMZ firewall:
1
2
3
4
5
6
7
HTTP/1.1 200 OK
Content-Length: 1179
...
ORDERS_DB_NAME=cascade
ORDERS_DB_PASS=St0refr0nt!retail
ORDERS_DB_HOST=JMP-lassen
ORDERS_DB_USER=storefront
One response body, mid-GET, no session, no authentication. The database password for the internal tier crossed the public face of the estate inside a page fetch.
Using the credentials: a detour through the victim
Stolen credentials that cannot open a connection are incomplete evidence, and the firewall says Kali cannot reach 10.3.10.10:3306 at all.
So the attacker makes the victim use its own credentials through the same 14-byte channel: a staged PHP agent (/opt/cascade/out/q, invoked as the 13-byte command /out/q>/out/t) opens mysqli with the environment it inherited and runs four queries into a file the attacker then fetches.

The readout:

On the wire, frame 41226 of the same capture carries it:
1
2
3
4
5
6
7
8
9
--- SELECT CURRENT_USER(), DATABASE()
storefront@% | cascade
--- SHOW TABLES
customers
orders
products
vault_access
...
JMP-whitney | vault_reader | V4ult!paymentdata
storefront@% is the proof the credential is live and accepted from anywhere the grants allow.
The last line is the critical finding: the orders database holds the credentials for the protected vault, on a host Kali cannot reach and the storefront can only reach through JMP-lassen.
The attacker now has the credentials needed to reach the protected vault.
Credential access timeline
The full run (24 Oct) took the path one step further, and the frame-indexed evidence table from the capture README anchors it:
| Frame | Time | What it proves |
|---|---|---|
| 4307 | 02:52:23 | First exploit POST, the multipart flood that triggers the OOM longjmp |
| 25001 | 02:54:36 | Post-exploitation command POST writing the id proof as www-data |
| 52985 | 02:55:09 | SELECT * FROM vault_access, credential theft crossing DMZ to INTERNAL over MySQL |
| 116094 | 02:56:24 | SELECT 'IyEvYm...' INTO OUTFILE '/opt/cascade-jobs/payload.b64', the FILE-privilege pivot seed |
| 153849 | 02:58:28 | SELECT * FROM loot, crown-jewel readback after the root cron executed the job |
The middle row is the weakness that turns a DB read into a vault read: the storefront’s DB user has FILE privilege, INTO OUTFILE can write into a cron-watched directory on the DB host, and the cron runs as root against the tier the firewall protected.
Zeek logged the base64 job as literal query text in mysql.log.
Defender telemetry
Network: Teleseer
The deduplicated full capture is uploaded to Teleseer under project JMP-PHP2Shell.
Teleseer’s Dashboard shows the abnormally high HTTP uploads that the attacker used to trigger the out-of-memory condition:

Examining that traffic in the timeline identifies these as focused points in time:

The flow reconstruction for the attack window shows the attacker’s 230 flows in and the DMZ-to-INTERNAL SQL out.
The Packets view lands directly on the evidence.
Packet 17 in the credential-run capture is the storefront’s HTTP 200 carrying the process environment back to the attacker on port 80 to 57086.
The hex dump in the Inspector panel shows ORDERS_DB_PASS= and St0refr0nt!retail in cleartext - the same credential that frame 17040 carried in Wireshark, now visible without leaving the browser:

The Network view shows the same packet in topological context. Three arcs connect the hosts involved in the credential-run window: the attacker (10.3.95.10) to the storefront (10.3.20.10) on port 80, a management host (198.51.100.4) to the attacker on 8444, and an external address (203.0.113.10) to the storefront on port 80. The flow from attacker to storefront carried 329 bytes out and is the one that delivered the credential:

In my experience, Teleseer’s asset discovery and credential extraction line up cleanly with what the offline tools produced, and the behavioral view - the sudden wall of oversized POSTs into a storefront that normally serves 5 KB pages - is immediately legible without knowing the CVE first.
Network: Zeek
Zeek http.log separates the 230 exploit POSTs from employee traffic by URI and size; conn.log shows the 10 MB+ flows that no legitimate browsing produces; mysql.log records the attacker’s SQL including the INTO OUTFILE job text.
The flow census in a table matches the spikes easily visualized in Teleseer:
| Crossing | Flows | Actor |
|---|---|---|
| attacker VLAN to DMZ | 230 | exploit + recon |
| Ludus management to DMZ | 35 | health checks |
| DMZ to INTERNAL MySQL | 38 | app traffic plus attacker SQL |
| INTERNAL to vault MySQL | 0 in window | the cron hop ran between capture cycles, a gap in coverage |
Network: Suricata
Suricata caught the malformed multipart parsing nine times as SURICATA HTTP multipart invalid header, caused by the PoC’s malformed header geometry.
It did not catch a specific exploit signature, because the ET ruleset had no PHP 8.4 multipart-abuse rule at run time.
A dedicated and well-written Suricata rule could work here, though.
That is recorded as a gap with a concrete fix: a lab rule keyed on part counts in the storefront’s Content-Disposition geometry, filed in the scenario repository.
It also generated 240,349 SURICATA TCPv4 invalid checksum events because mirrored frames are copied before NIC checksum offload completes.
That is capture physics, not corruption, and the correct handling is to disable checksum validation in the sensor config rather than filter through the noise in queries.
Host: Wazuh detections
The host lead is Wazuh rule 100210, “shell spawned by non-login service account”, keyed on auid=4294967295 process audits that the scenario’s agent role enables.
It fired 351 times on JMP-sthelens, clustered exactly in the exploit windows.

The Wazuh dashboard:

and the drill-down:

Gaps identified in the Wazuh setup: file-integrity monitoring never saw the staged /opt/cascade/out payloads (outside the watched paths, fix queued) and SCA does not read the container-mounted php.ini.
These are straightforward configurations that could be enabled to help identify this particular exploit.
Aggregation: Elastic and Kibana
The external Elastic feed carried 450 detections from the run window across agents, giving the cross-tool pane that the dashboard’s local indexer was supposed to duplicate.


Remediation
Containment during the run was the router: outbound HTTP from both the attacker and the victim container was tested live, and the SYNs went unanswered.
The exploit yields www-data inside a container with no outbound connectivity beyond hosts that are already permitted to connect to it.
The router did its job.
Remediation recommendation for Cascade Gear Company:
- Upgrade PHP to 8.4.27 (or the distro-equivalent of the fix release). The fix reorders the OOM path’s unwinding so the
longjmpno longer escapes with parser state aimed at freed memory, which removes the dangling primitive the whole chain needs. The offsets stop mattering because the slot they target is no longer reachable that way. - Where patching today is not possible, turn off
session.upload_progress.enabled, which unarms the parser entry point the leak rides. On mod_php it cannot be re-enabled per vhost. Loweringpost_max_sizebelow the PoC’s body size also starves it; the range config comments spell out the interaction. - A WAF is not reliable here. The corruption happens before application code, and the request is a well-formed multipart POST; the signature that worked in this run was malformed-part geometry at the IDS, not app-layer filtering.
- The lateral prize should be cut: the order DB user does not need
FILE, and the cron-watched directory should not be writable by the database process at all. Both were deliberate weaknesses in this scenario, and both are ordinary defaults in real setups.
Scope and artifacts
Everything described above is from two runs on a single simulated network on a Ludus range.
The full JMP-20261024-024850.pcap (306,043 packets) and the credential-run JMP-20261025-credentials-run.pcap (48,761 packets), frame-indexed tables included, are at Zenodo 23641810.
The credential frames most worth replaying: 17013/17040 (the GET /o/p and the response carrying the DB password) and 41202/41226 (the SQL readout with the vault row).