CVE-2026-109619 ·
De un use-after-free entre requests en el upload progress de PHP a ejecución de código
Reproducción independiente del bug, su causa raíz y una cadena de explotación completa en PHP 8.5.11.
Este análisis está disponible solo en inglés.
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 class | Use-after-free (cross-request, stale global state) |
| Component | main/main.c startup order · ext/session · ext/standard/var_unserializer |
| Reachability | Any endpoint that accepts multipart/form-data. No auth, no session_start() needed |
| Deterministic result | Pre-auth worker crash (SIGSEGV) |
| RCE | Demonstrated 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 |
| Fix | php_basic_early_rinit() in PHP 8.5.12 (21 Oct 2026) |
How it works
- Request 1A multipart POST with
PHP_SESSION_UPLOAD_PROGRESSand a large session (a:1048576:{...}). php_var_unserialize_init()setslevel = 1anddata = ptr.array_init_size(1M)exceedsmemory_limit.zend_bailout()skipsPHP_VAR_UNSERIALIZE_DESTROY.- The request heap is freed, but
level = 1anddatastill points into it. The client gets a500. - Request 2A second multipart POST with a small session (
x|i:1;). php_hash_environment()runs before RINIT.level > 0, so the staledatais reused.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:1945Full 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 --verbosebuild.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_dataThe 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/phpFrom 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.
- Dangle. The trigger request exhausts
memory_limitinside 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 azend_referenceis left dangling: the structure is freed with the request heap while the worker keeps the address. - 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_referenceat the offset where the stale pointer lands, and a fakeHashTableelsewhere in the same page. The fake reference points its value at the fake table and keeps a valid type tag. - Aim the destructor. The fake
HashTablecarriespDestructor = zif_system(PHP's internalsystem()handler) plus a fakeexecute_dataframe in the same page, so that when the destructor fires it reads its argument, the command, from a controlledzend_string. - 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 aswww-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
sleepplaced before the command, makes a successful reclaim observable as a slower response, so the search converges. - Resolving addresses.
zif_systemand 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
| Branch | Fixed in | Status |
|---|---|---|
| 8.5 | 8.5.12 | patched |
| 8.4 | 8.4.27 | patched |
| 8.3 | 8.3.36 | patched |
| 8.2 | 8.2.35 | patched |
| ≤ 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_requestsin FPM) to shorten the window. This reduces the risk but does not remove it.
References
- GHSA-6wmf-4rv5-p3rp: RCE via stale global state (CVE-2026-109619)
- GHSA-w8hw-w9fx-j79w: RCE via OOM corruption (CVE-2026-109618)
- PHP 8.5.12 changelog: "Avoid state corruption via bailout"
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.