Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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",
Expand Down
45 changes: 36 additions & 9 deletions declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<addr>_<name>.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
Expand All @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
35 changes: 35 additions & 0 deletions tests/test_decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading