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() when PS(in_rfc1867_update) is set.
  • The handler registers $_SESSION in the global symbol table (php_session_track_init → zend_hash_update_ind(&EG(symbol_table), "_SESSION", …)). The fix adds zend_delete_global_variable("_SESSION") at MULTIPART_EVENT_END so a script that never called session_start() cannot see the session data.
  • php_session_track_init() unconditionally frees the existing PS(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.phpt scenario. php_session_initialize() returns FAILURE (s_read fails), php_session_abort() sets PS(session_status) = php_session_none, and the vulnerable php_session_rfc1867_update then forces it back to php_session_active and 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”, but PS(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.