#!/usr/bin/env python3 """Probe for undocumented/hidden commands on any embedded console. Auto-discovers the device's prompt character, error string, and submenu structure, then sends candidate command names and flags anything that produces a non-error response. Works with any prompt-based firmware console: Winegard Trav'ler / G2, U-Boot, FreeRTOS shells, vendor debug consoles, etc. Usage: uv run scripts/hidden_menu_probe.py --port /dev/ttyUSB2 --baud 115200 uv run scripts/hidden_menu_probe.py --deep uv run scripts/hidden_menu_probe.py --submenu mot uv run scripts/hidden_menu_probe.py --wordlist scripts/wordlists/winegard.txt uv run scripts/hidden_menu_probe.py --prompt "U-Boot>" --error "Unknown command" """ from __future__ import annotations import argparse import json import re import string import sys import time from dataclasses import dataclass, field from pathlib import Path import serial # pyright: ignore[reportMissingImports] # --------------------------------------------------------------------------- # Device profile — populated by auto-discovery or CLI overrides # --------------------------------------------------------------------------- @dataclass class DeviceProfile: """Everything we know (or detected) about the attached console.""" port: str = "/dev/ttyUSB0" baud: int = 115200 root_prompt: str = "" # e.g. "TRK>" prompts: list[str] = field(default_factory=list) # all known prompts error_string: str = "" # e.g. "Invalid command." known_commands: set[str] = field(default_factory=set) # from help output submenus: list[str] = field(default_factory=list) # detected submenu names exit_cmd: str = "q" line_ending: str = "\r" # --------------------------------------------------------------------------- # Serial I/O # --------------------------------------------------------------------------- def send_cmd( ser: serial.Serial, cmd: str, profile: DeviceProfile, timeout: float = 1.0, ) -> str: """Send *cmd* + line-ending, read until a known prompt or timeout.""" ser.reset_input_buffer() ser.write(f"{cmd}{profile.line_ending}".encode("ascii", errors="replace")) ser.timeout = timeout buf = bytearray() deadline = time.monotonic() + timeout while time.monotonic() < deadline: chunk = ser.read(4096) if chunk: buf.extend(chunk) text = buf.decode("utf-8", errors="replace") if text.rstrip().endswith(">"): break elif buf: text = buf.decode("utf-8", errors="replace") if text.rstrip().endswith(">"): break return buf.decode("utf-8", errors="replace") # --------------------------------------------------------------------------- # Auto-discovery # --------------------------------------------------------------------------- _PROMPT_RE = re.compile(r"(\S+[>$#])\s*$") def detect_prompt(ser: serial.Serial, profile: DeviceProfile) -> str | None: """Send a bare line-ending and extract the prompt from the response.""" ser.reset_input_buffer() ser.write(profile.line_ending.encode("ascii")) ser.timeout = 2.0 buf = bytearray() deadline = time.monotonic() + 2.0 while time.monotonic() < deadline: chunk = ser.read(4096) if chunk: buf.extend(chunk) text = buf.decode("utf-8", errors="replace") tail = text.rstrip() if tail.endswith((">", "#", "$")): break elif buf: break text = buf.decode("utf-8", errors="replace").strip() if not text: return None # Take the last line, look for a prompt-like token last_line = text.split("\n")[-1].strip() m = _PROMPT_RE.search(last_line) return m.group(1) if m else (last_line if last_line else None) def detect_error_string(ser: serial.Serial, profile: DeviceProfile) -> str | None: """Send a garbage command and extract the error message template.""" resp = send_cmd(ser, "__xyzzy_probe__", profile, timeout=1.0) # Strip the echo of our command and any prompt lines = resp.replace("__xyzzy_probe__", "").strip().split("\n") # Filter out lines that are just prompts content_lines = [] for line in lines: stripped = line.strip() if not stripped: continue # Skip lines that are purely a prompt token if _PROMPT_RE.fullmatch(stripped): continue # Strip trailing prompt from content lines stripped = _PROMPT_RE.sub("", stripped).strip() if stripped: content_lines.append(stripped) if content_lines: # The error message is typically a single line return content_lines[0].strip() return None def parse_help_output( help_text: str, profile: DeviceProfile, ) -> tuple[set[str], list[str]]: """Parse help output for command names and submenu hints. Returns (known_commands, submenu_names). """ commands: set[str] = set() submenus: list[str] = [] # Common help-output patterns: # "command - description" # "command Description text" # " command" # "Enter - Description Menu" (Winegard G2 style) # "Enter name sub-menu" cmd_dash_re = re.compile(r"^\s*(\w[\w.]*)\s+[-—]\s+") cmd_spaces_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s{2,}") bare_cmd_re = re.compile(r"^\s{0,4}(\w[\w.]*)\s*$") # Angle-bracket format: "Enter - Description" bracket_re = re.compile(r"<(\w[\w.]*)>") enter_re = re.compile(r"[Ee]nter\s+?", re.IGNORECASE) menu_re = re.compile(r"(\w+)\s+[Mm]enu") submenu_re = re.compile(r"(\w+)\s+[Ss]ub-?[Mm]enu") for line in help_text.split("\n"): stripped = line.strip() if not stripped: continue # Skip lines that are just prompts if _PROMPT_RE.fullmatch(stripped): continue # Try angle-bracket commands first (e.g. "Enter - ...") bracket_match = bracket_re.search(stripped) if bracket_match: cmd_name = bracket_match.group(1).lower() if len(cmd_name) <= 20: commands.add(cmd_name) else: # Fall back to generic patterns for pat in (cmd_dash_re, cmd_spaces_re, bare_cmd_re): m = pat.match(stripped) if m: cmd_name = m.group(1).lower() if len(cmd_name) <= 20: commands.add(cmd_name) break # Look for submenu hints — prefer bracketed names enter_match = enter_re.search(stripped) if enter_match: sub = enter_match.group(1).lower() if sub not in submenus and len(sub) <= 20: submenus.append(sub) else: for pat in (submenu_re, menu_re): m = pat.search(stripped) if m: sub = m.group(1).lower() if sub not in submenus and len(sub) <= 20: submenus.append(sub) break # Any command that appears in the help output might also be a submenu # if the help text mentions "Enter " or " Menu". # Commands themselves that are single-word and also in submenus get both. return commands, submenus def auto_discover(ser: serial.Serial, profile: DeviceProfile) -> DeviceProfile: """Run the discovery sequence and populate the profile.""" # Step 1: Detect root prompt print("Phase 1: Auto-discovering device console...\n") prompt = detect_prompt(ser, profile) if prompt: profile.root_prompt = prompt profile.prompts = [prompt] print(f" Root prompt: {prompt}") else: print(" WARNING: Could not detect root prompt.") print(" Use --prompt to specify manually.") # Step 2: Detect error string err = detect_error_string(ser, profile) if err: profile.error_string = err print(f' Error string: "{err}"') else: print(" WARNING: Could not detect error string.") print(" Use --error to specify manually.") # Step 3: Parse help output print(" Sending help command: ?") help_resp = send_cmd(ser, "?", profile, timeout=2.0) commands, submenus = parse_help_output(help_resp, profile) if commands: profile.known_commands = commands print(f" Known commands ({len(commands)}): {', '.join(sorted(commands))}") else: print(" No commands parsed from help output.") if submenus: profile.submenus = submenus print(f" Detected submenus ({len(submenus)}): {', '.join(submenus)}") else: # Fall back: any known command that looks like a submenu name # (we'll discover actual submenus during probing) print(" No submenus detected from help output.") # Build prompt list from submenus for sub in profile.submenus: sub_prompt = f"{sub.upper()}>" if sub_prompt not in profile.prompts: profile.prompts.append(sub_prompt) print() return profile # --------------------------------------------------------------------------- # Navigation # --------------------------------------------------------------------------- def navigate_to_root(ser: serial.Serial, profile: DeviceProfile) -> str: """Send exit command until we're at the root prompt. Sends a bare line-ending first to check if we're already at root, avoiding the case where 'q' at root level kills the shell entirely. """ # Check if we're already at root (bare CR won't kill anything) resp = send_cmd(ser, "", profile) if profile.root_prompt and profile.root_prompt in resp: return profile.root_prompt for _ in range(5): resp = send_cmd(ser, profile.exit_cmd, profile) if profile.root_prompt and profile.root_prompt in resp: return profile.root_prompt # If no root prompt set, check if the last line looks prompt-like last_line = resp.strip().split("\n")[-1].strip() m = _PROMPT_RE.search(last_line) if m: detected = m.group(1) if not profile.root_prompt: profile.root_prompt = detected if detected == profile.root_prompt: return detected # Last resort: bare line-ending resp = send_cmd(ser, "", profile) last_line = resp.strip().split("\n")[-1].strip() return last_line def enter_submenu(ser: serial.Serial, menu: str, profile: DeviceProfile) -> str: """Enter a submenu and return the prompt we land on.""" navigate_to_root(ser, profile) resp = send_cmd(ser, menu, profile) lines = resp.strip().split("\n") last = lines[-1].strip() if lines else "" # Register this prompt if new m = _PROMPT_RE.search(last) if m: new_prompt = m.group(1) if new_prompt not in profile.prompts: profile.prompts.append(new_prompt) return new_prompt return last # --------------------------------------------------------------------------- # Probing # --------------------------------------------------------------------------- def clean_response( resp: str, cmd: str, profile: DeviceProfile, ) -> str: """Strip echo, prompts, and whitespace from a response.""" clean = resp # Remove echo of the command (with various line-ending combos) for suffix in ("\r\n", "\r", "\n", ""): clean = clean.replace(f"{cmd}{suffix}", "", 1) clean = clean.strip() # Remove any known prompt tokens for p in profile.prompts: clean = clean.replace(p, "") # Also strip any bare prompt-like token at the very end clean = _PROMPT_RE.sub("", clean) return clean.strip() def probe_commands( ser: serial.Serial, candidates: list[str], prompt: str, label: str, profile: DeviceProfile, timeout: float = 0.5, ) -> list[tuple[str, str]]: """Probe candidates at the current menu level. Returns (cmd, preview) hits.""" hits = [] total = len(candidates) for i, cmd in enumerate(candidates): if (i + 1) % 50 == 0: print(f" [{label}] Progress: {i + 1}/{total}...", flush=True) resp = send_cmd(ser, cmd, profile, timeout=timeout) clean = clean_response(resp, cmd, profile) # A "hit" is anything that isn't the error string and has content is_error = profile.error_string and profile.error_string in resp if not is_error and clean: preview = clean[:100].replace("\r\n", " | ").replace("\n", " | ") hits.append((cmd, preview)) print(f" *** HIT: '{cmd}' -> {preview}", flush=True) # If the command kicked us out of the submenu, recover if "Terminating shell" in resp or "exiting" in resp.lower(): if profile.root_prompt and prompt != profile.root_prompt: print(f" ('{cmd}' exited submenu, re-entering...)") menu_name = prompt.replace(">", "").strip().lower() enter_submenu(ser, menu_name, profile) else: # At root — shell may have died. Wait for firmware # to respawn it, then verify with a bare CR. print(f" ('{cmd}' terminated shell, waiting for restart...)") time.sleep(1.0) check = send_cmd(ser, "", profile) if profile.root_prompt and profile.root_prompt not in check: print( " WARNING: Shell did not restart. Remaining " "results may be incomplete.", flush=True, ) return hits # --------------------------------------------------------------------------- # Candidate generation # --------------------------------------------------------------------------- def generate_candidates( blocklist: set[str], wordlist_paths: list[Path] | None = None, ) -> list[str]: """Build the candidate command list. Includes generic embedded debug commands + single chars + two-letter combos. Merges in any external wordlist files. Applies blocklist last. """ candidates: list[str] = [] # Single characters candidates.extend(list(string.ascii_lowercase)) candidates.extend(list(string.ascii_uppercase)) candidates.extend(list(string.digits)) # Generic embedded debug commands (no device-specific words) generic = [ # Memory access "md", "mw", "mm", "mr", "mem", "peek", "poke", "rd", "wr", "read", "write", "dump", "load", "save", "md.b", "md.w", "md.l", "x", "xx", "xd", # Flash "flash", "fl", "erase", "program", "verify", "protect", "flinfo", "fldump", "flashdump", # Boot / system "boot", "reboot", "reset", "go", "run", "exec", "jump", "bootd", "bootm", "bootp", "version", "ver", "info", "about", "sysinfo", "uptime", "date", "time", "clk", "clock", # Debug "debug", "dbg", "trace", "log", "print", "echo", "test", "diag", "selftest", "bist", "bench", "assert", "crash", "fault", "panic", # Shell / OS "sh", "shell", "cmd", "command", "cli", "task", "tasks", "ps", "top", "threads", "kill", "suspend", "resume", "heap", "stack", "free", "malloc", "meminfo", "cpu", "cpuinfo", "temp", "temperature", # Network / comms "ping", "net", "ifconfig", "ip", "mac", "uart", "serial", "spi", "i2c", "can", # Service / factory "factory", "service", "mfg", "production", "prod", "cal", "calibrate", "calibration", "config", "cfg", "setup", "settings", "hidden", "secret", "admin", "su", "root", "login", "password", "passwd", "auth", "unlock", # Update "update", "upgrade", "firmware", "fw", "ota", "download", "upload", "xmodem", "ymodem", "zmodem", "tftp", "ftp", # Generic hardware "sw", "hw", "id", "sn", "help", "man", "usage", # Two-letter combos "bl", "bt", "db", "dm", "dp", "ds", "dt", "eb", "ed", "ee", "ef", "em", "en", "ep", "er", "es", "et", "fa", "fb", "fc", "fd", "fe", "ff", "fg", "fh", "fi", "fj", "ga", "gb", "gc", "gd", "ge", "gf", "gg", "gh", "gi", "gj", "ha", "hb", "hc", "hd", "he", "hf", "hg", "hh", "hi", "hj", "ia", "ib", "ic", "io", "ir", "ka", "kb", "kc", "kd", "ke", "la", "lb", "lc", "ld", "le", "lf", "lg", "lh", "li", "lj", "ma", "mb", "mc", "me", "mf", "mg", "mh", "mi", "mj", "na", "nb", "nc", "nd", "ne", "nf", "ng", "nh", "ni", "nj", "oa", "ob", "oc", "od", "oe", "of", "og", "oh", "oi", "oj", "pa", "pb", "pc", "pd", "pe", "pf", "pg", "ph", "pi", "pj", "ra", "rb", "rc", "re", "rf", "rg", "rh", "ri", "rj", "sa", "sb", "sc", "sd", "se", "sf", "sg", "si", "sj", "ta", "tb", "tc", "td", "te", "tf", "tg", "th", "ti", "tj", "ua", "ub", "uc", "ud", "ue", "uf", "ug", "uh", "ui", "uj", "va", "vb", "vc", "vd", "ve", "vf", "vg", "vh", "vi", "vj", "wa", "wb", "wc", "wd", "we", "wf", "wg", "wh", "wi", "wj", "za", "zb", "zc", "zd", "ze", "zf", ] candidates.extend(generic) # Merge external wordlists if wordlist_paths: for wl_path in wordlist_paths: try: text = wl_path.read_text() for line in text.split("\n"): word = line.strip() # Skip blanks and comments if word and not word.startswith("#"): candidates.append(word) except OSError as exc: print( f"WARNING: Could not read wordlist {wl_path}: {exc}", file=sys.stderr, ) # Deduplicate preserving order seen: set[str] = set() unique: list[str] = [] for c in candidates: if c not in seen: seen.add(c) unique.append(c) # Apply blocklist unique = [c for c in unique if c not in blocklist] return unique # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- def write_json_report( path: Path, profile: DeviceProfile, results: dict[str, list[tuple[str, str]]], ) -> None: """Write machine-readable JSON probe report.""" report: dict = { "device": {"port": profile.port, "baud": profile.baud}, "detected": { "root_prompt": profile.root_prompt, "error_string": profile.error_string, "known_commands": sorted(profile.known_commands), "submenus": profile.submenus, }, "results": {}, } for label, hits in results.items(): known_hits = [ (cmd, resp) for cmd, resp in hits if cmd.lower() in profile.known_commands ] unknown_hits = [ (cmd, resp) for cmd, resp in hits if cmd.lower() not in profile.known_commands ] report["results"][label] = { "total_hits": len(hits), "known": len(known_hits), "unknown": len(unknown_hits), "hits": [{"cmd": cmd, "response": resp} for cmd, resp in hits], } path.write_text(json.dumps(report, indent=2) + "\n") print(f"\nJSON report written to {path}") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- LINE_ENDINGS = {"cr": "\r", "lf": "\n", "crlf": "\r\n"} def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Probe for hidden commands on an embedded console", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ examples: %(prog)s --port /dev/ttyUSB2 --baud 115200 %(prog)s --deep %(prog)s --submenu mot %(prog)s --wordlist scripts/wordlists/winegard.txt %(prog)s --prompt "U-Boot>" --error "Unknown command" """, ) conn = parser.add_argument_group("connection") conn.add_argument( "--port", default="/dev/ttyUSB0", help="Serial port (default: /dev/ttyUSB0)" ) conn.add_argument( "--baud", type=int, default=115200, help="Baud rate (default: 115200)" ) conn.add_argument( "--line-ending", choices=LINE_ENDINGS, default="cr", help="Line ending to send (default: cr)", ) disc = parser.add_argument_group("discovery overrides") disc.add_argument( "--prompt", default=None, help="Override auto-detected root prompt" ) disc.add_argument( "--error", default=None, help="Override auto-detected error string" ) disc.add_argument( "--help-cmd", default="?", help="Command to request help (default: ?)" ) disc.add_argument( "--exit-cmd", default="q", help="Command to exit submenu (default: q)" ) probe = parser.add_argument_group("probing") probe.add_argument( "--deep", action="store_true", help="Probe all discovered submenus" ) probe.add_argument( "--submenu", type=str, default=None, help="Probe a single submenu by name" ) probe.add_argument( "--timeout", type=float, default=0.5, help="Per-command timeout in seconds (default: 0.5)", ) probe.add_argument( "--blocklist", default="reboot,stow,def,q,Q", help="Comma-separated commands to never send (default: reboot,stow,def,q,Q)", ) probe.add_argument( "--wordlist", action="append", default=None, metavar="FILE", help="Extra candidate words file (one per line, repeatable)", ) output = parser.add_argument_group("output") output.add_argument( "--json", metavar="FILE", default=None, help="Write results as JSON to FILE" ) return parser.parse_args() def main() -> None: args = parse_args() # Build profile from CLI args profile = DeviceProfile( port=args.port, baud=args.baud, line_ending=LINE_ENDINGS[args.line_ending], exit_cmd=args.exit_cmd, ) # Apply explicit overrides (before auto-discovery) if args.prompt: profile.root_prompt = args.prompt profile.prompts = [args.prompt] if args.error: profile.error_string = args.error # Build candidate list blocklist = {w.strip() for w in args.blocklist.split(",") if w.strip()} wordlist_paths = [Path(p) for p in args.wordlist] if args.wordlist else None candidates = generate_candidates(blocklist, wordlist_paths) print(f"Generated {len(candidates)} candidate commands\n") # Open serial ser = serial.Serial(profile.port, profile.baud, timeout=1) ser.reset_input_buffer() # Collect results for JSON report all_results: dict[str, list[tuple[str, str]]] = {} try: # Auto-discovery (or validate overrides) if not profile.root_prompt or not profile.error_string: profile = auto_discover(ser, profile) else: print("Using overrides:") print(f" Root prompt: {profile.root_prompt}") print(f' Error string: "{profile.error_string}"') # Still run help to discover known commands and submenus print(f" Sending help command: {args.help_cmd}") help_resp = send_cmd(ser, args.help_cmd, profile, timeout=2.0) commands, submenus = parse_help_output(help_resp, profile) profile.known_commands = commands if not profile.submenus: profile.submenus = submenus for sub in profile.submenus: sub_prompt = f"{sub.upper()}>" if sub_prompt not in profile.prompts: profile.prompts.append(sub_prompt) if commands: print( f" Known commands ({len(commands)}): {', '.join(sorted(commands))}" ) if submenus: print(f" Submenus ({len(submenus)}): {', '.join(submenus)}") print() if not profile.root_prompt: print( "ERROR: Could not determine root prompt. Use --prompt to specify.", file=sys.stderr, ) sys.exit(1) # Navigate to root prompt = navigate_to_root(ser, profile) print(f"Starting at: {prompt}\n") # Probe root menu root_label = profile.root_prompt.rstrip(">$#").strip() or "ROOT" print( f"=== Probing {profile.root_prompt} (root) " f"-- {len(candidates)} candidates ===\n" ) root_hits = probe_commands( ser, candidates, profile.root_prompt, root_label, profile, timeout=args.timeout, ) all_results[root_label] = root_hits # Classify hits unknown_hits = [ (cmd, resp) for cmd, resp in root_hits if cmd.lower() not in profile.known_commands ] known_count = len(root_hits) - len(unknown_hits) print("\n--- Root Results ---") print(f" Total hits: {len(root_hits)}") print(f" Known commands: {known_count}") print(f" UNKNOWN commands: {len(unknown_hits)}") for cmd, resp in unknown_hits: print(f" '{cmd}' -> {resp}") # Determine which submenus to probe submenus_to_probe: list[str] = [] if args.submenu: submenus_to_probe.append(args.submenu) elif args.deep: submenus_to_probe = list(profile.submenus) for menu in submenus_to_probe: label = menu.upper() print(f"\n=== Probing {label} submenu ===\n") sub_prompt = enter_submenu(ser, menu, profile) print(f" Prompt: {sub_prompt}") sub_hits = probe_commands( ser, candidates, sub_prompt, label, profile, timeout=args.timeout, ) all_results[label] = sub_hits print(f"\n--- {label} Results: {len(sub_hits)} hits ---") for cmd, resp in sub_hits: print(f" '{cmd}' -> {resp}") navigate_to_root(ser, profile) # JSON report if args.json: write_json_report(Path(args.json), profile, all_results) except KeyboardInterrupt: print("\n\nInterrupted.") finally: ser.close() print("\nPort closed.") if __name__ == "__main__": main()