Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus • October 23, 2026

# 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