Rewrote hidden_menu_probe.py from Winegard-hardcoded to auto-discovering: detects prompt, error string, and submenu structure from any firmware console. Extracted Winegard-specific candidate words to scripts/wordlists/winegard.txt. Deep probe of all 12 G2 submenus discovered commands across A3981 (driver diagnostics), ADC (RSSI monitoring + position sweep), DVB (extended help via man, transponder selection), EEPROM (read/write), GPIO (pin R/W), LATLON (calculator), MOT (azscan, sw), PEAK (EchoStar switch), and STEP (raw stepper control). NVS submenu generates false positives — treats any input as sequential index reads. Safety: added q/Q to default blocklist, bare-CR check before navigate_to_root to prevent accidental shell termination between submenus.
1044 lines
30 KiB
Python
1044 lines
30 KiB
Python
#!/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 <name> - 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 <command> - Description"
|
|
bracket_re = re.compile(r"<(\w[\w.]*)>")
|
|
enter_re = re.compile(r"[Ee]nter\s+<?(\w+)>?", 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 <a3981> - ...")
|
|
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 <cmd>" or "<cmd> 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()
|