//nefariousplan

CVE-2026-109618 and CVE-2026-109619 Are Two Directions Through the Same Pre-RINIT Window

patterns

cve

proof of concept

PHP's php_request_startup() function reaches two consecutive calls in main/main.c. Line 1942 calls php_hash_environment(). Line 1943 calls zend_activate_modules(). The first drives multipart body parsing, which drives the session.upload_progress callback, which decodes and re-encodes the session using the process-level globals BG(serialize) and BG(unserialize). The second runs each extension's RINIT, which is where those two globals are zeroed. The upload-progress handler reads them a line before RINIT writes them.

CVE-2026-109618 and CVE-2026-109619 are the two directions through the window between those two lines. 8.5.12 ships a new function called php_basic_early_rinit whose first job is to zero BG(serialize) and BG(unserialize). Its comment says why. The patch does not change the ordering.

The two CVEs name the two directions through one window

PHP ships session.upload_progress as a feature that reports multipart upload progress through a session variable. Enabled by default. The implementation registers an rfc1867 callback that runs on every MULTIPART_EVENT_FORMDATA, MULTIPART_EVENT_FILE_START, MULTIPART_EVENT_FILE_DATA, and MULTIPART_EVENT_FILE_END during request-body parsing. On each tick the callback calls php_session_initialize(), which decodes the serialized session file into $_SESSION, writes an updated progress entry, and re-encodes.

That callback runs during php_hash_environment(). In PHP 8.5.11's main/main.c:

php_hash_environment();     // line 1942 - rfc1867 body parse + upload_progress callback
zend_activate_modules();    // line 1943 - each extension's RINIT, including basic
PG(modules_activated)=1;    // line 1944

BG(serialize) and BG(unserialize) are process-wide globals owned by the basic extension. They hold the serializer's working state across the current operation. PHP_RINIT_FUNCTION(basic) zeroes them. PHP_RINIT_FUNCTION(basic) runs inside zend_activate_modules().

The upload-progress callback reaches php_var_serialize_init() and php_var_unserialize_init() while BG(serialize) and BG(unserialize) still hold whatever the previous request's basic RSHUTDOWN left there. In the normal case the previous RSHUTDOWN cleared them. In the case where the previous request's serializer bailed out through longjmp() on an OOM, it did not.

PHP named the two directions through this window as two separate CVEs.

CVE-2026-109618 is the exit direction. A request runs through php_hash_environment(), drives the upload-progress callback, hits memory_limit inside the serializer, and bails out via longjmp. The zend_catch on main.c:1945 catches the bailout and sets retval = FAILURE, but mod_php runs request shutdown anyway. The shutdown reads the half-initialized state that the serializer left behind. Mapped CWE-416. CVSS v4 9.2.

CVE-2026-109619 is the entry direction. The next request on the same worker runs through php_hash_environment() again. BG(serialize) still points at the previous request's freed heap. The upload-progress callback dereferences it before anything resets it. Mapped CWE-416. CVSS v4 9.2.

PHP's own GHSA-6wmf-4rv5-p3rp advisory describes CVE-2026-109619 as a producer-consumer model and notes that the producer's "state is not reset, or another operation repopulates it after the normal reset, because the request was interrupted before cleanup." The CVE-2026-109618 advisory for the exit direction describes the same window from the other side: "allocation failures within this window, PHP fails to account for the early exit, leaving request memory corrupted."

They are describing the same ordering. 1942 before 1943. Upload-progress callback before RINIT.

The patch names the window in its own comment

8.5.12 lands a new function in ext/standard/basic_functions.c:

/* Called from php_request_startup(), before any request data is processed. */
PHPAPI void php_basic_early_rinit(void)
{
	/* The session upload progress handler (un)serializes the session and
	 * registers the session id with the URL rewriter from
	 * php_hash_environment(), before RINIT. */
	BG(serialize_lock) = 0;
	memset(&BG(serialize), 0, sizeof(BG(serialize)));
	memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
	php_url_scanner_early_rinit();
}

The comment is the admission. "The session upload progress handler (un)serializes the session... from php_hash_environment(), before RINIT." PHP's own patch is now carrying a block comment that names the window produced by its own ordering.

The call site in 8.5.12's php_request_startup():

 		zend_activate();
+		php_basic_early_rinit();
 		sapi_activate();

The two problem lines are unchanged:

php_hash_environment();     // still line 1942 of 8.5.11; line 1979 of 8.5.12
zend_activate_modules();    // still the next line

The ordering stays. What changes is that a hand-picked subset of basic's RINIT runs earlier, before sapi_activate(), so that the three globals the rfc1867 callback is known to touch are zero when it arrives. The patch is a surgical pre-RINIT for exactly the globals this window reads.

A second patch, in ext/session/session.c, adds a flag:

PS(in_rfc1867_update) = true;
if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
    PS(in_rfc1867_update) = false;
    return;
}

When the flag is set, both php_session_serialize_init() and php_session_unserialize_init() switch the serializer into incomplete_classes_only mode. The comment above the two functions:

/* The upload progress handler (php_session_rfc1867_update()) decodes and
 * re-encodes the session during request startup, before RINIT, when no user
 * code may run. Objects are then kept as __PHP_Incomplete_Class: no
 * autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
 * and they are written back unchanged. */

Another admission from the same patch. The upload-progress handler decodes sessions "during request startup, before RINIT." Objects produced by unserialize inside the window are now downgraded to __PHP_Incomplete_Class. No autoload. No callbacks. No gadgets. The window remains; the serializer's output from inside it is now inert.

The third component of the patch is in Zend/zend_alloc.c. In 8.5.11 the OOM path ran:

static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap, ...)
{
    heap->overflow = 1;
    zend_try {
        zend_error_noreturn(E_ERROR, format, ...);
    } zend_catch {
    } zend_end_try();
    heap->overflow = 0;
    zend_bailout();   // longjmp
    exit(1);
}

zend_bailout() calls longjmp(). The longjmp unwinds out of whatever the serializer was doing. In 8.5.12 that function is deleted. Soft OOM now queues a delayed error, sets vm_interrupt, and returns normally so the caller can see FAILURE. Hard OOM, controlled by a new memory_hard_limit_percent INI (default 200% of memory_limit), calls _exit(137) directly. No longjmp. No partially-unwound serializer. The exit direction of the window is closed by removing the mechanism that produced partial state.

So the 8.5.12 patch is three changes, each of which names the window:

  1. php_basic_early_rinit() runs before sapi_activate(), zeroing the three basic globals this specific window reads.
  2. PS(in_rfc1867_update) gates the serializer into incomplete-classes-only mode inside the window.
  3. zend_bailout() is replaced for OOM so there is no longjmp out of the window.

What is not changed: php_hash_environment() still runs before zend_activate_modules(). The upload-progress callback still runs before basic's normal RINIT. Session data from an attacker's multipart POST still passes through php_var_unserialize in a state that is after sapi_activate and before zend_activate_modules. The window remains, with new rules about what is allowed to happen inside it.

The ecosystem's first theory was the wrong one

The leak hit Hacker News on 2026-10-20 at 15:20Z. Within forty-eight hours more than seventy GitHub repositories appeared under the CVE-2026-109618, CVE-2026-109619, and php2shell names. The majority of them point at a mechanism that doesn't exist.

The circulating theory was that session.upload_progress's cached zval* pointers (post_bytes_processed and current_file_bytes_processed) hold references into an array that a session re-decode frees, producing a UAF. The theory names a specific 258-byte multipart trigger and a 149-element packed array as the heap groom. Multiple independent audits refuted it inside the first day.

xuehuolll's audit ran the trigger against instrumented builds of 8.2.34, 8.4.4, 8.5.4, 8.5.11, and 8.5.12 plus three LiteSpeed vendor builds. On every build the cached array's refcount walks 2 → 1 at track_init and never reaches 0. zend_hash_update() destroys a different array, old_arr, which is not the one the cached pointers hold. Z_TRY_ADDREF(progress->data) at session.c:3194 prevents the free the theory requires. The published 258-byte mini-trigger fails its own byte gate: 258 < 260.

dinosn's lab ran the same theory through sixty update() re-decode passes on each build plus every documented cleanup, abort, and two-worker same-session concurrency corner. The refutation is exhaustive: "the free it needs exists only in places nothing dereferences, and the stale pointer it needs exists only where nothing reads it."

The actual exploit paths that produce uid=33(www-data) on patched or unpatched 8.4.x and 8.5.11 do not touch post_bytes_processed. They use the window. The ninebie-class port on 8.5.11 arms PS(http_session_vars) with a dangling zend_reference, reclaims the referent with a live 31-byte multipart field value, and forges a pDestructor pointer to zif_system in libphp. The chain fires with four plain HTTP POSTs, no debugger, no local access, no session_start() on the target page, twelve successful fires across eight ASLR generations.

The point here is not that the ecosystem's theory was wrong on one detail. The point is that about seventy repositories spent two days publishing variants, mirrors, scanners, and defender rules against a UAF that is structurally unfireable. The gap between code reading and ticker-chasing is forty-eight hours wide and seventy repositories deep.

Three paths survive 8.5.12

Both independent lab bodies converge on the same conclusion: 8.5.12 closes the two CVEs and keeps the window open.

The var_destroy re-entry UAF. The patch's ZEND_HAS_OOM() guards do not reach the var_destroy boundary. BG(serialize_lock) is still not held around the __destruct dispatch inside the dtor walk. xuehuolll's var_destroy_uaf_rce.php fires 5-of-5 on CLI 8.5.12 with uid=0. dinosn's instrumentation finds the fault at var_unserializer.c:906:67, identical to 8.5.11.

The serialize-handler mismatch. A session written by one application endpoint with session.serialize_handler = php_serialize is decoded by another endpoint running session.serialize_handler = php. The decoder splits at |, invoking unserialize on an attacker-controlled payload. The patch's incomplete_classes_only guard is scoped to the pre-RINIT upload window. When the handler-mismatch chain triggers via the application's own session_start(), the window flag is not set. uid=33(www-data) on 8.5.12 mod_php.

The upload-progress session injection. The rfc1867 handler stores the attacker-controlled name field, the uploaded filename, and the progress value in the session file verbatim. On 8.5.12 the raw bytes still land on disk. Any application that includes a file whose path is partially derived from session data has an RCE primitive sitting next to it.

GitHub advisory GHSA-6wmf-4rv5-p3rp describes CVE-2026-109619 as a producer-consumer pair and lists producers as "parse/compile failures, uncaught errors, require failure, maximum execution time and OOM." That is a list of fatal interruptions. The patch addresses the specific producers routed through BG(serialize) and the specific consumer routed through the rfc1867 callback. The window itself continues to accept producers and consumers that the patch's three changes did not cover. Three such pairs already have working exploits.

The ordering produced both CVEs. The patch keeps the ordering.

Both halves of the attack live in the gap between php_hash_environment() on line 1942 and zend_activate_modules() on line 1943 of PHP 8.5.11's main/main.c. The exit direction leaves state. The entry direction consumes it. The 8.5.12 patch zeroes exactly the three basic globals this window touches, flags the window so the serializer refuses to run user code inside it, and removes the longjmp that produced the leftover state. The upload-progress callback still runs before basic's normal RINIT.

The rfc1867 upload-progress callback is a design debt driver. It is a user-reachable callback that reads process-level globals before per-request initialization has run, and the cost of removing it is a feature deprecation that PHP has declined to pay through four major-version cycles. 109618 and 109619 are two instances. The three post-patch paths above are three more. Each release addresses specific producers and specific consumers. The substrate stays.

The window itself is an unpatchable primitive. The ordering between php_hash_environment() and zend_activate_modules() is load-bearing for a feature that ships enabled by default; reversing it breaks session.upload_progress, which is the point of the feature. 8.5.12 does not reverse the ordering. It polices what is allowed to happen inside the window. New producers and new consumers will continue to be found inside it, which is what "three paths survive" means in practice.

PoC: dinosn/php-cve-2026-10961x-lab-poc · xuehuolll/php2shell-CVE-2026-109618-109619 · Zer061/CVE-2026-109618

The patch names the window and leaves it open. The next PHP RCE in this file class will land in the same one.