# PHP Core: OOM Bailout State Corruption & Cross-Request Stale Global UAF (RCE)

**Security analysis of GHSA-w8hw-w9fx-j79w (CVE-2026-109618) and GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)**

## Executive Summary

Two interlocking vulnerabilities in the PHP engine allow **unauthenticated remote code execution** with the web worker's privileges against default configurations, most notably Apache `mod_php` (prefork) and — with more effort — PHP-FPM.

Both issues share a single root cause: PHP historically aborted requests mid-operation using `longjmp()` (via `zend_bailout()`), most commonly on `memory_limit` exhaustion. Because persistent workers (mod_php children, FPM children) handle many requests sequentially in one process, state corrupted or left dangling by an aborted request survives into the *next* request, where it is consumed before normal initialization. A strategically groomed heap turns this into RCE.

The PHP project has **confirmed working RCE over HTTP** against mod_php with default memory/input limits, and validated the session-lifecycle path against both mod_php and FPM.

| Advisory | CVE | Title | Confirmed impact |
|---|---|---|---|
| [GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) | CVE-2026-109618 | OOM bailout leaves invalid request state that shutdown processes | RCE (mod_php, validated over HTTP) |
| [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp) | CVE-2026-109619 | RCE via stale global state | RCE (mod_php confirmed; FPM validated for session path) |

**Fix:** single hardening commit [`31c0ea7d7ff1d1fccb6492e52dab544011628065`](https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065) (~22,000 lines) by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Niels Dossche, and Nicolas Grekas. **Updating is the only complete mitigation.**

---

## 1. Background: Why These Bugs Exist

### 1.1 The `longjmp()` OOM model

PHP's Zend Memory Manager historically enforced `memory_limit` by calling `zend_bailout()` — a `longjmp` that aborts whatever C code was executing, wherever it was, with **no unwinding**. Any structure under construction at that instant (a `HashTable` mid-resize with allocated-but-uninitialized buckets, a `smart_str` mid-append, a half-built zval) remains reachable from engine globals in its half-initialized state.

### 1.2 Persistent workers

Apache mod_php (prefork MPM) and PHP-FPM reuse one OS process for many HTTP requests. Each request gets its own heap arena, which is freed at request shutdown — but **worker-global fields** (`PG(...)`, `EG(...)`, extension globals like the session module's `PS(...)`) persist across requests. If such a global points into request-N's heap and is not reset before that heap is freed, request N+1 inherits a dangling pointer.

### 1.3 Code that runs *before* normal initialization

Certain operations execute before `php_request_startup()` / RINIT completes for the next request:

- **INI application with diagnostics** — Apache applies `.htaccess` `php_value` / `php_flag` / `php_admin_*` directives *before* `php_request_startup()`; FPM applies per-request `PHP_VALUE` / `PHP_ADMIN_VALUE` fastcgi params early. Any warning, notice, or deprecation raised there passes through the error subsystem before normal error state is (re)initialized.
- **Session upload progress** — multipart body parsing fires the rfc1867 callback (`php_session_rfc1867_callback()`) during request startup, *before RINIT*, whenever `session.upload_progress.enabled` is on (**the default**) and the POST contains the `PHP_SESSION_UPLOAD_PROGRESS` field. No application call to `session_start()` is required.

These are the "consumers" of stale or invalid state.

---

## 2. GHSA-w8hw-w9fx-j79w (CVE-2026-109618): OOM Bailout → Shutdown on Invalid State

**Advisory:** https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w

### 2.1 Mechanism

If an allocation fails while request memory is temporarily reachable but not fully initialized, the `longjmp` early-exit is not accounted for, and the **request shutdown procedure reads or branches on the invalid state**. Critically, **mod_php unconditionally invokes `php_request_shutdown()` even when request startup failed**. FrankenPHP behaves similarly per source analysis. FPM kills the child on failed startup, which blocks the *startup-time* variant but not mid-request variants.

The failed allocation does not need to be related to the corrupted memory — any allocation failure at the wrong moment suffices.

### 2.2 Attacker-controlled, application-independent OOM triggers

All reachable with a default install, during multipart body parsing, **before any application code runs**:

| Technique | Why it works |
|---|---|
| Unlimited headers per multipart part | Representing headers inside PHP costs far more memory than their wire size → exhaust `memory_limit` while staying under `post_max_size` |
| Nested field names (`a[b][c]…=`) | Each nesting level allocates another array |
| Empty file controls | Don't count toward `max_file_uploads` but still allocate full file-metadata arrays |
| No/invalid `Content-Length` | Skips the early `post_max_size` pre-check, so the request keeps supplying body data |

### 2.3 Fix

The allocator was reworked from "bailout on limit" to **graceful, delayed OOM**:

- New fallible allocation API: `_femalloc`, `_fecalloc`, `_ferealloc`, `safe_femalloc`, … returning `NULL` on limit instead of `longjmp` ([`Zend/zend_alloc.h`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_alloc.h)).
- Delayed-OOM state machine: `zend_oom_info` in executor globals, `ZEND_HAS_OOM()`, `zend_handle_delayed_oom()` — the OOM is raised as a normal error at the **next VM safe point**, after in-progress operations unwind cleanly. Call sites across the engine (`zend_hash`, `zend_string`, `zend_smart_str`, json, mbstring, pcre, exif, gd, zlib, soap, …) gained `ZEND_HAS_OOM()` checks — hence the ~22k-line patch.
- A second, higher **hard limit** (`memory_hard_limit_percent`, default 200%/1G buffer) does `_exit()` if soft-OOM handling itself keeps allocating ([`main/main.c` `OnChangeMemoryHardLimitPercent`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/main.c)).
- `array_init_singleton()` — pre-allocated immutable empty arrays so GPC/request parsing needs no allocation at all ([`main/php_variables.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/php_variables.c)).
- Multipart hardening in [`main/rfc1867.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/rfc1867.c): only `Content-Disposition`/`Content-Type` headers are kept (deduped), and `fill_buffer()` enforces `post_max_size` against actually-read bytes.

---

## 3. GHSA-6wmf-4rv5-p3rp (CVE-2026-109619): Stale Global State Across Requests

**Advisory:** https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp

### 3.1 The producer → free → consumer lifecycle

1. **Producer (request N):** an operation publishes request-heap-owned state into a worker-lifetime global — e.g. `PG(last_error_*)`, session `PS()` globals, object/exception state, or PHP 8.5's **fatal-error backtrace state** (with `fatal_error_backtraces=1` this state is stored at every fatal error and its reset is *always* missed during shutdown — the strongest demonstrated producer).
2. **Interruption:** a fatal error (parse/compile failure, uncaught error, failed `require`, **`Maximum execution time exceeded`**) or OOM (see §2) abandons the operation before its matching reset. Even a *non-fatal* warning from extension/server cleanup in a late shutdown stage can *repopulate* already-cleared error state.
3. **Free:** `php_request_shutdown()` frees the request heap; the worker global now dangles.
4. **Consumer (request N+1, same worker, before normal init):**
   - **INI diagnostics** — a warning/deprecation raised while applying `.htaccess` (mod_php) or `PHP_VALUE` (FPM) touches stale error state **before PHP decides whether to report it**. `error_reporting=0`, `@`, `display_errors=Off`, `log_errors=Off` **do not protect**. Qualifying directives include `auto_detect_line_endings=1`, non-default `filter.default`, legacy `assert.*` (8.3+), legacy session settings (8.4+), `report_memleaks=0` (8.5), invalid `memory_limit`/`date.timezone`/`zend.assertions`, `mbstring.http_input`, `iconv.*_encoding`, and `open_basedir`-rejected log/mail/session/SOAP paths.
   - **Session upload progress** — see §3.2.

### 3.2 The session upload progress path (ext/session/session.c)

Pre-patch, `php_session_rfc1867_update()` / `php_session_rfc1867_cleanup()` ran during multipart startup — **before RINIT** — and:

- Called `php_session_initialize()` and set `PS(session_status) = php_session_active` using session globals **not yet reset for the new request** → consumption of stale `PS()` pointers left by a previously interrupted request (the UAF).
- **Unserialized the session file with the full userland machinery** — autoloading, `unserialize_callback_func`, `__wakeup()` / `__unserialize()`, destructors — i.e. attacker-influenceable code execution before RINIT in a half-initialized engine.

The patch ([`ext/session/session.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/ext/session/session.c)):

- Calls `php_rinit_session_globals()` at `MULTIPART_EVENT_START`, since this runs before RINIT.
- Introduces `PS(in_rfc1867_update)`: the (un)serializers run in **incomplete-classes-only mode** — objects decode as `__PHP_Incomplete_Class`, no autoloading, no callbacks, no `__wakeup()`, and are written back unchanged; reserved incomplete-class bookkeeping property names are rejected (test: `rfc1867_incomplete_class_reserved_props.phpt`).
- Bails out when `PG(modules_activated)` is set, so `request_parse_body()` (PHP 8.4+) can't corrupt a live session.
- Deletes the early-populated `$_SESSION` global at `MULTIPART_EVENT_END`.

### 3.3 SAPI exposure

| SAPI | Producer | Consumer | Notes |
|---|---|---|---|
| **mod_php** | Fatal/OOM/timeout on attacker-reachable path (incl. during multipart startup) | `.htaccess` INI diagnostics; upload progress | **Confirmed RCE.** `.htaccess`/`php_value` applied before startup; shutdown always runs |
| **FPM** | State published during app execution + fatal error | Per-request `PHP_VALUE` diagnostics; upload progress | **Validated.** Child exit on failed startup blocks the startup-variant only |
| **FrankenPHP** | Same as mod_php (unconditional shutdown) | No early-config consumer established in default config | Not proven immune; app/extension consumers may exist |
| **LSAPI** | Likely | Early environment INI values | Conditional, unvalidated |

---

## 4. Exploitability Assessment

**Triggering the bug is easy; weaponizing to RCE is expert-level but proven.**

### Easy (low skill)

- Deterministic, unauthenticated OOM via multipart tricks against default `memory_limit`/`post_max_size` — reliable DoS, likely memory disclosure.
- Reaching the upload-progress consumer needs only a multipart POST with `PHP_SESSION_UPLOAD_PROGRESS`.

### Hard (expert exploit development)

- RCE requires **cross-request heap grooming**: leave a worker global dangling into a freed chunk, reoccupy that chunk with attacker-controlled bytes on the **same worker** (POST/upload bodies), then land a consumer request that dereferences the fake structure (read/free/indirect call).
- Reliability depends on PHP version, compiled extensions, SAPI, allocator state, and frontend (nginx buffering/proxying) behavior: *"exploit portability and reliability depend on the PHP build, extensions, frontend behavior and allocator layout."*
- Multiple workers / `pm.max_requests` recycling reduce per-request success probability but **do not remove the condition**.

### Why to treat it as exploitable in practice

- PHP confirmed *"complete remote code execution was validated over HTTP against mod_php with default PHP input and memory limits and normal prefork configurations with multiple workers."*
- The advisory itself documents the producer list, both consumer primitives, qualifying INI directives, and the grooming strategy — a ready-made roadmap that sharply lowers reproduction cost for capable attackers.
- No public PoC is attached, but that is a weak and temporary barrier.

| Attacker | Realistic outcome |
|---|---|
| Unskilled | Reliable unauthenticated DoS; possible info leak |
| Competent exploit developer | RCE on mod_php default configs (confirmed feasible); FPM with more target-specific effort |

---

## 5. Mitigation

> **Updating to a release containing the complete state-reset patch is the only reliable mitigation.** No configuration covers every producer/consumer pair.

Stopgaps (restart all workers after changing):

- `fatal_error_backtraces=0` (PHP 8.5) — removes the confirmed RCE producer.
- `session.upload_progress.enabled=0` — removes the early session producer/consumer; sensible to keep off permanently (rarely used, rarely tested).
- `memory_limit=-1` — removes the predictable per-request OOM for CVE-2026-109618 (incomplete: a failing `mmap()` can still reach the old `longjmp` path). **Do NOT use `RLIMIT_AS`/`RLIMIT_DATA`** — they cause `mmap()` failure and re-enter the vulnerable path. A cgroup `memory.max` is a partial alternative.
- Conservative `post_max_size` enforced independently at the frontend; reduce `max_input_nesting_level`; bound input vars, parts, headers, uploads; block multipart entirely if unused.
- `pm.max_requests=1` (FPM) if performance allows — one request per worker breaks the cross-request lifecycle.
- Eliminate fatal errors and warnings on attacker-reachable paths (uploads, imports, error pages, shutdown functions, destructors, serializers, output handlers); treat `Maximum execution time exceeded` as a fatal-error equivalent.
- Prefer startup-level config (`php.ini`, FPM pool, server config) over per-request overrides; remove obsolete directives.

### Audit guidance

Enable `log_errors=On`, a working `error_log`, `error_reporting=E_ALL` in an isolated audit and review Apache/FPM/proxy logs for: warnings/deprecations repeated before scripts run, diagnostics during extension/server cleanup, fatal/parse/uncaught errors, `Maximum execution time exceeded`, and failures restricted to attacker-controlled routes/content-types/files/callbacks. Absence of log entries is **not** proof of safety (errors may predate logging, and audits only cover exercised routes).

---

## 6. References

### Advisories & fix

- GHSA-6wmf-4rv5-p3rp — "RCE via stale global state" (CVE-2026-109619): https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- GHSA-w8hw-w9fx-j79w — OOM bailout state corruption (CVE-2026-109618): https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- Fix commit "Avoid state corruption via bailout": https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065
- Fix as patch: https://github.com/php/php-src/commit/31c0ea7d7ff1d1fccb6492e52dab544011628065.patch

### Key changed files (at fix commit)

- Allocator rework / delayed OOM: [`Zend/zend_alloc.h`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_alloc.h), [`Zend/zend_alloc.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_alloc.c), [`Zend/zend_globals.h`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_globals.h)
- Fallible hash/string/smart_str APIs: [`Zend/zend_hash.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_hash.c), [`Zend/zend_string.h`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_string.h), [`Zend/zend_smart_str.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/Zend/zend_smart_str.c)
- Request lifecycle, hard limit, shutdown resets: [`main/main.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/main.c)
- Multipart hardening (header filtering, post_max_size enforcement): [`main/rfc1867.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/rfc1867.c)
- GPC parsing without allocation (`array_init_singleton`): [`main/php_variables.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/main/php_variables.c)
- Session upload-progress hardening (incomplete-classes-only mode, early globals reset): [`ext/session/session.c`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/ext/session/session.c), [`ext/session/php_session.h`](https://github.com/php/php-src/blob/31c0ea7d7ff1d1fccb6492e52dab544011628065/ext/session/php_session.h)

### Documentation

- `session.upload_progress.*` directives: https://www.php.net/manual/en/session.configuration.php
- Session upload progress feature: https://www.php.net/manual/en/session.upload-progress.php
- `memory_limit` / resource limits: https://www.php.net/manual/en/ini.core.php#ini.memory-limit
- Apache `mod_php` configuration (`php_value`, `.htaccess`): https://www.php.net/manual/en/configuration.changes.php
- FPM `PHP_VALUE`/`PHP_ADMIN_VALUE` via fastcgi params: https://www.php.net/manual/en/install.fpm.configuration.php

---

*Analysis based on the public advisories and the fix commit. Confirmed-impact statements ("validated RCE", SAPI exposure) are the PHP project's own claims from the advisories.*
