# CVE-2026-115560 — PHP session serializer confusion via `session.upload_progress` → unauthenticated object injection → RCE
PoC and verification material for CVE-2026-115560: PHP writes attacker-influenced
upload-progress data into the session file under the *configured* serializer;
an application that switches `session.serialize_handler` to `php` at runtime
before `session_start()` then decodes those bytes with the `php` parser, whose
record separator is the `|` character — handing the deserializer to attacker
bytes. This repository demonstrates the resulting chain to unauthenticated
remote command execution against a locally recreated lab built on PHP 8.5.12.
**Status note (read first).** The PHP project published an *informational*
advisory for this CVE on 2026-10-23 and states the full advisory will ship with
the regular bugfix release. No CVSS score, CWE, affected-version range or
GHSA has been published for this CVE at the time of this repository's release,
and this repository asserts none. A CVSS 9.2 figure circulating in coverage of
the October 21 PHP releases belongs to **CVE-2026-109619** (a different bug);
this material deliberately does not quote it. If the full vendor advisory
changes the technical picture, this repository will be corrected in place.
PHP's advisory names the vulnerable combination:
> An application must set `session.serialize_handler` to `php_binary` or
> `php_serialize` in its configuration and then switch it to `php` at runtime
> with `ini_set()` before calling `session_start()`. Session upload progress
Mechanism (observed against PHP 8.5.12 and consistent with the advisory text;
courtesy of reading `ext/session/session.c` and `main/rfc1867.c` in the pinned
1. A multipart upload with a `PHP_SESSION_UPLOAD_PROGRESS` field makes the
upload hook write an upload-progress record into the session file *before*
the session module initializes the request — under the **configured**
serializer (`php_binary` or `php_serialize`).
2. Attacker-controlled strings (the uploaded **filename**, the progress field
3. When the application then calls `ini_set('session.serialize_handler','php')`
and `session_start()`, PHP decodes the file with the **`php` decoder**, which
splits records on the first `|`. The attacker's `|` moves the parse start
4. `php_var_unserialize()` runs on those bytes with class autoloading and
destructors enabled → arbitrary object injection.
5. Instantiating a class whose `__destruct()` persists a property to a path
(this repository's lab ships a framework-style cache-writer class as the
gadget surface) drops a webshell → command execution as the web user.
Two negative controls each individually break the chain — one of them is PHP's
recommended mitigation (`session.upload_progress.enabled=0`), the other is the
guard the advisory's preconditions imply the application owns (never switching
the handler at runtime) — and both are verified as causal negative controls in
- Vendor advisory: php.net news archive, 23 Oct 2026 — "Informational advisory:
Session upload progress"; the preconditions quoted above are a condensation
of that notice's wording (the notice URL is in References).
- Mitigation, verbatim from the advisory: set `session.upload_progress.enabled=0`
until the regular bugfix release. The same setting was already listed on
21 Oct 2026 as a mitigation for CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp).
- Not fixed by any PHP release at the time of writing: 8.5.12 / 8.4.27 /
8.3.36 / 8.2.35 (the 21 Oct emergency releases covering CVE-2026-109618 and
CVE-2026-109619) do not cover this CVE. The fix ships with the regular
bugfix release, and a patched-build negative therefore cannot be produced
yet — this is stated, and it is why the negatives here are configuration
Everything in the `captured` tier has the transcript that proves it, in
`evidence/`, produced verbatim by tooling that records command, working
directory, UTC timestamp and exit code. Everything in the second tier is
**documented, not reproduced here**, with the reason.
| 1 | Lab fingerprint: PHP 8.5.12, `serialize_handler=php_binary`, upload progress on, mpm_prefork + mod_php | captured | `evidence/01-lab-fingerprint.txt` |
| 2 | The upload hook writes the `php_binary` record containing the attacker's `\|` (byte dump); the handler-switching app then fails to decode the same session file | captured | `evidence/02-poison-bytes-decode-mismatch.txt` |
| 3 | RCE, non-default cleanup=0 profile: poisoned record → confused decode → gadget `__destruct` writes a PHP shell → nonce-verified `id`/`/etc/passwd` as `uid=33(www-data)` | captured | `evidence/03-direct-rce-cleanup0.txt` |
| 4 | RCE on the DEFAULT cleanup=1 profile: direct path fails (record erased at upload completion), concurrent request racing the streaming window lands the decode | captured | `evidence/04-race-rce-default-cleanup.txt` |
| 5 | RCE with `session.serialize_handler=php_serialize` (the other serializer PHP names in the advisory) | captured | `evidence/07-rce-php-serialize-profile.txt` |
| 6 | Causality receipt for the no-switch negative: the identical poisoned request is rejected by the vulnerable app but accepted (progress key listed) by the consistent-handler app | captured | `evidence/05-consistent-twin-decode-receipt.txt` |
| 7 | Both configuration negatives (upload progress off; no runtime switch), direct and race modes: exit 1, no nonce, no shell written — 4/4 exploit invocations across isolated docroots, each seed also byte-verified clean | captured | `evidence/06-negatives-isolated-docroots.txt` |
| 8 | Reliability: 5/5 direct (cleanup=0) + 10/10 race (default profile) on the shipped implementation | captured | `evidence/08-reliability-15-of-15.txt` |
| 9 | `php_binary` key-length ceiling measured with corrected numbers (55-byte key → 381-byte record; 172-byte key → 0-byte file, encoder skips over-long keys) | captured | `evidence/09-key-length-ceiling.txt` |
| 10 | Exit-code contract of both drivers (0 executed+nonce / 1 completed-no-execution / 2 usage / 3 transport), incl. malformed port → 2 | captured | `evidence/10-exit-code-matrix.txt` |
| A | Vulnerable PHP versions beyond 8.5.12 (e.g. the "> = 5.4" range implied by the code history) | documented, not reproduced here | only 8.5.12 was executed; the statement is source-derived from the pinned tree, labelled as such |
| B | A stock Composer-library gadget chain (e.g. monolog) | documented, not reproduced here | the shipped lab uses a lab-provided model class `App\Cache\FileCache` (framework-style cache writer); "RCE given *any* class with a file-writing destructor" is what the transcripts show — "RCE against framework X out of the box" is not claimed |
| C | Race timing on a real (non-loopback) network | documented, not reproduced here | 10/10 was measured in-container on loopback with a 1.5 s settle; a real network adds jitter and no timing sweep was done |
| D | A patched-build negative | documented, not reproduced here | impossible: no fixed PHP release exists yet |
poc/exploit_opt.py compact driver (single pass, --mode auto|direct|race)
poc/exploit_readable.py stage-by-stage documented driver (same flags + --verbose
and --shell-template, the contents the gadget writes)
lab/Dockerfile PHP 8.5.12 built from the pinned php-src tag, mod_php
lab/envs/*.ini the four deployment profiles (five containers)
lab/www/ vulnerable application tree (handler switch) + gadget class
lab/www-consistent/ negative control: a different index.php (no runtime switch),
copied over a copy of the full www/ tree by reproduce.sh --
the gadget class is present in the twin on purpose
lab/probe_inject.py POSTs the upload-progress poison for the byte-level check
(dump the record with the docker exec command shown in
lab/probe_consistent.py causality probe for the no-switch negative
lab/reproduce.sh builds the image, seeds per-profile docroots, starts all five
evidence/ 10 transcripts (one per captured claim, 1:1 with the table)
Transcript provenance: the transcripts are kept as captured, including their
leading `[b63]` capture headers. The only edits are visible edge masks —
private prefixes inverted to `~` inside `Command:`/`CWD:` header lines —
and raw output bodies are byte-untouched. Header paths use the producing
tool's internal workspace layout (`~/.b63/projects/CVE-2026-115560/03_exploit/…`);
the mapping onto this bundle is: `03_exploit/exploit_opt/exploit.py` →
`poc/exploit_opt.py`, `03_exploit/exploit_readable/exploit.py` →
`poc/exploit_readable.py`, `03_exploit/lab` → `lab/`, and the project root →
this repository's root. (A capture `CWD: ~/.b63` value is the producing tool's
workspace root — one level above the project paths — and not part of the bundle.)
The exploits act only against the recreated lab target pattern described above.
bash lab/reproduce.sh # build + five containers on 127.0.0.1:18090-18094
python3 poc/exploit_opt.py --url --cmd 'id; uname -a'
Expected output (a per-run random shell name and nonce; `sids` are fixed here
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Driver options: `--url` (required), `--cmd`, `--sid`, `--mode auto|direct|race`,
`--gadget`, `--prop-path`, `--prop-data`, `--shell`, `--docroot`, `--shell-url`.
A filesystem path is mapped to its URL through `--docroot` (never by basename),
and a path outside the docroot is refused unless `--shell-url` names the serving
URL. Exit codes: `0` executed (nonce-verified) · `1` completed, no execution ·
`2` usage error · `3` transport failure — transport failure is deliberately
Reproducibility notes (each step shown in the transcripts):
python3 probe_inject.py b63ev1 'b63' 'f|s:5:"pwned";' # writes record, dumps bytes
docker exec 115560-vuln od -c /var/lib/php/sessions/sess_b63ev1 | head
python3 probe_consistent.py 18090 18093 # causality receipt
Verbatim from the php.net advisory: **set `session.upload_progress.enabled=0`**
until the regular bugfix release. The advisory's own wording is that this
step is what "fully protect[s] from an unlikely RCE vector" — a phrase from the
advisory, quoted here. Applications that never change the serialize handler at
runtime can keep the feature enabled. Apply the setting in `php.ini`, an FPM
pool, or server-level configuration that takes effect before request parsing,
and restart PHP workers; verify with `php-fpm -i | grep upload_progress` or the
SAPI's `phpinfo()`. Applying it protects the session path of the 21 Oct
- Access logs with multipart POSTs to any endpoint whose request carries a
`PHP_SESSION_UPLOAD_PROGRESS` field, followed by an unexpected second request
on the same PHP session cookie within the same few seconds (the race).
- Session stores (`session.save_path`) containing records whose bytes do not
decode under the configured serializer, or that contain `|` bytes inside
- Unexpected file writes by PHP workers into the document root with names
matching `*.php` that the deployment did not create (the gadget's dropped
file); any `classes/`-style path suddenly served as PHP.
- Source-level grep markers: in the pinned 8.5.12 tree the relevant symbols
live in `ext/session/session.c` (upload-progress update/flush paths) and
`main/rfc1867.c` (the pre-init hook); a build of PHP with that reference
tree allows diffing the session serializer write paths against a fixed
`bash lab/reproduce.sh` performs the recipe end to end: one source-built image
(clones `php/php-src` at tag `php-8.5.12`, Apache prefork + mod_php, needs
network egress for the build), then five containers that differ only in
configuration or application code, each with its own writable docroot
(`vuln` 18090, `cleanup1` 18091, `noupload` 18092, `consistent` 18093,
`serialize` 18094). `PORT_BASE`/`NAME_PREFIX` environment overrides exist so the
lab can run alongside another copy of itself. Teardown commands are printed at
the end of the run. Requirements: Docker, ~2 GB disk, ~1 GB RAM per container.
Every evidence transcript in this repository is reproducible from this lab plus
the shipped drivers and probes; transcript headers quote the exact commands.
This material exists for defensive research, detection engineering and
authorized testing of a locally recreated environment. The vulnerable pattern
is a specific application configuration; do not point the drivers at systems
you do not own or do not have written permission to test. The responsible
action for operators running affected applications is the mitigation above,
- php.net news archive (23 Oct 2026), "Informational advisory: Session upload
progress" — the incident's primary source:
- CVE-2026-109619 advisory, GHSA-6wmf-4rv5-p3rp (php/php-src) — the 21 Oct
advisory whose mitigation text this CVE shares
- CERT-EU advisory 2026-018 — independent summary of the three PHP issues
- php-src tag `php-8.5.12` (the tree the lab builds; the CVE's fix lands in the
- phpggc — reference for gadget-shape classification (the lab's model gadget
is a file-writing-destructor class, the simplest shape of that family)
The PoC, lab, verification transcripts and writeup in this repository were
produced with the b63 exploit-foundry tooling as part of its internal
capability-validation program: the exploit drivers are Python 3 (stdlib only),
chosen with a recorded language rationale; the verification set was
independently re-run by an adversarial review subagent across five review
rounds against a checksum manifest of the reviewed bytes; both exploit
variants and all four negative controls were re-executed against the lab
shipped in this repository immediately before publication. The files here are
the reviewed form of the delivered bundle; verification tiers are separated
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
