-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The two CVEs name the two directions through one window\n\nPHP 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.\n\nThat callback runs during `php_hash_environment()`. In PHP 8.5.11's `main/main.c`:\n\n```c\nphp_hash_environment(); // line 1942 - rfc1867 body parse + upload_progress callback\nzend_activate_modules(); // line 1943 - each extension's RINIT, including basic\nPG(modules_activated)=1; // line 1944\n```\n\n`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()`.\n\nThe 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.\n\nPHP named the two directions through this window as two separate CVEs.\n\nCVE-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.\n\nCVE-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.\n\nPHP'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.\"\n\nThey are describing the same ordering. 1942 before 1943. Upload-progress callback before RINIT.\n\n## The patch names the window in its own comment\n\n8.5.12 lands a new function in `ext/standard/basic_functions.c`:\n\n```c\n/* Called from php_request_startup(), before any request data is processed. */\nPHPAPI void php_basic_early_rinit(void)\n{\n\t/* The session upload progress handler (un)serializes the session and\n\t * registers the session id with the URL rewriter from\n\t * php_hash_environment(), before RINIT. */\n\tBG(serialize_lock) = 0;\n\tmemset(&BG(serialize), 0, sizeof(BG(serialize)));\n\tmemset(&BG(unserialize), 0, sizeof(BG(unserialize)));\n\tphp_url_scanner_early_rinit();\n}\n```\n\nThe 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.\n\nThe call site in 8.5.12's `php_request_startup()`:\n\n```diff\n \t\tzend_activate();\n+\t\tphp_basic_early_rinit();\n \t\tsapi_activate();\n```\n\nThe two problem lines are unchanged:\n\n```c\nphp_hash_environment(); // still line 1942 of 8.5.11; line 1979 of 8.5.12\nzend_activate_modules(); // still the next line\n```\n\nThe 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.\n\nA second patch, in `ext/session/session.c`, adds a flag:\n\n```c\nPS(in_rfc1867_update) = true;\nif (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {\n PS(in_rfc1867_update) = false;\n return;\n}\n```\n\nWhen 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:\n\n```c\n/* The upload progress handler (php_session_rfc1867_update()) decodes and\n * re-encodes the session during request startup, before RINIT, when no user\n * code may run. Objects are then kept as __PHP_Incomplete_Class: no\n * autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),\n * and they are written back unchanged. */\n```\n\nAnother 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.\n\nThe third component of the patch is in `Zend/zend_alloc.c`. In 8.5.11 the OOM path ran:\n\n```c\nstatic ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap, ...)\n{\n heap->overflow = 1;\n zend_try {\n zend_error_noreturn(E_ERROR, format, ...);\n } zend_catch {\n } zend_end_try();\n heap->overflow = 0;\n zend_bailout(); // longjmp\n exit(1);\n}\n```\n\n`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.\n\nSo the 8.5.12 patch is three changes, each of which names the window:\n\n1. `php_basic_early_rinit()` runs before `sapi_activate()`, zeroing the three `basic` globals this specific window reads.\n2. `PS(in_rfc1867_update)` gates the serializer into incomplete-classes-only mode inside the window.\n3. `zend_bailout()` is replaced for OOM so there is no `longjmp` out of the window.\n\nWhat 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.\n\n## The ecosystem's first theory was the wrong one\n\nThe 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.\n\nThe 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.\n\n[xuehuolll's audit](https://github.com/xuehuolll/php2shell-CVE-2026-109618-109619) 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.\n\n[dinosn's lab](https://github.com/dinosn/php-cve-2026-10961x-lab-poc) 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.\"\n\nThe 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.\n\nThe 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.\n\n## Three paths survive 8.5.12\n\nBoth independent lab bodies converge on the same conclusion: 8.5.12 closes the two CVEs and keeps the window open.\n\nThe `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.\n\nThe 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.\n\nThe 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 `include`s a file whose path is partially derived from session data has an RCE primitive sitting next to it.\n\n[GitHub advisory GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/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.\n\n## The ordering produced both CVEs. The patch keeps the ordering.\n\nBoth 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.\n\nThe 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.\n\nThe 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.\n\nPoC: [dinosn/php-cve-2026-10961x-lab-poc](https://github.com/dinosn/php-cve-2026-10961x-lab-poc) · [xuehuolll/php2shell-CVE-2026-109618-109619](https://github.com/xuehuolll/php2shell-CVE-2026-109618-109619) · [Zer061/CVE-2026-109618](https://github.com/Zer061/CVE-2026-109618)","closing_line":"The patch names the window and leaves it open. The next PHP RCE in this file class will land in the same one.","hook_md":"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.\n\nCVE-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.","post_id":737,"slug":"php-cve-2026-109618-109619-two-directions-one-window","title":"CVE-2026-109618 and CVE-2026-109619 Are Two Directions Through the Same Pre-RINIT Window","type":"initial","unreadable_sentence":"PHP's patch does not change the ordering. It adds a function called `php_basic_early_rinit` that does a partial RINIT for exactly the two globals the window reads."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatlAuQAKCRDeZjl4jgkQ JnVYAQDeKdMGe9F1ZJEl99tYit1lYZAEEylsTTcdXd8s8jWmCAD/d08DlpQtM1kr qDwhTAv1pqFsddd7nA1FeVcyCjoIiAI= =HdAM -----END PGP SIGNATURE-----