CVE-2026-109619 — session upload-progress leak characterization (in progress)
This documents the session-side half of PHP2Shell: the cross-request stale
session state that the advisory lists as “memory disclosure / RCE”, and the
work done so far toward harvesting a heap-address leak to feed the
CVE-2026-109618 primitive (see CHARACTERIZATION.md).
Status: mechanism understood and partly confirmed; the concrete heap-address leak is not yet reproduced.
1. The vulnerable code path
The default-on session.upload_progress handler (php_session_rfc1867_callback)
runs during multipart request parsing, i.e. inside php_request_startup →
sapi_activate, which is before RINIT (zend_activate_modules). At that
point the session globals have not been reset for the new request.
php_request_startup order (main/main.c):
zend_activate(); /* engine state */
sapi_activate(); /* <-- multipart parsing → upload-progress handler runs HERE */
php_hash_environment();
zend_activate_modules(); /* <-- RINIT → php_rinit_session_globals() runs HERE */
PG(modules_activated)=1;
The fix (commit 01a40e89fb) therefore adds, at the top of
MULTIPART_EVENT_START:
/* This runs before RINIT, so we need to clear these early */
php_rinit_session_globals();
php_rinit_session_globals() resets PS(id), PS(session_status),
PS(http_session_vars), PS(session_vars), PS(mod_data), … — all the
per-request session state. Without it, a second request running on the same
worker sees the previous request’s session state.
2. What the fix’s own regression tests reveal
The patch ships three session tests (ext/session/tests/rfc1867_*.phpt) that
enumerate the vulnerabilities precisely:
| Test | What it guards |
|---|---|
rfc1867_no_user_code.phpt |
the pre-RINIT decode must not autoload classes or run __wakeup/__unserialize/Serializable::unserialize; and must not leave $_SESSION in $GLOBALS |
rfc1867_incomplete_class_reserved_props.phpt |
the “keep objects as __PHP_Incomplete_Class” bookkeeping must be unambiguous, else the decode is refused |
rfc1867_sid_invalid.phpt |
the progress array must not leak into $_SESSION on failed sessions |
Key facts the tests encode:
- The handler decodes the session record before RINIT, and in the
vulnerable build that decode can resolve classes / run user code before
any application code exists. The fix forces “incomplete classes only” mode
via
php_session_unserialize_init()whenPS(in_rfc1867_update)is set. - The handler registers
$_SESSIONin the global symbol table (php_session_track_init→zend_hash_update_ind(&EG(symbol_table), "_SESSION", …)). The fix addszend_delete_global_variable("_SESSION")atMULTIPART_EVENT_ENDso a script that never calledsession_start()cannot see the session data. php_session_track_init()unconditionally frees the existingPS(http_session_vars)before creating a new one:
static void php_session_track_init(void)
{
zend_string *var_name = ZSTR_INIT_LITERAL("_SESSION", 0);
zend_delete_global_variable(var_name);
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars)); /* <-- free previous */
}
array_init(&session_vars);
ZVAL_NEW_REF(&PS(http_session_vars), &session_vars);
...
}
3. Empirically confirmed
3.1 Session poisoning (data disclosure into the session file)
A single mod_php request (PHP 8.4.26, single prefork worker) with
PHPSESSID + PHP_SESSION_UPLOAD_PROGRESS + a file whose field name is
~700 KB writes that field name into the session file. When the same request
then OOMs (the CVE-2026-109618 $_FILES corruption), the
MULTIPART_EVENT_END cleanup is skipped, so the progress data persists:
$ cat /tmp/sess_abcdef0123456789abcdef0123456789 # 717,324 bytes
upload_progress_k|a:5:{s:10:"start_time";i:…;s:5:"files";a:2:{
i:0;a:7:{s:10:"field_name";s:4:"seed";…}
i:1;a:7:{s:10:"field_name";s:716803:"AAAA…AAAA";…} # ← the 700 KB field name
So an attacker can write arbitrary (large) content into the session file even
before any application session_start(). This is a session-poisoning
primitive (useful for the classic session-file + LFI chain), and it confirms
the handler runs and the cleanup-skip behavior — but it leaks attacker data,
not a heap address.
3.2 Pre-RINIT decode of a crafted session record
Under an ASan php-cgi build (USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1), a
session file containing a serialized object is decoded during the upload
progress handler before the script runs, and re-encoded unchanged:
input: k|O:8:"stdClass":1:{s:1:"a";i:1;}
output: k|O:8:"stdClass":1:{s:1:"a";i:1;}upload_progress_key|a:5:{…}
No ASan fault in this case (built-in class, no __wakeup), but it confirms the
decode-into-$_SESSION-then-re-encode flow the fix hardens.
3.3 Not yet reproduced: the heap-address leak
The memory disclosure the advisory describes is the cross-request stale
state: a previous request leaves PS(id) / PS(http_session_vars) /
PS(rfc1867_progress) referencing memory that a later request’s
php_session_track_init or serializer reads. Two candidate triggers were
attempted and did not produce a UAF in this environment:
- Request A = startup OOM (modules_activated=0): the session globals are leaked (still valid), and request B frees them cleanly — no UAF.
- Request A = simple session progress + request B = progress: clean.
Two further single-request candidates were exercised under the ASan build
(USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1) and produced no ASan fault:
- Invalid session id (too long / illegal chars) — the exact
rfc1867_sid_invalid.phptscenario.php_session_initialize()returns FAILURE (s_readfails),php_session_abort()setsPS(session_status) = php_session_none, and the vulnerablephp_session_rfc1867_updatethen forces it back tophp_session_activeand writes the progress data into the (still-valid, empty)PS(http_session_vars). This reproduces the advisory’s “session data written into a failed session”, butPS(http_session_vars)is valid here — no use-after-free.
Conclusion so far: every single-request path leaves PS(http_session_vars) /
PS(id) either UNDEF/NULL (cleanly reset) or leaked-but-valid (freed
correctly by the next php_session_track_init). The use-after-free therefore
requires the cross-request case in which the backing memory is freed and
a pointer to it is left non-reset — i.e. a fatal error that aborts
php_rshutdown_session_globals() part-way (the “fatal error / timeout leaves
state behind” condition from the advisory). That precise abort point has not
been reproduced here, and it is the remaining gate for a heap-address leak.
Key negative result (confirmed): zend_deactivate() (the per-request
engine teardown that does run even after a startup failure) does not
reset the Zend MM heap — the worker heap persists across mod_php requests. So
session state skipped by RSHUTDOWN is leaked (still mapped and valid), not
freed, and the next request’s php_session_track_init frees it cleanly.
Consequently the UAF is not a simple “OOM skips cleanup” effect; it needs a
specific free-without-reset inside the session code itself (e.g. the progress
struct’s internal zval*s — post_bytes_processed /
current_file_bytes_processed — dangling after a resize, or a double-free
across MULTIPART_EVENT_END + module RSHUTDOWN), which has not been triggered
in this environment.
4. Where this fits the overall chain
The goal is one heap address to use as P in the CVE-2026-109618
*(uint32_t*)P -= 1 primitive. The 109619 session surface offers, so far:
- session poisoning (confirmed) — arbitrary session-file content;
- pre-RINIT user code (the fix’s “no user code” guarantee) — a POP-chain / code-execution vector if the target app has a suitable class;
- cross-request stale state / decode-OOM (mechanism identified) — the path to an actual heap-address disclosure, still to be triggered.
No heap address has been leaked yet. The single-request decode-OOM path in §3.3 is the concrete next step.