The php2shell emergency release closed a memory-corruption class that had survived in PHP for two decades. A researcher package published the same evening argues it did not close two session-handling logic bugs, and includes HTTP receipts showing uid=0 on a freshly built, fully patched PHP 8.5.12. The claim is narrow, configuration-dependent, and not vendor-confirmed, but the mechanism is worth understanding because it does not rely on memory corruption at all.
The package, php2shell-zeros, was published on October 21, 2026 by Andrew Dorman (@acd421). It covers four items: the session gaps, a GC double-free regression introduced by the patch itself, a defense map of the patch, and an unserialize() use-after-free. This article examines the session findings. The other two are covered separately.
What the official patch actually changed
The October 21 releases, PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, address CVE-2026-109618 and CVE-2026-109619. Both are CVSS 4.0 9.2. The first is an out-of-memory path: PHP uses longjmp() to abandon a request when an allocation fails, and some callers had already published state that cleanup later treated as valid. The second is stale process-global state that points into a freed request heap and is consumed by a later request on the same worker.
The fix reworked allocation-failure handling so a soft out-of-memory condition is deferred to a safe point and a hard one terminates the worker with _exit(137). It added php_basic_early_rinit() to reset serialization and session globals before the multipart parser can reach them, hardened multipart parsing, and disabled the upload-progress path in code. According to the PHP Foundation, the root cause had been known internally since a 2024 audit but was not fixed because no exploit path had been proven.
That last detail matters. The patch removed the confirmed producers. It did not redesign the session file format, and it did not make the session decoder stricter about what it accepts.
Gap 1: a serialize-handler mismatch is still a format confusion
PHP supports several session serialization formats. The php handler stores a session as a sequence of key|serialized-value pairs. The php_serialize handler stores the whole session array as one serialize() blob. They are not interchangeable, and the format used to write a session file is chosen by session.serialize_handler.
The read and write sides of a request are supposed to use the same handler. In practice they do not always do so. A per-directory php_value, a virtual host, an .htaccess rule, or an application that calls ini_set() between routes can change the handler for one code path and not another. Shared hosting makes this more likely, not less.
When a php handler reads a file written by php_serialize, the decoder looks for the first | byte and unserializes everything after it. Inside a serialized session array, a string value can contain a |. So can a key. An attacker who can get a string containing |O:... persisted into $_SESSION controls the bytes that the php decoder will treat as a serialized object.
The second half of the bug is the cleanup path. When decoding fails, PHP destroys the partially decoded data. Object destruction invokes __destruct(). The package's receipt shows the attacker class destructor being called from inside session_start(), after the decoder had already decided the session was invalid. PHP 8.5's "Session has been destroyed" hardening does not prevent that, because the hardening runs after the object exists.
The package's minimal shape is a session value like this:
legit|O:4:"Evil":1:{s:3:"cmd";s:2:"id";}TRAILING
Read by the php handler, the decoder splits at the first |, constructs Evil, then fails on TRAILING. The failure path still destroys Evil, and the destructor runs. Whether that becomes code execution depends on a gadget class in the application, exactly as with classic PHP object injection.
Gap 2: upload-progress writes an unvalidated key before any script runs
The more interesting finding is the upload-progress key. PHP's RFC1867 handler tracks file uploads in the session, and it runs while the request body is being parsed, before the target script executes and before normal module request-initialization.
The package points at ext/session/session.c around lines 3265 to 3269 in php-8.5.12, where the progress key is built as a prefix plus the raw value of the PHP_SESSION_UPLOAD_PROGRESS multipart field, with no grammar validation:
smart_str_appendl(&progress->key, *data->value, value_len);
If the write side uses php_serialize, that key is persisted inside serialize($_SESSION). A later request that reads the same session with the php handler hits the same format confusion as Gap 1, but the attacker no longer needs an application sink to store a string. The upload handler stores it for them, before application code runs.
On a stock configuration, session.upload_progress.cleanup=1 is supposed to remove the progress entry when the upload finishes. The package defeats this with a slow, drip-fed upload and a concurrent read, so the poisoned entry is observed while it still exists. The receipt claims the injection landed on the first read attempt against an all-stock ini with cleanup=1 and use_strict_mode=1.
There is an important correction in the package. Same-day public research concluded that the upload-progress class was "patched in 8.5.12". That research tested the pure-php write variant, where the 8.5 hardening rejects keys containing |. It did not test the php_serialize write side. The package's hexdump of a php-8.5.12 session file shows the poisoned 145-byte key persisted verbatim:
00000000 61 3a 31 3a 7b 73 3a 31 34 35 3a 22 75 70 6c 6f |a:1:{s:145:"uplo|
00000010 61 64 5f 70 72 6f 67 72 65 73 73 5f 69 6e 6a 7c |ad_progress_inj||
00000020 4f 3a 31 34 3a 22 44 65 66 65 72 72 65 64 57 72 |O:14:"DeferredWr|
Gap 3: a session wipe with defaults only
The third primitive is simpler. Even when the invalid-key check rejects the write, the sequence opens the session file and then rejects the content. The file is left truncated to zero bytes. One unauthenticated multipart POST can therefore log a victim out, or destroy a session that has not yet been written. The package's T4 receipt records a victim session file at 0 bytes on a default configuration.
This is a denial-of-service primitive, not code execution. It is still operationally relevant: session wipes are noisy, cheap, and hard to distinguish from ordinary expiry if the application does not log session-file state.
What the receipts show, and what they do not
The package includes five independent uid= receipts over HTTP: A5 on php-8.5.12, T1 via application ini_set(), T2 via per-directory php_value, T3 against all-stock ini using the slow-upload race, and B2 using a save_path inside the document root so the session file itself is executable. A negative control, C1, shows no mismatch means no object and no shell.
Those runs were performed in the author's lab, in some cases as root. Real deployments would run the web worker as www-data or an equivalent. The package says the findings were unfiled with php-src at publication, so there is no CVE, no vendor advisory, and no independent reproduction of the 8.5.12 RCE claims yet.
Public research does corroborate the building blocks. Qi Deng's October 21 write-up documents the handler-mismatch chain on PHP 8.2.34, including the detail that __destruct fires even when decoding returns failure. Calif's disclosure describes the session upload-progress feature as a pre-authentication surface and its patch notes say the feature was disabled in code. Neither source claims the mismatch is fixed on 8.5.12.
The honest reading is this: the format confusion is real and documented; the upload-progress write is unvalidated in the patched source; the 8.5.12 RCE chain rests on a single researcher's receipts. Treat it as a configuration-dependent risk that deserves an audit, not as a second emergency CVE.
How to audit and harden
Start with uniformity. Enumerate every session.serialize_handler value in play: php.ini, pool configs, vhosts, .htaccess, .user.ini, and ini_set() calls. A server that mixes php and php_serialize across directories is the vulnerable shape. Pick one handler and enforce it everywhere.
Disable upload progress unless an application genuinely needs it:
session.upload_progress.enabled = 0
session.use_strict_mode = 1
session.use_only_cookies = 1
The PHP manual enables upload progress by default, and it runs even when the application never calls session_start(). Disabling it removes both the key-injection write and the cross-request state that CVE-2026-109619 used. Sansec notes that strict mode has no effect when sessions are stored in Redis or a database through a custom handler, so do not treat it as a substitute for patch management.
Do not persist raw request data into $_SESSION. The mismatch only becomes object injection when an attacker-controlled string lands in the session and is later read by a different decoder. That includes upload filenames, PHP_SESSION_UPLOAD_PROGRESS, referrers, and anything copied from $_GET or $_POST without validation.
For detection, watch for session files that shrink to zero bytes, multipart requests carrying PHP_SESSION_UPLOAD_PROGRESS with unusual characters, and repeated session_start() failures in the same second. Application logs will not show the destructor, because it runs inside the session module.
The larger lesson is the one the PHP Foundation made explicit: the patch fixed the producers it knew about. It did not change the session format's assumption that the writer and reader agree. That assumption is an application and configuration property, and it is still the defender's job to enforce it.