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
.htaccessphp_value/php_flag/php_admin_*directives beforephp_request_startup(); FPM applies per-requestPHP_VALUE/PHP_ADMIN_VALUEfastcgi 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, wheneversession.upload_progress.enabledis on (the default) and the POST contains thePHP_SESSION_UPLOAD_PROGRESSfield. No application call tosession_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, … returningNULLon limit instead oflongjmp(Zend/zend_alloc.h). - Delayed-OOM state machine:
zend_oom_infoin 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, …) gainedZEND_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.cOnChangeMemoryHardLimitPercent). 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: onlyContent-Disposition/Content-Typeheaders are kept (deduped), andfill_buffer()enforcespost_max_sizeagainst 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
- Producer (request N): an operation publishes request-heap-owned state into a worker-lifetime global — e.g.
PG(last_error_*), sessionPS()globals, object/exception state, or PHP 8.5's fatal-error backtrace state (withfatal_error_backtraces=1this state is stored at every fatal error and its reset is always missed during shutdown — the strongest demonstrated producer). - 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. - Free:
php_request_shutdown()frees the request heap; the worker global now dangles. - Consumer (request N+1, same worker, before normal init):
- INI diagnostics — a warning/deprecation raised while applying
.htaccess(mod_php) orPHP_VALUE(FPM) touches stale error state before PHP decides whether to report it.error_reporting=0,@,display_errors=Off,log_errors=Offdo not protect. Qualifying directives includeauto_detect_line_endings=1, non-defaultfilter.default, legacyassert.*(8.3+), legacy session settings (8.4+),report_memleaks=0(8.5), invalidmemory_limit/date.timezone/zend.assertions,mbstring.http_input,iconv.*_encoding, andopen_basedir-rejected log/mail/session/SOAP paths. - Session upload progress — see §3.2.
- INI diagnostics — a warning/deprecation raised while applying
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 setPS(session_status) = php_session_activeusing session globals not yet reset for the new request → consumption of stalePS()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()atMULTIPART_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, sorequest_parse_body()(PHP 8.4+) can't corrupt a live session. - Deletes the early-populated
$_SESSIONglobal atMULTIPART_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_requestsrecycling 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 failingmmap()can still reach the oldlongjmppath). Do NOT useRLIMIT_AS/RLIMIT_DATA— they causemmap()failure and re-enter the vulnerable path. A cgroupmemory.maxis a partial alternative.- Conservative
post_max_sizeenforced independently at the frontend; reducemax_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 exceededas 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,Zend/zend_alloc.c,Zend/zend_globals.h - Fallible hash/string/smart_str APIs:
Zend/zend_hash.c,Zend/zend_string.h,Zend/zend_smart_str.c - Request lifecycle, hard limit, shutdown resets:
main/main.c - Multipart hardening (header filtering, post_max_size enforcement):
main/rfc1867.c - GPC parsing without allocation (
array_init_singleton):main/php_variables.c - Session upload-progress hardening (incomplete-classes-only mode, early globals reset):
ext/session/session.c,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_phpconfiguration (php_value,.htaccess): https://www.php.net/manual/en/configuration.changes.php - FPM
PHP_VALUE/PHP_ADMIN_VALUEvia 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.