Skip to content

fix(ghidra): keep the cross-format branch callee multi-block - #1382

Merged
morluto merged 3 commits into
morluto:mainfrom
BaaRai:fix/cross-branch-multiblock-callee
Oct 10, 2026
Merged

morluto merged 3 commits into
morluto:mainfrom
BaaRai:fix/cross-branch-multiblock-callee

Conversation

@BaaRai

@BaaRai BaaRai commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The cross-format oracle walks the entry's callees for one function whose recovered control flow has more than one basic block, and rea_cross_branch provided it through a symmetric if/else of tail calls. Clang 21 folds that shape into cmp; cneg; b — a single basic block — so findCrossBranchAddress failed with cross-arm64-elf did not recover the multi-block callee against a correct Ghidra analysis. The branch now runs a loop whose trip count depends on the fixture's volatile global, which no compiler can eliminate, keeping the callee multi-block at every optimization level.

Problem and expected behavior

Smallest trigger: npm run verify:ghidra:cross-format with clang 21 (reproduced on Kali rolling: clang 21.1.8, ld.lld 21.1.8, gcc 16.2 host compiler, Ghidra 12.1.4, JDK 21, node 24.19). The lane fails on the first cross target:

Error: cross-arm64-elf did not recover the multi-block callee
    at findCrossBranchAddress (scripts/verify-real-ghidra-function.mjs:523)

Violated invariant: the oracle needs a fixture function that is multi-block after compilation, but symmetric tail-call if/else codegen is a toolchain decision. Disassembly of the clang 21 AArch64 build of the previous source:

rea_cross_branch:
  cmp   w0, #0xa
  cneg  w0, w0, le
  b     rea_cross_leaf

One basic block, so Ghidra correctly reports basicblock_count of one and the verifier rejects it. This is the same class as the -O0 dense-switch lowering fixed for the host lane in #1374, and the dedicated switch lane already avoids it by construction.

Change and scope

tests/conformance/ghidra/cross-format.c: rea_cross_branch now computes an adjusted value and decrements it by rea_cross_global (volatile) inside a while loop before the tail call. The loop is a real backedge at runtime-unknown trip count, so it survives every optimization level; on clang 21 AArch64 the function now compiles to a compare, a conditional select, and a b.lt-guarded loop with a backward branch and the volatile load. No symbols, call structure, entry chain, string evidence, or dense-switch shape change; PE and Mach-O builds of the same source are unaffected beyond the same guaranteed block structure.

Not included: no verifier, bridge, provider, or contract changes — the oracle itself is correct; only the fixture's compiler-independence was missing.

Contract and boundary impact

  • Semantic owner and earliest changed stage: conformance fixture source only.
  • CLI and MCP/tool-catalog contract: none.
  • Provider, bridge, target-format, or platform compatibility: none; fixture behavior is identical on GCC and older Clang.
  • Evidence, artifact, provenance, or reconstruction contract: not applicable.
  • Process execution, authorization, cleanup, or containment impact: not applicable.
  • Generated metadata, package, or installation impact: not applicable.

Evidence and regression coverage

  • Tests added or updated: the fixture source is the regression; the cross-format and AArch64 oracles assert the multi-block property directly.
  • Base reproduction and other evidence: executed runs on Kali rolling with GHIDRA_INSTALL_DIR at Ghidra 12.1.4 and JDK 21:
    • Base 08ec2bf: verify:ghidra:cross-format fails at cross-arm64-elf did not recover the multi-block callee; the clang 21 disassembly above shows the single-block fold.
    • This change: verify:ghidra:cross-format completes with exit 0 and ok: true across all seven fixtures (debug, stripped, type-layout, jump-table, cross-arm64-elf, cross-x86_64-pe, cross-x86_64-mach-o) with cleanup: complete, and verify:ghidra:aarch64-jump-table (which compiles the same source for its aarch64-jump-table target) still completes with exit 0 across all seven of its fixtures, confirming no regression from the fixture change.
  • User-visible CLI/MCP output: not applicable.

The cross-format oracle requires a callee whose recovered control flow
has more than one basic block, and rea_cross_branch provided it through
a symmetric if/else of tail calls. Clang 21 folds that shape into a
conditional negate plus one tail branch, a single basic block, so the
lane rejected a correct Ghidra analysis of the fixture.

Give the branch a loop whose trip count depends on a volatile global:
no compiler can eliminate the backedge, and the callee stays multi-block
at every optimization level on every toolchain.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

The failed shard is the JavaScriptSemanticGraphBuilder short-circuit
mutation test, which also failed on main run 38003459598 at the same
base commit (08ec2bf) before this branch started CI, and passed on
main run 38003459600 minutes later at the same commit.
@BaaRai

BaaRai commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

The two failed checks are the same pre-existing main-branch flake, not this change: src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts > preserves possible alias mutation uncertainty in queries: short-circuit mutation also failed on main run 38003459598 (Test shard 1/4) at base commit 08ec2bf about twenty minutes before this branch started CI, while main run 38003459600 at the same commit passed it minutes later. This branch only touches the tests/conformance/ghidra/cross-format.c fixture, which that suite never loads. I pushed an empty commit to re-run the suite.

@BaaRai

BaaRai commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the failed shard: it is not a flake after all. The same test fails deterministically on main since 001851f (bisected locally: 39dabd0 passes, 001851f fails, reproduced 15/15 on Windows and 8/8 on Linux; main run 38003459598 caught it at 23:15). That commit's capture-time ordering proves the short-circuit mutation cannot reach the earlier alias target, so the graph now correctly reports present while the test still expects unknown-coverage. #1385 updates the stale expectation and asserts the mutation stays recorded on the alias binding; once it merges this branch's checks only need a rerun to go green. The fixture change in this PR is unaffected: the failing suite never loads the Ghidra fixtures.

@CassieFox23

CassieFox23 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: Clang 22 does the same fold. This fix works there too.

I tested this on Fedora 44, not Kali. The tools were newer than yours.

What ran:

  • Main at 08ec2bf8, verify:ghidra:cross-format: failed. Exit 1, 376 s. It gave your error: cross-arm64-elf did not recover the multi-block callee.
  • This PR at e10c934d, verify:ghidra:cross-format: passed. Exit 0, 370 s. All seven fixtures were ok. Cleanup was complete.
  • This PR, verify:ghidra:aarch64-jump-table: passed. Exit 0, 361 s. All seven fixtures were ok.

I also built rea_cross_branch for AArch64 at -O2. On main, clang 22 still folds it to cmp; cneg; b. With this PR it keeps the loop. The back branch is there.

The head then moved to 40f632a1. That commit is empty. Its tree matches e10c934d, so these results still hold.

Not run: the full test suite. No test files changed, so there was no focused run. I only tried a Linux x64 host.

Test setup
  • Box: Fedora 44
  • Node: 24.18.0
  • Other: npm 11.16.0
  • Other: Clang 22.1.8 and LLD 22.1.8, the Fedora builds
  • Other: GCC 16.2.1 as the host compiler
  • Other: Ghidra 12.1.4 with JDK 21.0.12.1

🤖 Generated with Claude Code (Opus 5.5) 🦊

b8a8190 restored the guarded-mutation uncertainty the failing test
encodes, so the shard should pass against the new merge base.
@morluto
morluto merged commit f1231d4 into morluto:main Oct 10, 2026
19 checks passed
@CassieFox23

CassieFox23 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: The fix in this PR holds on main: with a one-line local change to a schema check, all seven fixtures pass. The commit before this merge still fails with the old multi-block error. As shipped, the lane on main stops before that, at the schema check, which #1433 broke.

I ran npm run verify:ghidra:cross-format after the merge, four times:

Tree Verifier script Result Wall time How far it got
main 983b070d4 as shipped fail, exit 1 28.5 s first fixture (debug, x86-64 ELF)
parent a957f397a as shipped fail, exit 1 28.9 s first fixture (debug, x86-64 ELF)
main 983b070d4 one-line change pass, exit 0 373.3 s all seven fixtures ok, cleanup complete
parent a957f397a one-line change fail, exit 1 351.5 s cross-arm64-elf did not recover the multi-block callee

The two as-shipped runs fail on this line:

Error: schema with key or id "urn:rea:tool-output:sha256:8d99d0ac…" already exists
    at verifyGhidraBoundaries (scripts/lib/real-ghidra-boundaries.mjs:43:35)

The first fold below explains the as-shipped failure. My earlier run on 08ec2bf8, before #1433, got past this check and failed later at the arm64 step.

The merged head e17290cd has the same tree as e10c934d, which I tested before the merge. The wall times differ from my earlier 376 s and 370 s because those runs were on other commits (08ec2bf8 and e10c934d).

Not run

  • verify:ghidra and verify:ghidra:aarch64-jump-table on main. Both run the same schema check on their first fixture. So I expect the same error, from reading the script, not from a run.
  • scripts/lib/real-hopper-boundaries.mjs:45, which compiles schemas the same way. I did not run that verifier.
  • The other Ghidra lanes, the full test suite, and any host other than Linux x64.

Thanks, BaaRai, for the fix and the clear clang 21 repro.

Why the as-shipped runs fail

Line 43 of real-ghidra-boundaries.mjs compiles every tool's output schema into one Ajv instance. Ajv is the JSON Schema validator. Since #1433, each self-contained output schema gets a $id made from a hash of its content. So tools with the same schema share one $id: the MCP server lists 139 tools with 118 distinct ids. Ajv then refuses the second schema with that id. The server itself is fine; only this verifier step trips. Before #1433 the output schemas had no content-hash $id.

My local change to the check

I changed scripts/lib/real-ghidra-boundaries.mjs in my scratch clones only, never committed. It reuses the compiled validator when the $id is already known. The id hashes the whole schema, so a shared id means an identical schema.

-    validators.set(tool.name, ajv.compile(tool.outputSchema));
+    validators.set(tool.name, (tool.outputSchema?.$id && ajv.getSchema(tool.outputSchema.$id)) || ajv.compile(tool.outputSchema));
Tools that share an output schema id on main

The same eight groups show up on the parent.

  • address_name, comment, inline_comment, procedure_pseudo_code
  • current_address, current_procedure, current_document, goto_address, next_address, prev_address, procedure_address, procedure_assembly
  • list_documents, procedure_callees, procedure_callers, xrefs
  • list_names, list_procedures
  • list_strings, search_strings
  • set_address_name, set_bookmark, set_comment, set_inline_comment, unset_bookmark
  • observe_native_ui, capture_native_ui_scenario
  • record_unknown, update_unknown
Test setup

🤖 Generated with Claude Code (Opus 5.5) 🦊

@BaaRai

BaaRai commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Cassie, this report was complete: reproduced on main byte for byte, and your reuse approach is exactly right. I applied it in #1543 together with the third site with the same pattern, verify/electron/tool-contracts.mjs, which compiles both input and output schemas through one shared Ajv instance. Validated on the real lanes there: cross-format completes all seven fixtures green on main plus the fix (which also confirms the merged fixture end to end), the Hopper boundary phase now runs past the compile and stops at the known #341 demo string-typing boundary, and the Electron tool-contract probes complete with schema-validated results. #1527 covers the two boundary scripts but its validation notes the lanes were not run, so #1543 supersedes it if you prefer one PR carrying the evidence.

darsrc pushed a commit to darsrc/rea that referenced this pull request Oct 10, 2026
…ck-callee

fix(ghidra): keep the cross-format branch callee multi-block
@morluto

morluto commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Thanks for adjusting the cross-format fixture, @BaaRai. The volatile, runtime-dependent loop gives rea_cross_branch a real backedge that Clang 21 cannot fold into a single block, so the verifier still tests the multi-block case it intends to exercise.

@morluto morluto added bug Something isn't working area: native Native analysis and decompilation, including Ghidra, IDA, and Hopper analysis behavior labels Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: native Native analysis and decompilation, including Ghidra, IDA, and Hopper analysis behavior bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants