Research

CVE-2026-109619 ·

From a cross-request use-after-free in PHP's upload progress to code execution

An independent reproduction of the bug, its root cause, and a full exploitation chain on PHP 8.5.11.

TL;DR

php_request_startup() parses the multipart body, and with it runs the session.upload_progress callback, before PHP_RINIT(basic) resets BG(serialize) and BG(unserialize).

If a session decode runs out of memory, zend_bailout() skips PHP_VAR_UNSERIALIZE_DESTROY. The global is left at level = 1, pointing into a request heap that is about to be freed. The next request on the same worker reaches the callback before RINIT and reuses the freed pointer.

Bug classUse-after-free (cross-request, stale global state)
Componentmain/main.c startup order · ext/session · ext/standard/var_unserializer
ReachabilityAny endpoint that accepts multipart/form-data. No auth, no session_start() needed
Deterministic resultPre-auth worker crash (SIGSEGV)
RCEDemonstrated on single-worker mod_php (php:8.5.11-apache) as www-data. High-complexity and build-specific; the weaponized exploit is not published here
Fixphp_basic_early_rinit() in PHP 8.5.12 (21 Oct 2026)

How it works

  1. Request 1
    A multipart POST with PHP_SESSION_UPLOAD_PROGRESS and a large session (a:1048576:{...}).
  2. php_var_unserialize_init() sets level = 1 and data = ptr.
  3. array_init_size(1M) exceeds memory_limit. zend_bailout() skips PHP_VAR_UNSERIALIZE_DESTROY.
  4. The request heap is freed, but level = 1 and data still points into it. The client gets a 500.
  5. Request 2
    A second multipart POST with a small session (x|i:1;).
  6. php_hash_environment() runs before RINIT. level > 0, so the stale data is reused.
  7. tmp_var() reads (*var_hashx)->last_dtor. SIGSEGV.

The ASAN stack shows the whole path running inside php_request_startup(), before a single line of application code:

==PID==ERROR: AddressSanitizer: SEGV on unknown address 0x00000000013a
    #0  tmp_var                        ext/standard/var_unserializer.c:173
    #1  var_tmp_var                    ext/standard/var_unserializer.c:195
    #2  ps_srlzr_decode_php            ext/session/session.c:1149
    #3  php_session_decode             ext/session/session.c:320
    #4  php_session_initialize         ext/session/session.c:540
    #5  php_session_rfc1867_cleanup    ext/session/session.c:3199
    #6  php_session_rfc1867_callback   ext/session/session.c:3364
    #7  rfc1867_post_handler           main/rfc1867.c:1273
        ...
    #13 php_hash_environment           main/php_variables.c:794
    #14 php_request_startup            main/main.c:1945

Full report: poc/asan-trace.txt.

Reproduce

Requirements: Linux, a C compiler, make, curl, xz and Python 3.8+.

git clone https://github.com/santihabib/CVE-2026-109619.git
cd CVE-2026-109619
 
./build.sh              # PHP 8.5.11, normal build
./build.sh asan         # optional: PHP 8.5.11 with AddressSanitizer
 
python3 poc/repro_uaf.py
python3 poc/repro_uaf.py --php build-asan/php-8.5.11/sapi/cli/php --verbose

build.sh downloads the official tarball, checks its SHA-256 and builds with --disable-all --enable-session --enable-cli. Everything stays inside the repo directory and nothing is installed.

Expected output

[*] php          build/php-8.5.11/sapi/cli/php
[*] server       http://127.0.0.1:54249/  memory_limit=16M
[*] req1         seeded session: 4194317 bytes (1048576 elements)
[*] req1         HTTP/1.0 500 Internal Server Error  oom=True  alive=True
[*] req2         seeded session: x|i:1;
[*] req2         <no response>  exit=-11  asan=False
[+] worker crashed: BG(unserialize) reused the freed php_unserialize_data

The script exits 0 only when the worker dies from a signal. On a patched build the second request returns 200, the worker survives and the script exits 1:

./build.sh patched
python3 poc/repro_uaf.py --php build-patched/php-8.5.12/sapi/cli/php

From crash to code execution

The pre-auth crash is the entry point, not the end. The same stale pointer can be driven to command execution on a single-worker mod_php target: the official php:8.5.11-apache image with Apache prefork. At a conceptual level the chain is four steps.

  1. Dangle. The trigger request exhausts memory_limit inside the multipart parser while the upload-progress callback is initializing the session. The OOM bailout skips the normal shutdown, so a session-side pointer to a zend_reference is left dangling: the structure is freed with the request heap while the worker keeps the address.
  2. Reclaim. The next request sprays many session values, each large enough to cover a 4 KiB page. Inside each value it lays out a fake zend_reference at the offset where the stale pointer lands, and a fake HashTable elsewhere in the same page. The fake reference points its value at the fake table and keeps a valid type tag.
  3. Aim the destructor. The fake HashTable carries pDestructor = zif_system (PHP's internal system() handler) plus a fake execute_data frame in the same page, so that when the destructor fires it reads its argument, the command, from a controlled zend_string.
  4. Fire. When the session is torn down, the engine resolves the stale reference to the fake table and invokes its destructor. system() runs the command as www-data.

Two things keep this high-complexity rather than turnkey:

  • Finding where the pointer lands. The reference offset inside the 2 MiB Zend MM chunk depends on the build and on the request layout. Recovering it needs a search: the low bits are the offset inside the page, the high bits select the page. A timing oracle, a sleep placed before the command, makes a successful reclaim observable as a slower response, so the search converges.
  • Resolving addresses. zif_system and the destructor path live at build-specific addresses. On a fixed image they are constant; against an unknown target they need a leak or a per-image recovery step. There is no universal "one POST and shell".

The chain also has preconditions that keep it from being a generic RCE: a single prefork worker (mod_php or lsphp), session.upload_progress.enabled = On, a small memory_limit so the OOM fires where it should, and the address recovery above. The pre-auth crash is deterministic; the RCE is not.

Affected versions

BranchFixed inStatus
8.58.5.12patched
8.48.4.27patched
8.38.3.36patched
8.28.2.35patched
≤ 8.1, 7.x, 5.x—no official fix (EOL)

Reproduced on 8.5.11. Other branches are listed according to the official advisory.

The fix

PHP 8.5.12 adds php_basic_early_rinit(), called from php_request_startup() before sapi_activate() and php_hash_environment(). It resets BG(serialize_lock), BG(serialize) and BG(unserialize), so no early consumer can find stale state from a previous request.

The same release adds incomplete_classes_only while PS(in_rfc1867_update) is set, so objects found in the session during the upload-progress update become __PHP_Incomplete_Class and no magic methods run.

Mitigation for unpatched branches

  • Upgrade to a fixed release. This is the only full fix.
  • If you don't use upload progress, disable it: session.upload_progress.enabled = Off.
  • Recycle workers regularly (pm.max_requests in FPM) to shorten the window. This reduces the risk but does not remove it.

References

Disclaimer

This is an independent reproduction of a publicly disclosed and patched vulnerability, done in a local lab on my own builds. It is published for research, education and defense. The reproducer only crashes a local php -S server that it starts itself. Don't run it against systems you don't own or have permission to test.