PHP2Shell (CVE-2026-109618 / CVE-2026-109619): A 14-Year-Old PHP Vulnerability

Türkçe oku

You’ve probably seen it by now: on October 21, 2026, the PHP project disclosed two critical vulnerabilities that let an unauthenticated attacker run commands on almost any PHP server using nothing but HTTP requests. The nasty part? No login, no file upload. The flaw sits right inside the PHP runtime, the PHP engine itself. And the wild part: PHP2Shell has apparently been in the code since PHP 5.4.0 came out in 2012. That’s 14 years of releases affected.

The researchers found it in a very AI-assisted way. We started with human in the loop, now we’re at human on the loop. Pretty soon we’ll be human out of the loop, I think. AI will attack, AI will defend, and we’ll sit at home eating chips. At this point, underestimating how fast AI is moving just makes me laugh. Anyway, I have thoughts on this, I’ll save them for another post.

Back to the topic: two CVEs. CVE-2026-109618 and CVE-2026-109619, both rated critical with a CVSS v4.0 score of 9.2.

The details leaked before the official patches, so when the patches dropped, attackers were already ready. Some systems got hit, some were taken offline as a precaution. Even Japan’s Prime Minister Sanae Takaichi posted an urgent call to patch on X and shut down her own site. It was one of the most chaotic moments I’ve followed live in security.

So, to understand the flaw and see it live, we’re going to solve the TryHackMe room together. The room is long, so I’ll skip some parts. This is not a flag-hunting walkthrough.

The short version: PHP reads a file-upload request before your application code runs. If memory runs out, PHP’s emergency exit skips cleanup and leaves a broken address behind. The next request on the same process uses that address. The attacker first leaks addresses from the server’s memory, then turns the corruption into command execution. The permanent fix is updating PHP. Let’s get into it.

First, let’s understand session.upload_progress

Think of the progress bar you see when you upload a file. While the browser sends the file, PHP writes the “how much has arrived” info into the user’s session. That’s session.upload_progress. PHP saves the upload status, file name, size, and bytes received so far into $_SESSION while it’s still reading the multipart/form-data request body. A second request can then read that data and show the progress on the page. The feature is enabled by default. All it takes to trigger it is a form field whose name matches session.upload_progress.name (the default is PHP_SESSION_UPLOAD_PROGRESS).

The important part is when this happens. PHP reads the request long before your application code runs. The upload info is written right then, before a single line of app code executes. So you don’t need session_start(), a logged-in session, or any application logic at all. Any URL that accepts a POST is enough, even if the script only does echo "OK";.

Upload progress runs before your code does That “pre-execution window” gives the attacker a foothold. The whole PHP2Shell attack happens there, while the app is still asleep, using data the attacker puts in an ordinary file-upload request.

Where does it break?

The actual flaw isn’t the upload progress feature. It happens when PHP runs out of memory while doing that work.

PHP has a memory cap per request: memory_limit. When a request goes over it, PHP can’t continue, so it takes the engine’s bailout emergency exit. Normally, when a request ends, PHP returns the memory and clears the address pointing to it. But bailout skips that cleanup. It’s not an orderly shutdown; it’s an emergency exit.

The flaw is pure timing. If the memory error hits right between “memory returned” and “address cleared”, a stale address is left pointing at freed memory. That’s a dangling pointer. (CWE-824.)

A memory bailout skips cleanup and leaves a dangling pointer A stale address inside a dead request is harmless by itself, because the request is over. What makes it dangerous is the server model. In Apache with mod_php, a single worker process handles requests one after another. The broken address doesn’t die with the request; it stays in the process memory. The next request that lands on the same worker uses an address PHP never reset. If the attacker controls what gets written into that freed space, they control what the address points to.

That one bug gave researchers two attacks, which is why there are two CVEs. CVE-2026-109618 reads the server’s memory. CVE-2026-109619 uses that info to run commands on the server. The PHP patch description says it plainly: “Avoid state corruption via bailout.” Now we’ll check the target, leak memory, and then run a command.

The target machine

I SSH’d into the machine. The victim machine runs PHP 8.4.26, one of the vulnerable versions (fix: 8.4.27):

ubuntu@tryhackme-2404:~$ docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED      STATUS         PORTS                    NAMES
d77a04da7d60   php:8.4.26-apache   "docker-php-entrypoi…"   2 days ago   Up 4 minutes   127.0.0.1:8088->80/tcp   php2shell-target

As you can see, there’s no vulnerable application code on the target. It’s an extremely simple page:

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 server runs a standard Apache + mod_php setup with session.upload_progress enabled by default. For the lab, it’s pinned to a single worker and memory_limit is low. So the same process handles every request and memory runs out fast. That makes the broken address easier to reuse.

CVE-2026-109618: leaking addresses from memory

Modern systems load code and data at different addresses each run. That’s ASLR (address space layout randomization). If the attacker doesn’t know the addresses, they can’t find the right place to run code. CVE-2026-109618 leaks those addresses.

The mechanism uses the same memory bug. This time memory runs out while a session id is being handled, leaving a stale address pointing at the session id. The next request refills that space. Now the stale address points to a string whose length the attacker inflated. When PHP sends the session cookie, it reads that string and puts it into the Set-Cookie: PHPSESSID response header. So the server accidentally sends a chunk of its memory back to you.

The exploit does this twice: first it learns where PHP’s memory is, then where PHP’s own code is. Together, they remove ASLR’s protection and give the addresses needed to run a command.

The exploit is already on the machine under ~/php2shell-bypass-aslr-exp. Here’s what’s inside:

ubuntu@tryhackme-2404:~/php2shell-bypass-aslr-exp$ ls
ASLR_PATHS.md      ASLR_PURE_HTTP_RESEARCH.md  HTTP_HEAP_LEAK.md  NOTES.md     NO_DISPLAY_ERRORS.md  docker  exploit.py  logs  readme_en.md  tools
ASLR_PURE_HTTP.md  DISPLAY_ERRORS_OFF.md       NOASLR_REPORT.md   NOTES_BG.md  README.md             e.py    lab         out   src

I’m running it with no arguments:

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 = 0x799e81200000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x799e82eb3000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x799e812690d8 ZIF=0x799e83318370
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

The chunk base line is the base address of PHP’s memory, and libphp base is the base address of PHP’s code. The PROOF line shows the command ran as www-data on the server. So the exploit didn’t just leak addresses; it ran a command in the same run.

CVE-2026-109619: from addresses to a command

The leak told the attacker where everything is. CVE-2026-109619 turns that into a command running on the server.

When PHP destroys a value, it can call a function address stored next to it. The exploit places a fake structure where the broken address points and sets its “function to call during cleanup” (destructor) field to system() (the function that runs commands). PHP thinks it’s cleaning up, but it runs the command the attacker supplied. The command runs as the web server user. No extra library or outside code needed; PHP’s own code is enough.

ubuntu@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 = 0x799e81200000
[*] rotating worker over HTTP
[*] stage 2: libphp leak (attempt 1/3)
[+] libphp base = 0x799e82eb3000
[*] rotating worker over HTTP
[*] stage 3: HTDATA=0x799e812690d8 ZIF=0x799e83318370
[+] W3 attempt 1
PROOF: THM{....}

That’s how we passed an argument, ran the command we wanted, and got the flag.

Mitigation

Because PHP2Shell lives inside PHP itself, the “I’ll update the site” reflex doesn’t help. Updating WordPress, Drupal, Joomla or Laravel changes the application; the vulnerable code is in the PHP underneath. The only complete fix is patching PHP: update the PHP package your distro ships or the base image your container is built from to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, then restart every worker. Don’t leave a single process running the old code.

Until you can patch, there are temporary mitigations, but the PHP team is clear that none of them replaces the patch. Setting session.upload_progress.enabled = 0 and restarting workers removes the trigger. Disabling PHP’s opcode cache (the cache that stores compiled PHP code, opcache.enable = 0) makes the code-execution path harder. On PHP 8.5, disabling fatal error backtraces (fatal_error_backtraces = 0) specifically addresses CVE-2026-109619. At the network edge, rejecting file-upload (multipart/form-data) requests closes the delivery path, but breaks legitimate uploads. Running PHP with least privilege limits what the attacker gains if the chain lands.

One defense that doesn’t help: a WAF (web application firewall) running as a PHP module only sees traffic after PHP hands control to the application. This attack finishes before the app ever runs. The filter inside PHP never gets a chance. Protection has to be in the patched PHP or in a layer in front of it.

For detection, look for abnormally large PHPSESSID values in Set-Cookie responses, very large POST bodies with many fields carrying PHP_SESSION_UPLOAD_PROGRESS, and PHP worker crashes or memory errors in the logs. Any of these, especially together, deserves an alert while you’re patching.

More info

Part of this write-up is based on TryHackMe’s CVE PHP2Shell room and Wiz Research’s PHP2Shell research. Check those out for more technical detail.

Conclusion

PHP2Shell is the kind of vulnerability people will talk about for years, not just this year. It reminds us that software isn’t just the application. Nobody paid enough attention to how the PHP engine behaves when memory runs out, apparently since 2012. Whatever framework you use, we’re talking about an engine that runs about 70% of the web.

The AI era keeps shocking us every day. Vulnerability researchers are finding bugs that might never have been noticed, thanks to AI. In the hands of malicious actors, AI becomes a very dangerous weapon. I’m not going to lie, defense has a hard job. The attacker’s advantage is simple: they only need one path. Defense, on the other hand, is responsible for keeping every place secure.

Who knows what other vulnerabilities will show up in the future, maybe even tomorrow, in the apps we use every day. We’re never safe in the cyber world, that’s for sure ;)


Thanks for reading. This one was fun for me too; I learned a lot of low-level detail along the way.

Enes Bayraker

Hack The Planet