PoC Archive PoC Archive
Critical CVE-2026-48909 (GHSA-gf8c-xmwj-whrh) patched

SP LMS PHP Object Injection → Unauthenticated RCE (CVE-2026-48909)

by Amin İsayev / Proxima Cyber Security (Is4yev) · 2026-07-05

CVSS 9.5/10
Severity
Critical
CVE
CVE-2026-48909 (GHSA-gf8c-xmwj-whrh)
Category
web
Affected product
JoomShaper SP LMS (com_splms) Joomla Learning Management System extension
Affected versions
1.0.0 - 4.1.3 (fixed in 4.1.4); RCE additionally requires Joomla core < 5.2.2
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherAmin İsayev / Proxima Cyber Security (Is4yev)
CVE / AdvisoryCVE-2026-48909 (GHSA-gf8c-xmwj-whrh)
Categoryweb
SeverityCritical
CVSS Score9.5 (CVSS 4.0: AV:N/AC:L/AT:P/PR:N/UI:N)
StatusPoC
Tagsjoomla, sp-lms, com_splms, php-object-injection, cwe-502, deserialization, unauthenticated-rce, gadget-chain
RelatedN/A

Affected Target

FieldValue
Software / SystemJoomShaper SP LMS (com_splms) Joomla Learning Management System extension
Versions Affected1.0.0 - 4.1.3 (fixed in 4.1.4); RCE additionally requires Joomla core < 5.2.2
Language / PlatformPHP / Joomla CMS, PoC written in Python 3.10+
Authentication RequiredNo
Network Access RequiredYes

Summary

SP LMS’s cart model (components/com_splms/models/cart.php) reads the lmsOrders cookie, base64-decodes it, and passes the result directly to PHP’s unserialize() with no validation, giving an unauthenticated attacker full control over the deserialized object graph. On Joomla versions before 5.2.2, this chains into Joomla’s FormattedtextLogger gadget: the object’s __destruct() writes attacker-controlled bytes to an attacker-chosen file path via File::write(), allowing a PHP webshell to be dropped on disk. The repo ships a detection-only script (CVE-2026-48909.py) that fingerprints the vulnerable unserialize() behavior via response-status/timing differentials, and a full exploit script (CVE-2026-48909_exploit.py) that builds a filter-safe serialized payload and writes a webshell to a specified server path.


Vulnerability Details

Root Cause

components/com_splms/models/cart.php (line ~28):

1
2
3
4
$cookie  = Factory::getApplication()->input->cookie;
$raw     = $cookie->get('lmsOrders', base64_encode(serialize(array())));
$decoded = base64_decode($raw);
$cartItems = unserialize($decoded);   // untrusted user input, no validation

Joomla’s built-in FormattedtextLogger class provides a usable gadget on Joomla < 5.2.2: its __destruct() calls initFile()File::write($path, $format), allowing arbitrary file writes with attacker-controlled content (a PHP webshell). Joomla 5.2.2 patched FormattedtextLogger::__wakeup() to close the gadget chain, but the underlying PHP Object Injection in com_splms remains regardless of Joomla core version.

Attack Vector

  1. Attacker crafts a base64-encoded, PHP-serialized Joomla\CMS\Log\Logger\FormattedtextLogger object whose path property points at a web-accessible, PHP-writable file and whose format property contains hex-encoded PHP webshell code (to survive Joomla’s cmd cookie input filter, which strips +, /, and =).
  2. Attacker sets this payload as the lmsOrders cookie and sends a request to index.php?option=com_splms&view=cart.
  3. unserialize() instantiates the gadget object; at the end of the request, PHP’s garbage collector invokes __destruct(), triggering File::write() and dropping the webshell at the chosen path.
  4. Attacker requests the written file directly (e.g. /tmp/x.php?c=id) to execute arbitrary OS commands.

Impact

Unauthenticated remote code execution on any Joomla site running a vulnerable SP LMS version and a pre-5.2.2 Joomla core, leading to full server compromise (webshell access, arbitrary command execution).


Environment / Lab Setup

Target:   Joomla CMS < 5.2.2 with JoomShaper SP LMS (com_splms) <= 4.1.3 installed, cart endpoint reachable
Attacker: Python 3.10+ with `requests`, network access to the target's index.php?option=com_splms&view=cart endpoint

Proof of Concept

PoC Script

See CVE-2026-48909.py (detection only, no code execution) and CVE-2026-48909_exploit.py (full RCE exploit) in this folder. poc.png is a screenshot from the original author showing an active shell.

1
2
3
python3 CVE-2026-48909.py https://target.com

python3 CVE-2026-48909_exploit.py https://target.com /var/www/html/tmp/x.php

The detection script sends a benign cookie and a serialized-stdClass probe cookie to the cart endpoint and compares status codes/response bodies/timing to infer whether unserialize() is being called. The exploit script builds a filter-safe base64 payload encoding a FormattedtextLogger gadget object, requests the cart page to trigger the deferred file write on __destruct(), then requests the written path with ?c=id to confirm shell access.


Detection & Indicators of Compromise

grep 'option=com_splms&view=cart' /var/log/apache2/access.log | grep -i 'Cookie.*lmsOrders='

find /var/www/html -newer /var/www/html/configuration.php -name '*.php' -path '*tmp*'

Signs of compromise:

  • Requests to index.php?option=com_splms&view=cart carrying an unusually long, non-standard-looking lmsOrders cookie value
  • New, unexpected .php files appearing in writable directories (tmp, cache, uploads) shortly after such a request
  • Outbound requests to those newly-created .php files with a ?c= (or similar) command parameter

Remediation

ActionDetail
Primary fixUpdate SP LMS to >= 4.1.4 (removes the raw unserialize() call); also update Joomla core to >= 5.2.2 to eliminate the known FormattedtextLogger gadget chain
Interim mitigationDo not deserialize user-controlled cookie data; if immediate patching isn’t possible, block/strip the lmsOrders cookie at the WAF/reverse-proxy layer

References


Notes

Mirrored from https://github.com/Is4yev/CVE-2026-48909 on 2026-07-05.

CVE-2026-48909.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#!/usr/bin/env python3
"""
CVE-2026-48909 — SP LMS (com_splms) PHP Object Injection Detection
Affected : JoomShaper SP LMS <= 4.1.3
Fixed    : JoomShaper SP LMS >= 4.1.4
Author   : Amin İsayev / Proxima Cyber Security

Detects whether the lmsOrders cookie is passed to unserialize() without
validation. This script only identifies the vulnerability — it does NOT
exploit it and does NOT execute any code on the target.

Usage:
    python3 CVE-2026-48909.py https://target.com
"""

import sys
import time
import base64
import requests
import urllib3
urllib3.disable_warnings()

# com_splms model: $cartItems = unserialize(base64_decode($cookie))
#                  $cartItems = (!is_array($cartItems) && !$cartItems) ? [] : $cartItems;
#                  if(count($cartItems)) { ... }
#
# PROBE  : base64(serialize(stdClass{}))
#   Vulnerable : unserialize() → stdClass → count(stdClass) → PHP 8 TypeError → HTTP 500
#   Patched    : cookie rejected/sanitized → empty array → count([])=0 → HTTP 200
#
# BENIGN : base64("not_php_serialized_data")
#   Any server: unserialize() → false → cartItems=[] → HTTP 200
#
# Signal: probe=500 AND benign=200  →  VULNERABLE
PROBE_SERIALIZED = base64.b64encode(b'O:8:"stdClass":0:{}').decode()
PROBE_BENIGN     = base64.b64encode(b'not_php_serialized_data').decode()

CART_PATH = "/index.php?option=com_splms&view=cart"

TIMEOUT = 10


def detect(base_url: str) -> tuple[bool, str]:
    url = base_url.rstrip("/") + CART_PATH
    session = requests.Session()
    session.verify = False
    session.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.5",
    })

    try:
        # Step 0: baseline — no cookie at all, check if endpoint is healthy
        r_base = session.get(url, timeout=TIMEOUT)
        if r_base.status_code == 404:
            return False, f"SP LMS cart endpoint not found (HTTP 404) — com_splms may not be installed"
        if r_base.status_code >= 500:
            return False, (
                f"Endpoint already returns HTTP {r_base.status_code} without any cookie "
                f"— server is broken/misconfigured, cannot assess vulnerability"
            )
        if r_base.status_code not in (200, 301, 302, 403):
            return False, f"Unexpected HTTP {r_base.status_code} without cookie — skipping"

        # Step 1: benign cookie — base64 of random non-PHP string
        r_benign = session.get(url, cookies={"lmsOrders": PROBE_BENIGN}, timeout=TIMEOUT)

        # Step 2: serialized stdClass probe
        t0 = time.time()
        r_probe = session.get(url, cookies={"lmsOrders": PROBE_SERIALIZED}, timeout=TIMEOUT)
        elapsed = time.time() - t0

    except requests.RequestException as e:
        return False, f"Connection error: {e}"

    # Signal 1 (primary): stdClass → count() TypeError → HTTP 500 (PHP 8)
    # Requires benign=200 AND no-cookie=200 to avoid false positives from Mod_Security/WAF
    if r_probe.status_code == 500 and r_benign.status_code == 200:
        return True, (
            f"HTTP 500 on probe vs 200 on benign "
            f"— unserialize() called on cookie (count(stdClass) TypeError)"
        )

    # Signal 2: PHP error text in probe response
    php_indicators = ["PHP Warning", "PHP Fatal error", "TypeError", "unserialize()"]
    for ind in php_indicators:
        if ind in r_probe.text and ind not in r_benign.text:
            return True, f"PHP error in probe response: '{ind}'"

    # Signal 3: 403 vs 200
    if r_probe.status_code == 403 and r_benign.status_code == 200:
        return True, f"HTTP 403 on probe vs 200 on benign — deserialization side-effect"

    # Signal 4: response body differs significantly between benign and probe
    len_diff = abs(len(r_probe.text) - len(r_benign.text))
    if len_diff > 500 and r_probe.status_code == r_benign.status_code:
        return True, (
            f"Response body differs {len_diff} bytes (probe vs benign) "
            f"— unserialize() processing the cookie"
        )

    # Signal 5: slow response on probe only
    if elapsed > 5.0 and r_benign.status_code == 200:
        return True, f"Abnormal response time {elapsed:.2f}s on probe — deserialization delay"

    return False, (
        f"No vulnerability indicators "
        f"(base={r_base.status_code}, benign={r_benign.status_code}, probe={r_probe.status_code})"
    )


def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <target_url>")
        print(f"  Example: {sys.argv[0]} https://example.com")
        sys.exit(1)

    target = sys.argv[1]
    print(f"[*] Target : {target}")
    print(f"[*] Path   : {CART_PATH}")
    print(f"[*] Probe  : lmsOrders={PROBE_SERIALIZED}")
    print()

    vulnerable, reason = detect(target)

    if vulnerable:
        print(f"[VULNERABLE] {reason}")
        print("[!] Update to SP LMS >= 4.1.4 immediately.")
    else:
        print(f"[NOT VULNERABLE / INCONCLUSIVE] {reason}")

    sys.exit(0 if not vulnerable else 1)


if __name__ == "__main__":
    main()