Back Sploitus Exploit for Improperly Controlled Sequential Memory Allocation in Php
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` |
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.
