Php2Shell
Upload Progress
When you upload a large file, the webpage can’t easily tell how much has arrived. PHP’s session.upload_progress solves this: while the file streams in, PHP records its name, total size, and bytes received into your session, and another request can read those notes to drive a progress bar. It's on by default and activates whenever the upload form includes a field named PHP_SESSION_UPLOAD_PROGRESS.
The key detail: timing
PHP reads uploaded files very early in handling a request, and the progress notes are written during that step. Only afterward does PHP set up the environment and run the actual script. So the notes are written before any of the website’s own code runs.
Why that’s a foothold
This happens “in the lobby,” before the security guard. An attacker doesn’t need to be logged in, doesn’t need the page to start a session, and doesn’t need the page to have any real logic. Any URL that accepts a POST works, even one that just prints a line of text. A normal file upload to it makes PHP write attacker-supplied data into the session while the application hasn’t started yet.
The real bug: running out of memory
The progress feature isn’t the flaw. The flaw appears when PHP runs out of memory in the middle of that progress work.
PHP caps each request’s memory with memory_limit. If a request exceeds it, PHP performs a bailout: it jumps straight to a safe escape point and abandons what it was doing ("Allowed memory size exhausted"). Unlike a normal finish, a bailout skips cleanup, so memory isn't tidied and pointers to it aren't reset.
The dangerous window
Freeing memory takes two steps: (1) release the memory block, then (2) clear the pointer that referred to it. If PHP runs out of memory between those steps, the bailout leaves a pointer aimed at memory that’s been released and can be reused. That’s a dangling pointer (CWE-824).
Why a crashed request still matters
Under Apache + mod_php, one worker process handles many requests without restarting. The stale pointer survives the failed request and stays in that worker’s memory, where the next request on the same worker still trusts it. If an attacker’s follow-up request fills the freed memory with their own content, PHP ends up reading attacker-controlled data through the old pointer.
Two bugs, two CVEs
From this one flaw, the researchers built two different attacks — which is why PHP2Shell has two CVE numbers:
- CVE-2026–109618 — memory leak (reading secrets). The stale pointer gets read back as a string with an attacker-chosen, too-large length, and PHP hands the result back in a response header. The attacker gets to peek inside the worker’s memory.
- CVE-2026–109619 — remote code execution (taking control). The same corrupted state is pushed further, until the attacker controls what the program does — ultimately running their own code.
Recon
read the PHP version:
ubuntu@tryhackme-2404:~$ docker ps --format '{{.Names}} {{.Image}} {{.Status}}'
php2shell-target php:8.4.26-apache Up 55 secondsubuntu@tryhackme-2404:~$ docker exec php2shell-target php -vphp2shell-target php:8.4.26-apache Up 55 seconds
PHP 8.4.26 (cli) (built: Oct 21 2026 00:31:19) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.4.26, Copyright (c) Zend Technologies
with Zend OPcache v8.4.26, Copyright (c), by Zend TechnologiesThe server is 8.4.26, one release short of the 8.4.27 fix, so it is vulnerable. Now look at the page you will be attacking:
ubuntu@tryhackme-2404:~$ curl -s http://127.0.0.1:8088/index.php
OK
ubuntu@tryhackme-2404:~$ docker exec php2shell-target cat /var/www/html/index.php
<?php echo "OK\n"; ?>The entire application is one line that prints OK
There is no login form, no file upload, no database, and certainly no bug in this code to exploit. That is the point worth pausing on: if the attack works against this page, it works against the runtime, not the application, and a real target such as a WordPress or Laravel site is no better protected simply because its own code is sound.
The server runs the ordinary prefork mod_php
setup with session.upload_progress
enabled as it is by default. For the lab it is cut down to a single worker, and memory_limit is set low, so the dangling pointer is reliably reused and the out-of-memory condition is easy to provoke; a production host would run many workers with a higher limit, which makes the timing harder to hit but not impossible. The exploit you will use is already on the machine:
What ASLR is, and why the attacker is stuck without beating it
Modern computers hide their furniture. Every time a program runs, the system loads its code and data at random memory addresses — a defense called ASLR (Address Space Layout Randomization).
Why does this stop an attacker? To hijack a program, the attacker eventually needs to say “jump to this exact address and run my stuff.” But if the addresses are scrambled differently every time, the attacker doesn’t know where anything is. It’s like being told to meet someone in a specific room of a hotel, except the room numbers are reshuffled on every visit. ASLR’s whole job is to deny the attacker that map.
Get clwwn’s stories in your inbox
Join Medium for free to get updates from this writer.
CVE-2026–109618 hands them the map anyway, by turning the dangling pointer from before into a way to read the worker’s own memory.
How the leak works
It reuses the same bailout trick:
- Provoke the crash at the right moment — this time while PHP is handling a session ID. The messy emergency exit leaves a stale pointer to that session ID.
- Refill the freed spot — a follow-up request drops attacker-chosen data into the emptied space. Now the stale pointer describes a string, but the attacker has lied about its length, inflating it far beyond the real data.
- Make PHP read it out loud — when PHP sends its response, it reads that over-long session-ID string and puts it into the
Set-Cookie: PHPSESSIDheader it sends back to the client.
Because the length was faked to be huge, the header now carries not just the session ID but whatever happened to be sitting next to it in memory — measured at up to roughly 67 KB of the worker’s memory per request.
In a real server, that leaked memory could contain other users’ passwords, session cookies, secret keys, even private TLS keys. For this exploit, though, it contains the one thing the attacker needs: addresses.
Run it twice, get the whole map
The exploit does the leak two times:
- Leak #1 — the heap. Reveals where PHP’s memory heap (its pile of working data) begins.
- Leak #2 — libphp. Reveals where PHP’s own code library (
libphp) begins. It does this by reading a pointer whose position inside the library is already known, so finding the pointer reveals the library’s start.
Together these two leaks tell the attacker exactly where the heap and the engine’s code live in this particular worker — which was the only missing ingredient. ASLR is now defeated.
ubuntu@tryhackme-2404:~/php2shell-bypass-aslr-exp$ python3 exploit.py -u http://127.0.0.1:8088/index.php
[*] target http://127.0.0.1:8088/index.php (cmd='id>/out/p1')
[*] offsets loaded from /home/ubuntu/php2shell-bypass-aslr-exp/out/offsets.env
[*] offsets: zif=0x465370 dtor=0x65eb60 htdata=0x690d8
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x7e8f12600000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x7e8f141f0000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x7e8f126690d8 ZIF=0x7e8f14655370
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)The leak bug did the scouting — it told the attacker the exact addresses of things in memory. By itself that’s just knowledge. CVE-2026–109619 is the step that turns that knowledge into an actual command running on the server.
The clever move: instead of refilling the freed memory with data to read, the attacker refills it with a fake object designed to hijack PHP’s own cleanup routine.
The trick: hijacking the cleanup machinery
When PHP throws away a value, it sometimes calls a destructor — a little “cleanup function” whose address is stored right next to the value. Think of it as a phone number PHP dials to say “please tidy this up for me.”
The attacker rebuilds that freed memory as a forged internal structure (a HashTable) and sets its destructor “phone number” (pDestructor) to point at system() instead — the function that runs shell commands. They know exactly where system() lives because the earlier leak revealed it.
So when PHP finishes the request and goes to “tidy up,” it dials what it thinks is a harmless cleanup number — but it’s actually calling system(), with the attacker’s chosen text as the command. The command runs as the web server’s user (www-data).
ubuntu@tryhackme-2404:~/php2shell-bypass-aslr-exp$ python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'ls -al>/out/p1' --proof-file out/p1
[*] target http://127.0.0.1:8088/index.php (cmd='ls -al>/out/p1')
[*] offsets loaded from /home/ubuntu/php2shell-bypass-aslr-exp/out/offsets.env
[*] offsets: zif=0x465370 dtor=0x65eb60 htdata=0x690d8
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x7e8f12600000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x7e8f141f0000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x7e8f126690d8 ZIF=0x7e8f14655370
[+] W3 attempt 1
PROOF: total 12
drwxrwxr-x 2 1000 1000 4096 Oct 22 08:54 .
drwxr-xr-x 3 root root 4096 Oct 21 00:29 ..
-rw-rw-r-- 1 1000 1000 22 Oct 22 08:54 index.phpubuntu@tryhackme-2404:~/php2shell-bypass-aslr-exp$ python3 exploit.py -u http://127.0.0.1:8088/index.php -c 'cat /f>/out/p1' --proof-file out/p1
[*] target http://127.0.0.1:8088/index.php (cmd='cat /f>/out/p1')
[*] offsets loaded from /home/ubuntu/php2shell-bypass-aslr-exp/out/offsets.env
[*] offsets: zif=0x465370 dtor=0x65eb60 htdata=0x690d8
[*] rotating worker over HTTP
[*] stage 1: heap leak (attempt 1/3)
[+] chunk base = 0x7e8f12600000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x7e8f141f0000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x7e8f126690d8 ZIF=0x7e8f14655370
[+] W3 attempt 1
PROOF: THM{*****************}The fix
PHP2Shell lives in the PHP runtime itself, not in your website. So updating WordPress, Drupal, Joomla or Laravel does nothing — the bug sits underneath all of them. The only real fix is to patch PHP (to 8.5.12, 8.4.27, 8.3.36 or 8.2.35) via your OS package or container base image, then restart every worker so none keeps running old code.
Stopgaps until you can patch (none is a full substitute):
session.upload_progress.enabled = 0— removes the trigger for most techniques.opcache.enable = 0— blocks the code-execution path.fatal_error_backtraces = 0(PHP 8.5) — addresses the RCE bug specifically.- Reject
multipart/form-dataat the edge — blocks delivery, but breaks real file uploads. - Run PHP with least privilege — limits the damage if it lands.
What won’t help: a PHP-module WAF. It only sees traffic after PHP hands off to the app — but this attack finishes its work during parsing, before the app ever runs. Protection has to be in the patched runtime or in a layer in front of PHP.
How to spot it:
- Abnormally large
PHPSESSIDvalues inSet-Cookieheaders (normal ones are fixed-length). - Large multipart POSTs with very high field counts carrying
PHP_SESSION_UPLOAD_PROGRESS. - PHP worker crashes / out-of-memory errors in logs.








