diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index fb4c05b3..3401c063 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -1747,15 +1747,25 @@ def cmd_export(args) -> int: with _with_client(args) as client: pattern = re.compile(args.filter) if args.filter else None functions = [] + skipped_small = 0 for addr, func in sorted(client.functions.items(), key=lambda kv: kv[0]): name = getattr(func, "name", None) or "" if pattern and not pattern.search(name) and not pattern.search(hex(addr)): continue + size = getattr(func, "size", 0) or 0 + # Import stubs dominate the function count of any dynamically + # linked binary and carry no information -- on a sample crackme, + # 93 of 117 functions were PLT thunks, and they accounted for 45% + # of the exported pseudocode. Skipping them is what makes the + # output small enough to read rather than only grep. + if args.min_size and size < args.min_size: + skipped_small += 1 + continue functions.append({ "addr": addr, "addr_hex": hex(addr), "name": name, - "size": getattr(func, "size", 0) or 0, + "size": size, }) if args.limit and len(functions) > args.limit: @@ -1819,6 +1829,7 @@ def cmd_export(args) -> int: "failed": sorted(failed), "failed_count": len(failed), "string_count": len(strings), + "skipped_below_min_size": skipped_small, } (out_dir / "export.json").write_text(json.dumps(manifest, indent=2) + "\n") @@ -2771,6 +2782,11 @@ def build_parser() -> argparse.ArgumentParser: help="Only export functions whose name or hex address matches this regex.") p_export.add_argument("--limit", type=int, default=2000, help="Cap on functions exported (default 2000; 0 for no cap).") + p_export.add_argument("--min-size", type=int, default=0, + help="Skip functions smaller than this many bytes. " + "Import stubs/PLT thunks are usually the bulk of " + "a function list and carry no information; " + "--min-size 16 drops them. Default 0 (export all).") p_export.add_argument("--batch-size", type=int, default=25, help="Functions decompiled per round-trip (default 25).") p_export.add_argument("--no-string-xrefs", action="store_true", diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 77daad82..7b9dc609 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -60,15 +60,36 @@ narrowing to an interesting method. **Always prefer IDA Pro when it's available** (`--backend ida`) — it generally produces the cleanest decompilation and the most accurate type recovery. If IDA fails to load the binary (missing license, unsupported -file type, decompiler error), fall back to `--backend ghidra`, then -`--backend angr` as a last resort. +file type, decompiler error), fall back to `--backend binja` if it is +licensed here, then `--backend ghidra`, then `--backend angr` as a last +resort. -**Always start with `list_functions` and `list_strings`** — the same binary +**On a small or medium binary, `export` first and grep the result.** One +command decompiles everything to a directory, so orienting becomes a +filesystem problem instead of dozens of round-trips: + +```bash +decompiler load ./target --backend ida +decompiler export --out ./dump --min-size 16 # skip PLT thunks; see below +grep -rl 'flag\|serial\|password' ./dump/pseudo # which functions matter +``` + +That writes `functions.json`, `strings.json` (with xrefs), and one +`pseudo/_.c` per function. On a 117-function binary it takes +about 30 seconds and replaces the whole `list_functions` → `decompile` → +`decompile` → … loop below. `--min-size 16` drops import stubs, which are +typically ~80% of the function count and half the output bytes. + +Prefer the interrogative commands when `export` is a bad fit: a very large +binary (thousands of functions — use `--filter`/`--limit` or skip it), or +when you already know the one function you want. + +**Otherwise start with `list_functions` and `list_strings`** — the same binary can have the entry named `main` (angr), `FUN_00101c5c` (Ghidra), or `sub_101c5c` (IDA). Don't assume `main` exists. ```bash -decompiler load ./target --backend ida # prefer IDA; fall back to ghidra if it fails +decompiler load ./target --backend ida # prefer IDA; fall back to binja, then ghidra decompiler list_functions # enumerate every function — pick a real entry decompiler list_functions --filter 'main|auth' # or narrow by regex decompiler list_strings --filter 'flag|pass' # find interesting string constants @@ -81,8 +102,8 @@ initial analysis. Successful JSON output includes the persistent `log_path`. Typical first-hour workflow on a stripped binary: -1. `decompiler load ./bin --backend ida` (fall back to `--backend ghidra`, - then `--backend angr`, if IDA can't open the binary) +1. `decompiler load ./bin --backend ida` (fall back to `--backend binja`, + then `--backend ghidra`, then `--backend angr`, if IDA can't open it) 2. `decompiler list_functions` → note non-stub function names + sizes 3. `decompiler list_strings` → look for error messages, user prompts, format strings — they often point at the interesting code @@ -151,14 +172,20 @@ second server alongside the existing one). **Default: IDA Pro.** Use `--backend ida` whenever IDA is installed and licensed — its decompilation is the most reliable across architectures. Only switch backends if IDA fails to load the binary (the `load` call -errors, or analysis stalls); fall through in this order: `ida → ghidra -→ angr`. Use `binja` only when explicitly requested. +errors, or analysis stalls); fall through in this order: `ida → binja → +ghidra → angr`. + +Check what is actually licensed here before falling back — `decompiler +backend status binja --json` reports `available` in one call. Binary Ninja +outranks Ghidra when it is available: its decompilation is closer to IDA's, +and a licensed backend is the one worth using. Skip to `ghidra` when binja +reports unavailable (no license, not installed). ```bash decompiler load ./my-binary --backend ida # PREFERRED: IDA Pro (needs install + license) +decompiler load ./my-binary --backend binja # 2nd CHOICE: Binary Ninja (needs license) decompiler load ./my-binary --backend ghidra # FALLBACK: needs GHIDRA_INSTALL_DIR decompiler load ./my-binary --backend angr # LAST RESORT: pure-Python, always available -decompiler load ./my-binary --backend binja # Binary Ninja, needs license decompiler load ./challenge.apk --backend jadx # Java/Android managed code ``` diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index bd163aab..28ec0d3e 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -272,6 +272,41 @@ def test_export_filter_narrows_the_set(self): manifest = json.loads(result.stdout) self.assertEqual(manifest["function_count"], 1) + def test_export_min_size_drops_stubs_but_keeps_real_code(self): + """--min-size is what makes a dump readable rather than only greppable. + + Import stubs dominate the function count of any dynamically linked + binary and say nothing. Dropping them must not drop real code, and + must stay opt-in so existing callers see no change. + """ + import tempfile as _tf + + self._load_fauxware() + with _tf.TemporaryDirectory() as out: + everything = json.loads(_run_cli( + "export", "--out", out, "--no-string-xrefs", "--json").stdout) + self.assertEqual(everything["skipped_below_min_size"], 0, + "default must export everything, unchanged") + + with _tf.TemporaryDirectory() as out: + trimmed = json.loads(_run_cli( + "export", "--out", out, "--min-size", "16", + "--no-string-xrefs", "--json").stdout) + + self.assertLessEqual(trimmed["function_count"], + everything["function_count"]) + self.assertEqual( + trimmed["function_count"] + trimmed["skipped_below_min_size"], + everything["function_count"], + "every function is either exported or counted as skipped") + + kept = json.load(open(os.path.join(out, "functions.json"))) + self.assertTrue(kept, "min-size must not empty the export") + for f in kept: + self.assertGreaterEqual(f["size"], 16) + # main is real code by any measure and must survive the filter. + self.assertIn(self._resolve_main_name(), [f["name"] for f in kept]) + def test_decompile_many_matches_one_at_a_time(self): """The batch path must not change results, only round-trips.""" self._load_fauxware()