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..9443e134 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -63,7 +63,27 @@ 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. -**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. 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()