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
22 changes: 21 additions & 1 deletion declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.

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