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

Exploit for Improperly Controlled Sequential Memory Allocation in Php

Sploitus • October 24, 2026

static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap, ...) {

zend_try { zend_error_noreturn(E_ERROR, ...); } zend_catch {} zend_end_try();

zend_bailout(); // longjmp() — unwinds the stack, skipping cleanup

`zend_bailout()` is a `longjmp` to the nearest saved context. It leaves

`heap->overflow`/executor-global/request-global state inconsistent; a later read

or the request's startup branches on that stale state.

The **fixed** build (`php-8.3.36`, `5ba6ad5`) replaces the raw `longjmp` with a

recorded, reset, cleanly-unwound path (fix commit

`3c832b523ee` "Avoid state corruption via bailout"):

| Stage | Affected 8.3.35 | Fixed 8.3.36 |

| OOM detected | `zend_error_noreturn(E_ERROR)` immediately | records `EG(delayed_oom)` (msg/filename/lineno, `state=PENDING`), sets `vm_interrupt` |

| Unwind | `zend_bailout()` (`longjmp`, skips cleanup) | VM unwinds via `UnwindExit`, running proper cleanup |

| Executor-globals reset | none | `executor_globals_ctor`: `memset(&EG(delayed_oom),0,…)` |

| Request-init reset | none | `main.c` rinit zeroes `PG(http_globals)[i]` + `EG(delayed_oom).state` ("may be accessed before `php_request_startup()`"), `PG(modules_activated)=0` |

| After main script | — | `zend_execute_scripts`: if `ZEND_HAS_OOM()` → `zend_handle_delayed_oom()` |

| Deactivate | — | `zend_deactivate`: `zend_mm_oom_info_dtor(&EG(delayed_oom))` |

That table *is* the deterministic before/after: the affected code path

`longjmp`s out of a half-built request while the fixed path records the OOM,

resets the globals that a subsequent request may read, and unwinds cleanly.

Pinned, adjacent tags on `php-8.3` — the only binary difference is the fix:

| affected | `php-8.3.35` | `2d3433d24d5` | last pre-fix |

| fixed | `php-8.3.36` | `5ba6ad51922` | first post-fix |

| fix | — | `3c832b523ee` | "Avoid state corruption via bailout" |

Build (minimal CLI; multipart/request-parse OOM surface is core SAPI, no dev

./build.sh # -> php-src-build/affected/sapi/cli/php and php-src-build/fixed/sapi/cli/php

./run.sh # crash-rate over N runs (default 300) + single-run before/after

Trigger `triggers/bug76846.php` is PHP's own canonical repro

(`Zend/tests/bug76846.phpt`, "Segfault in shutdown function after memory limit

error"): a self-referential object (`$this->c=$this`) with `zend.enable_gc=0`,

memory exhausted in a `while(true)` loop, then exhausted again inside a

registered shutdown function — the exact shape that reads the stale state.

Representative `./run.sh` output (N=300, this sandbox):

affected segfault(139)=0 clean-fatal(255)=300 other=0

fixed segfault(139)=0 clean-fatal(255)=300 other=0

The crash is **probabilistic**: across repeated runs we observe intermittent

`SIGSEGV` (139) on the affected build that the fixed build never produces — the

rate is ASLR/heap-layout dependent (hence `AC:H`), and in a given sandbox it can

run clean for long stretches. That is precisely why the CVE is AC:H and why the

public RCE is demonstrated-but-disputed. The deterministic, reproducible part is

the source-level before/after and the fact that the affected build's code path is

the one that `longjmp`s out of a half-built request.

### Multi-request (worker-persistence) variant

`triggers/router.php` + the built-in server (`php -S`) model a persistent

worker: request 1 OOMs inside its shutdown function, request 2 is a fresh

request that must stay clean. This is the authoritative

`sapi/cli/tests/oom_shutdown_function_next_request.phpt` scenario. It exercises

the request-lifecycle stage of the bug (the `PG(http_globals)`/`delayed_oom`

reset "before `php_request_startup()`"); in a long-lived worker (PHP-FPM /

mod_php — the highest-risk SAPIs per the advisory) the same stale-state read is

* This proves the **memory-safety violation** (OOM unwind leaves stale state) —

the necessary condition for the RCE. It is local, deterministic at the source

level, and has a clean affected-vs-fixed before/after.

* It does **not** deliver a guaranteed full RCE. Converting the stale-state read

into a write/exec gadget over a real request stream is probabilistic

(`AC:H`, `AT:P`) and requires a persistent worker; that step is

demonstrated-but-disputed in public reporting. Same posture as the public

HTTP2Shell PoC and Part C of this series.

* **CVE-2026-109619** ("php2shell" part 2, stale-request-global UAF) is the

sibling: same fix commit, a *different* request-life-cycle stage (request N

leaves a pointer to freed memory that request N+1 uses). The mechanism here

(`delayed_oom` + rinit reset) is what closes both.

| `build.sh` | clone pinned tags, build affected + fixed minimal CLI |

| `run.sh` | crash-rate comparison + single-run before/after |

| `triggers/bug76846.php` | canonical shutdown-after-OOM crash repro |

| `triggers/oom_after_main.php` | OOM at end of main script + shutdown ordering |

| `triggers/router.php` | multi-request (persistent worker) OOM-leak scenario |

| `oom_shutdown_function_next_request.phpt` | reference: canonical upstream test this scenario is extracted from |

| `php_cli_server.inc` | reference: CLI test harness used by the canonical `.phpt` |

Extracted Entities

Platforms (2)