Skip to content

Commit 1645b3b

Browse files
branoverclaude
andauthored
fix: re_search_code byte/immediate scan no longer runs a whole-binary analysis (2547s timeout) (#269)
* fix: re_search_code byte/immediate scan no longer runs a whole-binary analysis (2547s timeout) The byte/immediate scan shelled to xrefs_probe.py, which runs a whole-binary radare2 `aaa` before EVERY mode — but a raw memory scan doesn't need analysis (the `aaa` existed only to map a hit to its containing function), so on a ~480MB target it hit the ~2547s size-scaled timeout. Fix, tiered like the re_xrefs routing: a warm Ghidra Memory.findBytes scan (pyghidra_lib.search_bytes_core; served headless via `ghidra_probe --search` WARM-ONLY + a resident-bridge `search` op) routed through sandbox.decompiler.ghidra_op_backend — reuses the warm project / a live bridge, each hit mapped to its function; the r2 xrefs_probe fallback now SKIPS `aaa` for search mode so it's fast too. Also fixes the read-only warm open (_read_only_program, shared with re_script), latently broken under jpype (a boxed JInt DEFAULT_VERSION + a Python object() that can't bind java.lang.Object). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address PR #269 review nits — guard found.add() overflow, reject negative immediates Wrap search_bytes_core's found.add(1) so an AddressOverflowException at the very top of the address space stops the scan cleanly instead of raising out and dropping already-found hits. And _search_patterns now rejects negative immediates to match the r2 fallback's _IMM regex (which has no leading '-'), so the same `immediate` resolves identically on both backends — a two's-complement value is searchable via bytes_pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa6fa3e commit 1645b3b

8 files changed

Lines changed: 275 additions & 29 deletions

File tree

src/hexgraph/agent/agent_tools.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,26 +2385,58 @@ def _search_code_grep(ctx: ToolContext, args: dict, *, query: str, functions) ->
23852385
return _clip("\n".join(lines))
23862386

23872387

2388-
def _search_code_scan(ctx: ToolContext, args: dict, *, bytes_pat, immediate) -> str:
2389-
"""The byte/immediate scan: run the r2 `--mode search` probe (`/xj`//`/vj`) over the mapped
2390-
image and map each hit to its containing function, PAGINATED (total + next offset reported,
2391-
the no-silent-caps discipline). QUERY: records an Observation; adds no graph nodes."""
2388+
def _ghidra_search(ctx: ToolContext, *, bytes_pat, immediate) -> dict | None:
2389+
"""The WARM Ghidra memory scan (`GhidraDecompiler`/bridge `search_bytes`): `Memory.findBytes`
2390+
over the resident/warm image, each hit mapped to its containing function — a fast scan that
2391+
reuses the warm analysis, NOT the r2 whole-binary `aaa` sweep. Returns the probe dict on a
2392+
COMPLETED run, or None when Ghidra couldn't answer (not the active backend / no warm project /
2393+
a probe fault) so the caller falls back to r2. Best-effort: never raises into the caller."""
2394+
from hexgraph.sandbox.decompiler import ghidra_op_backend
2395+
2396+
try:
2397+
out = ghidra_op_backend(ctx.target).search_bytes(
2398+
ctx.target.path, bytes_pattern=bytes_pat,
2399+
immediate=(str(immediate) if immediate is not None else None), project=ctx.project)
2400+
except Exception: # noqa: BLE001 — a warm-path failure DEGRADES to r2, never aborts the tool
2401+
return None
2402+
if not isinstance(out, dict) or out.get("error"):
2403+
return None
2404+
return out
2405+
2406+
2407+
def _r2_search(ctx: ToolContext, *, bytes_pat, immediate):
2408+
"""The radare2 raw-scan fallback (`xrefs_probe --mode search`, `/xj`//`/vj`) for when Ghidra
2409+
can't answer. The probe no longer runs a whole-binary `aaa` for a search, so it's fast (function
2410+
mapping best-effort from the symbol table). Returns the probe dict, or a string error message."""
23922411
from hexgraph.sandbox.executor import get_executor
2393-
from hexgraph.sandbox.runner import docker_available
23942412

2395-
if not docker_available():
2396-
return "search_code byte/immediate scan unavailable (Docker/sandbox not running)"
2397-
extra = ["--mode", "search"]
2398-
if bytes_pat:
2399-
extra += ["--bytes", str(bytes_pat)]
2400-
subj = f"bytes {bytes_pat!r}"
2401-
else:
2402-
extra += ["--imm", str(immediate)]
2403-
subj = f"immediate {immediate!r}"
2413+
extra = ["--mode", "search"] + (["--bytes", str(bytes_pat)] if bytes_pat
2414+
else ["--imm", str(immediate)])
24042415
try:
2405-
out = get_executor().run_json_probe("xrefs_probe.py", ctx.target.path, extra_args=extra)
2416+
return get_executor().run_json_probe("xrefs_probe.py", ctx.target.path, extra_args=extra)
24062417
except Exception as exc: # noqa: BLE001
24072418
return f"search_code scan failed: {exc}"
2419+
2420+
2421+
def _search_code_scan(ctx: ToolContext, args: dict, *, bytes_pat, immediate) -> str:
2422+
"""The byte/immediate scan, PAGINATED (total + next offset reported, the no-silent-caps
2423+
discipline). Prefers the WARM Ghidra loaded-memory scan (the byte-scan analog of re_xrefs
2424+
consulting the warm reference index — a fast memory scan over the resident image, NOT the r2
2425+
whole-binary `aaa` sweep that times out on a large target); falls back to the r2 raw scan (also
2426+
fast now — no `aaa` for a search) when Ghidra can't answer. QUERY: records an Observation."""
2427+
from hexgraph.sandbox.runner import docker_available
2428+
2429+
if not docker_available():
2430+
return "search_code byte/immediate scan unavailable (Docker/sandbox not running)"
2431+
subj = f"bytes {bytes_pat!r}" if bytes_pat else f"immediate {immediate!r}"
2432+
2433+
# Warm Ghidra first (reuses the warm project / a live bridge); r2 raw scan otherwise.
2434+
out = _ghidra_search(ctx, bytes_pat=bytes_pat, immediate=immediate) \
2435+
if _ghidra_xrefs_active() else None
2436+
if out is None:
2437+
out = _r2_search(ctx, bytes_pat=bytes_pat, immediate=immediate)
2438+
if isinstance(out, str): # a string error from the r2 fallback
2439+
return out
24082440
if isinstance(out, dict) and out.get("error"):
24092441
return f"search_code: {out['error']}"
24102442

src/hexgraph/agent/mcp_catalog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@
182182
("read", "re_resolve", _t.resolve_address, "Triage a hex ADDRESS (a crash address, a pointer, a DAT_ label) WITHOUT a full decompile: returns {nearest_symbol + offset, section, containing_function (name+bounds when the symbol table knows it)}. Assembled server-side from the symbol table + section table (re_binutils_facts / pyelftools over the on-disk ELF) — cheap orientation before you spend a re_decompile_at. On a stripped binary it still resolves the section + nearest dynamic symbol (a FUN_ name needs a decompile). QUERY: records an Observation; adds no graph nodes.", {"type": "object", "properties": {"target_id": {"type": "string"}, "address": {"type": "string", "description": "hex address, e.g. 0x401200"}}, "required": ["target_id", "address"]}),
183183
("read", "re_hexdump", _t.hexdump, "Dump raw BYTES at a virtual ADDRESS as hex + ascii (bounded — default 256, max 4096) — inspect a DAT_ table, an embedded key, a struct, or a string constant's exact bytes. Maps the vaddr to a file offset via the ELF program headers/sections and reads the on-disk artifact server-side (no decompile, no Docker). Bytes in a .bss/zero-fill region read as 00 with a note; an unmapped address is reported, not faked. QUERY: records an Observation; adds no graph nodes. (For the INSTRUCTIONS at an address use re_disassemble_range; this is the raw-bytes view.)", {"type": "object", "properties": {"target_id": {"type": "string"}, "address": {"type": "string", "description": "hex virtual address, e.g. 0x4c1000"}, "length": {"type": "integer", "description": "bytes to dump (default 256, clamped 1-4096)"}}, "required": ["target_id", "address"]}),
184184
("read", "re_function_info", _t.function_info, "Lightweight metadata for a function (by NAME or ADDRESS) WITHOUT a full decompile: address, size, prototype/signature + calling convention (when known — from the symbol table, a prior decompilation, or recon), and #callers / #callees (from the call graph). The cheap 'what is this function' triage before deciding to re_decompile_function it. Fields not yet recovered are marked unknown (decompile to recover). QUERY: records an Observation; adds no graph nodes.", {"type": "object", "properties": {"target_id": {"type": "string"}, "function": {"type": "string", "description": "function name (or pass address)"}, "address": {"type": "string", "description": "hex address in the function (or pass function)"}}, "required": ["target_id"]}),
185-
("read", "re_search_code", _t.search_code, "Search the WHOLE binary's code (not just already-decompiled bodies, which re_search_decompiled covers): a decompile-on-demand grep (`query`) over a BOUNDED candidate set you name in `functions` (so you control the cost — an unbounded whole-binary decompile is intentionally NOT offered), OR a BYTE/opcode pattern (`bytes_pattern`, hex pairs) / an IMMEDIATE constant (`immediate`) scanned across the mapped image (radare2 `/xj`//`/vj`, mapped to the containing function). To find CALLERS of a symbol/sink use re_xrefs (whole-program, indexed) — this does NOT duplicate it. Paginated. QUERY: records an Observation; adds no graph nodes.", {"type": "object", "properties": {"target_id": {"type": "string"}, "bytes_pattern": {"type": "string", "description": "hex byte pattern to scan for, e.g. 'deadbeef' or '48 8b'"}, "immediate": {"type": "string", "description": "an immediate/constant value to find (hex or decimal)"}, "functions": {"type": "array", "description": "bound the decompile-on-demand grep to these functions (with `query`)"}, "query": {"type": "string", "description": "substring to grep in the decompiled bodies of `functions`"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, "required": ["target_id"]}),
185+
("read", "re_search_code", _t.search_code, "Search the WHOLE binary's code (not just already-decompiled bodies, which re_search_decompiled covers): a decompile-on-demand grep (`query`) over a BOUNDED candidate set you name in `functions` (so you control the cost — an unbounded whole-binary decompile is intentionally NOT offered), OR a BYTE/opcode pattern (`bytes_pattern`, hex pairs) / an IMMEDIATE constant (`immediate`) scanned across the mapped image — a FAST memory scan over the WARM Ghidra project (reuses the analysis, no whole-binary re-scan; radare2 raw-scan fallback when Ghidra's off), each hit mapped to its containing function. To find CALLERS of a symbol/sink use re_xrefs (whole-program, indexed) — this does NOT duplicate it. Paginated. QUERY: records an Observation; adds no graph nodes.", {"type": "object", "properties": {"target_id": {"type": "string"}, "bytes_pattern": {"type": "string", "description": "hex byte pattern to scan for, e.g. 'deadbeef' or '48 8b'"}, "immediate": {"type": "string", "description": "an immediate/constant value to find (hex or decimal)"}, "functions": {"type": "array", "description": "bound the decompile-on-demand grep to these functions (with `query`)"}, "query": {"type": "string", "description": "substring to grep in the decompiled bodies of `functions`"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, "required": ["target_id"]}),
186186
("run", "re_script", _t.run_script, "ESCAPE-HATCH: run an AGENT-SUPPLIED PyGhidra Python-3 script against a target's already-built WARM Ghidra project and get its JSON output — full Ghidra-API power (data-flow slicing, BSim/FID, stack-frame analysis, custom P-Code walks) WITHOUT a pre-built tool per capability, for a query the other re_* verbs don't cover. GHIDRA-ONLY (radare2 unsupported — errors if headless Ghidra isn't the active backend). Runs inside the SAME hardened sandbox every probe uses (--network none, read-only rootfs, --cap-drop ALL, non-root) and opens the warm project READ-ONLY, so your script can inspect but NEVER mutate/corrupt it; the target binary is NEVER executed (Ghidra statically analyzes). WARM-ONLY: if the target has no warm project yet it errors → run re_analyze(target) first (this never runs a cold analysis). CONTRACT (same as HexGraph's built-in postScripts): your script receives `out_path = getScriptArgs()[0]` and MUST write its JSON result to that path — HexGraph reads it back and returns it. Tiny example: `import json; fm = currentProgram.getFunctionManager(); n = len(list(fm.getFunctions(True))); f = open(getScriptArgs()[0], 'w'); f.write(json.dumps({'function_count': n})); f.close()`. program / currentProgram / flat / monitor / out_path / getScriptArgs() and the ghidra.* API are in scope — it's Python 3 (write JSON to out_path, or assign the `result` variable). GATED: opt-in via features.ghidra.scripting (hidden until enabled — it's an arbitrary-code-in-sandbox surface). Records a `script` Observation; adds NO graph nodes. Long output is truncated to max_chars (default 6000, clamped) with a marker; obs_get reads the full payload.",
187187
{"type": "object", "properties": {"target_id": {"type": "string"}, "script": {"type": "string", "description": "PyGhidra/Jython source; writes its JSON result to getScriptArgs()[0]"}, "max_chars": {"type": "integer", "description": _MAX_CHARS_DESC}}, "required": ["target_id", "script"]}),
188188
("read", "re_search_symbols_project", _t.search_symbols_project, "Search a symbol/function NAME pattern across ALL targets in a project -> which target(s) DEFINE or IMPORT it (the name analogue of re_yara_sweep). For each non-archived target it tests the pattern against its imports/exports/defined symbols (and function names when known), sourced from stored recon metadata + prior re_binutils_facts Observations — no per-target decompile, no probe. Returns {pattern, matches:[{target_id, name, kind}], scanned, targets_without_symbols}. Use to locate which binary in a firmware defines a given sink or a shared helper. `regex=true` for a regex; `kind` scopes to imports|exports|defined. A target with no stored symbols is listed in targets_without_symbols (re_binutils_facts it first) so a miss isn't read as authoritative. QUERY: reads the substrate; adds no graph nodes.", {"type": "object", "properties": {"project_id": {"type": "string"}, "pattern": {"type": "string", "description": "substring (or regex) to match against symbol/function names"}, "kind": {"type": "string", "enum": ["imports", "exports", "defined", "all"], "description": "scope (default all)"}, "regex": {"type": "boolean"}, "limit": {"type": "integer", "description": "max matches to return"}}, "required": ["project_id", "pattern"]}),

src/hexgraph/engine/re/ghidra_bridge.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ def run_emulate(self, function: str | None) -> dict:
206206
def rename_function(self, address: str, new_name: str) -> dict:
207207
return self._rpc({"op": "rename", "address": address, "new_name": new_name})
208208

209+
def search_bytes(self, bytes_pattern, immediate) -> dict:
210+
return self._rpc({"op": "search", "bytes_pattern": bytes_pattern, "immediate": immediate})
211+
209212

210213
def connect_managed(host: str, port: int) -> BridgeOps:
211214
"""Ops for HexGraph's managed resident bridge (custom JSON RPC). Unlike `connect_ops`, needs NO
@@ -265,6 +268,10 @@ def run_emulate(self, artifact: str, function: str, *, project=None) -> dict:
265268
def rename_function(self, artifact: str, *, address: str, new_name: str, project=None) -> dict:
266269
return self._managed().rename_function(address, new_name)
267270

271+
def search_bytes(self, artifact: str, *, bytes_pattern: str | None = None,
272+
immediate: str | None = None, project=None) -> dict:
273+
return self._managed().search_bytes(bytes_pattern, immediate)
274+
268275

269276
def list_open_programs(ops: BridgeOps | None = None) -> list[dict]:
270277
return (ops or connect_ops()).list_programs()

src/hexgraph/sandbox/decompiler.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,30 @@ def xrefs(self, artifact: str, *, mode: str, subject: str | None = None, project
317317
return self.runner.run_json_probe("ghidra_probe.py", artifact, extra_args=args)
318318
return self._run_locked(slot, artifact, args)
319319

320+
def search_bytes(self, artifact: str, *, bytes_pattern: str | None = None,
321+
immediate: str | None = None, project=None) -> dict:
322+
"""Scan the warm project's LOADED MEMORY for a BYTE pattern / IMMEDIATE value (`--search`),
323+
each hit mapped to its containing function. WARM-ONLY on purpose: a byte scan must NEVER
324+
trigger a cold whole-binary analysis — that cold import/`aaa` IS the 2547s timeout the r2
325+
xrefs_probe hits. Without a committed warm slot this returns an `error` so the caller degrades
326+
to the r2 raw-scan path (fast, no analysis) instead of cold-importing here. Held under the
327+
slot lock (read-only open, so the warm project is never mutated); emits the same JSON shape as
328+
the r2 xrefs_probe `search` mode."""
329+
slot = self._resolve_slot(artifact, project)
330+
if slot is None or not slot.exists():
331+
return {"tool": "ghidra_search", "mode": "search",
332+
"error": "no warm Ghidra analysis for this target (search is warm-only)"}
333+
args = ["--search"]
334+
if bytes_pattern:
335+
args += ["--sbytes", str(bytes_pattern)]
336+
elif immediate is not None:
337+
args += ["--simm", str(immediate)]
338+
with slot.lock() as locked:
339+
if not locked:
340+
return {"tool": "ghidra_search", "mode": "search",
341+
"error": "ghidra project busy (locked); try again"}
342+
return self._run_locked(slot, artifact, args)
343+
320344
def _run_locked(self, slot, artifact: str, args, *, force_cold: bool = False):
321345
"""Run the probe with the slot held exclusively. Decides cold vs warm on the AUTHORITATIVE
322346
committed marker (`slot.exists()`); cleans a partially-written slot before a cold run; and

src/hexgraph/sandbox/probes/ghidra_probe.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,30 @@ def _check() -> int:
132132
return 0
133133

134134

135+
def _flag_value(argv, flag):
136+
"""The value immediately following `flag` in argv, or None (for `--sbytes <hex>` / `--simm <v>`)."""
137+
if flag in argv:
138+
i = argv.index(flag)
139+
if i + 1 < len(argv):
140+
return argv[i + 1]
141+
return None
142+
143+
135144
def _parse(argv):
136145
"""Parse the probe argv into a mode dict. Mirrors the Jython arg grammar exactly."""
137146
artifact = argv[1]
138147
rest = argv[2:]
139148
m = {"artifact": artifact, "mode": "decompile", "focus": None,
140-
"rename": None, "xrefs_mode": "sinks", "xrefs_subject": "", "user_script": None}
149+
"rename": None, "xrefs_mode": "sinks", "xrefs_subject": "", "user_script": None,
150+
"search_bytes": None, "search_imm": None}
141151
if "--script" in rest:
142152
m["mode"] = "script" # run the agent-supplied script over the WARM program, read-only
143153
return m
154+
if "--search" in rest:
155+
m["mode"] = "search" # byte/immediate memory scan over the WARM program, read-only
156+
m["search_bytes"] = _flag_value(rest, "--sbytes")
157+
m["search_imm"] = _flag_value(rest, "--simm")
158+
return m
144159
if "--analyze" in rest:
145160
m["mode"] = "analyze" # cold whole-binary analysis, no focus
146161
return m
@@ -177,12 +192,19 @@ def _run(m) -> dict:
177192
from ghidra.util.task import ConsoleTaskMonitor
178193

179194
mode = m["mode"]
180-
read_only = mode == "script"
195+
# script + search are READ-ONLY, WARM-ONLY queries: they open the warm program immutable and
196+
# never trigger a cold analysis (a byte scan that cold-analyzes a large target IS the timeout
197+
# bug; the host falls back to the r2 raw scan on a warm miss instead).
198+
read_only = mode in ("script", "search")
181199
with L.open_target(m["artifact"], cold_analyze=not read_only,
182200
read_only=read_only) as (program, flat, cached):
183201
monitor = ConsoleTaskMonitor()
184202
if mode == "script":
185203
result = L.script_core(program, flat, monitor, m["user_script"])
204+
elif mode == "search":
205+
result = L.search_bytes_core(program, flat, monitor,
206+
bytes_pattern=m.get("search_bytes"),
207+
immediate=m.get("search_imm"))
186208
elif mode == "taint":
187209
result = L.taint_core(program, flat, monitor)
188210
elif mode == "emulate":

0 commit comments

Comments
 (0)