markdown.page

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 CVE-2026-109618 OOM bailout leaves invalid request state that shutdown processes RCE (mod_php, validated over HTTP)
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 (~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).
  • 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).
  • array_init_singleton() — pre-allocated immutable empty arrays so GPC/request parsing needs no allocation at all (main/php_variables.c).
  • Multipart hardening in 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):

  • 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

Key changed files (at fix commit)

Documentation


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.