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 | zend_module_entry my_module_entry = { |
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) doesmemset(&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 resetBG(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 | int php_request_startup(void) |
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 | /* ext/standard/basic_functions.h */ |
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 | /* ext/standard/var.c — simplified */ |
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 |
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 | serialize($huge_array); |
php_var_serialize_destroy() (the macro PHP_VAR_SERIALIZE_DESTROY) is the function that would have done:
1 | BG(serialize).level--; |
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 | php_request_startup() |
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 | php_request_startup() |
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:
- Opens (or creates) a session
- Writes upload progress data to it
- 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:
- Send a
PHPSESSIDcookie (any value) - Include a
PHP_SESSION_UPLOAD_PROGRESSform field in the multipart body - 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 | /* zend_alloc.c — zend_mm_shutdown() */ |
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->heappointerchunk->next/chunk->prevlinked-list pointerschunk->free_pages,chunk->free_tail,chunk->numchunk->free_map[]— page allocation bitmaskchunk->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 | Stale pointer → offset 0x2040 in the recycled ZMM chunk |
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:
serialize()allocatesphp_serialize_dataearly in the request — from the main chunk’s first usable pages- The offset within the chunk is deterministic (same code path, same allocation order)
- After shutdown + reinit, the same chunk is reused without zeroing
- 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 | Request A (OOM) Request B (upload-progress) |
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 | Request 1: GET /any-php-endpoint.php ← triggers OOM during serialize() |
Setup
1 | # docker-compose.yml |
1 | ; php-config/php.ini |
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 |
|
target.php — Target for the upload-progress POST (any PHP file works):
1 |
|
Exploit Script
1 | #!/usr/bin/env python3 |
Result
1 | $ python3 poc.py |
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. Withmemory_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