From c8a7d6cd7b044a4162c9d9678ca637a803a7400c Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Tue, 28 Jul 2026 16:50:46 +0000 Subject: [PATCH 1/2] Make `export` discoverable, and its output readable `export` was added in #222 to replace the load -> list_functions -> decompile -> decompile -> ... loop that everyone otherwise scripts by hand. Across 60 solve sessions on a 40-binary benchmark it was invoked **zero times**. Not because agents rejected it. SKILL.md -- 589 lines, the doc agents actually read -- never mentions `export`. It says "Always start with `list_functions` and `list_strings`", which is precisely the loop export exists to collapse, so agents did that instead. The four sessions that found the verb at all found it by running `decompiler export --help` inside a batch of `--help` calls at command 60-85 of ~130, long after they had done the work by hand. One of them then hand-wrote eight per-function .c files -- a worse copy of export's own output -- having spent 112 CLI calls to get there. A 117-function binary exports in 31 seconds. Two changes: **SKILL.md** now leads the "first moves" section with export, shows the export-then-grep pattern, and says when *not* to use it (very large binaries, or when you already know the one function you want). The interrogative path is still documented, just no longer the only thing documented. **`--min-size`** makes the output worth reading rather than only grepping. Import stubs dominate the function count of any dynamically linked binary: on the crackme above, 93 of 117 functions were PLT thunks and they were 45% of the exported pseudocode. `--min-size 16` cuts that binary to 24 functions and 108K (from 117 and 480K) while keeping every function a human analyst annotated by hand. Opt-in; default 0 exports everything exactly as before. The manifest reports `skipped_below_min_size` so the trim is never silent. Co-Authored-By: Claude Opus 5 (1M context) --- declib/cli/decompiler_cli.py | 18 +++++++++++++++- declib/skills/decompiler/SKILL.md | 22 ++++++++++++++++++- tests/test_decompiler_cli.py | 35 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) 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() From 47c9921d45121cbebc4c6bf17f50d8c596e36632 Mon Sep 17 00:00:00 2001 From: Eric Gustafson Date: Wed, 29 Jul 2026 03:04:35 +0000 Subject: [PATCH 2/2] Put Binary Ninja ahead of Ghidra in the fallback order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md told agents "use `binja` only when explicitly requested" and documented the fallback chain as `ida → ghidra → angr`, leaving the one other *licensed* backend out of it entirely. The result, measured over 60 solve sessions: binja was loaded in **1**. Agents reached for IDA first every time (20/20 and 38/38 where a backend was named -- the IDA guidance works), and when IDA died they went straight to Ghidra, because that is what the doc says to do. So a paid Binary Ninja license sat unused while the fallback path ran on the unlicensed backend. That fallback is not free. In the same run a Ghidra load died outright on a binary named `timo#3` (see the name-sanitizing fix), costing a backend switch and several minutes on a binary binja would have opened. Fallback order is now `ida → binja → ghidra → angr`, with a pointer to `decompiler backend status binja --json` so an agent can tell in one call whether it is licensed here rather than assuming. Where binja is not installed the guidance is unchanged in effect: the check reports unavailable and the chain falls through to ghidra. Co-Authored-By: Claude Opus 5 (1M context) --- declib/skills/decompiler/SKILL.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 9443e134..7b9dc609 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -60,8 +60,9 @@ 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. **On a small or medium binary, `export` first and grep the result.** One command decompiles everything to a directory, so orienting becomes a @@ -88,7 +89,7 @@ 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 @@ -101,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 @@ -171,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 ```