[PR #2977] feat(ghidra): report computed branches without a recovered jump table #5533

Open
opened 2026-10-31 01:07:22 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/2977
Author: @RishTheFish21
Created: 10/13/2026
Status: 🔄 Open

Base: main ← Head: feat/487-unresolved-indirect-branches


📝 Commits (10+)

  • 26fd63c feat(ghidra): report computed branches without a recovered jump table
  • ac520aa test(ghidra): assert computed branch warning evidence
  • 523cad5 Merge commit '2895a18ca9' into codex/ci-review-pr2977-20261014
  • fb77645 fix(ghidra): address review on unresolved indirect branches
  • 9a4866d Merge remote-tracking branch 'origin/main' into HEAD
  • d170ed2 fix(native): preserve indirect branch unknowns without API coverage
  • 50b39ef fix(workflows): invalidate stale native API inspection snapshots
  • 903e659 fix(ghidra): attribute indirect-branch warnings by source address
  • 0e18a17 merge(main): integrate indirect-branch warning attribution with upstream
  • a02741b Merge main into feat/487-unresolved-indirect-branches

📊 Changes

27 files changed (+1716 additions, -44 deletions)

View changed files

📝 bridge/ghidra/ReaGhidraBridge.java (+214 -0)
📝 docs/native-investigation.md (+26 -1)
📝 docs/testing-lanes.md (+6 -0)
📝 knip.json (+1 -0)
📝 scripts/verify-real-ghidra-assertions.mjs (+3 -0)
📝 scripts/verify-real-ghidra-function.mjs (+153 -0)
➕ scripts/verify-real-ghidra-warning-attribution.mjs (+191 -0)
📝 scripts/verify-real-ghidra.mjs (+57 -25)
📝 src/application/WorkflowAnalysisProfile.ts (+5 -3)
📝 src/application/WorkflowEvidence.test.ts (+14 -4)
📝 src/application/binary/AnalysisSnapshotCache.test.ts (+35 -0)
📝 src/application/native/NativeApiInspection.test.ts (+235 -1)
📝 src/application/native/NativeApiInspection.ts (+34 -4)
📝 src/domain/ghidraValues.fixture.ts (+1 -0)
📝 src/domain/native/nativeApiBoundary.ts (+37 -0)
📝 src/ghidra/GhidraAnalysisProfile.ts (+1 -0)
📝 src/ghidra/GhidraFunctionValues.test.ts (+63 -0)
📝 src/ghidra/GhidraNativeAnalysisValues.ts (+11 -0)
📝 src/ghidra/GhidraProvider.behavior.test.ts (+4 -0)
📝 src/ghidra/GhidraRawProfileDigest.test.ts (+4 -2)

...and 7 more files

📄 Description

Summary

Part of #487 (second of the steps proposed there; the first was #1592). When Ghidra cannot recover a jump table for a computed br, its decompiler rewrites the branch as a call or return (FlowInfo::truncateIndirectJump), so nothing reached REA's output: jump_tables and unsupported_branches stayed empty and the dossier read as complete. This is the gap @CassieFox23 reported for the RenderScript blur kernels.

This PR reports those sites in a new optional field, native_api.unresolved_indirect_branches, and surfaces each one as a residual unknown in inspect_native_api.

Problem and expected behavior

Before: a function whose only dispatch is an unbounded computed jump (adr x8, …; add x8, x8, x0, lsl #3; br x8) produces jump_tables: [], unsupported_branches: [], and no residual unknown.

After, for the same function:

  • unresolved_indirect_branches: [{ branch_address, decompiler_outcome: "jump_table_recovery_failed", listing_targets: [], evidence: [...] }], with Ghidra's warning ("Could not recover jumptable at … Too many branches") retained as evidence
  • unsupported_branches: ["indirect-branch-target-recovery"]
  • residual unknown: "Which targets can the indirect branch at 0x… reach? The decompiler could not recover a jump table there and treated it as a call; it may be a dispatch inside this function or an indirect tail call."

Change and scope

  • Bridge (ReaGhidraBridge.java): every listing instruction in the function body whose flow type is a computed jump (not a call or terminator), and whose address is not a recovered JumpTable switch, is reported. decompiler_outcome is jump_table_recovery_failed when the decompiler's pseudocode contains "Could not recover jumptable at ", otherwise no_jump_table_warning. listing_targets are the listing's flow references (no case values implied).
  • Schema (nativeApiBoundary.ts): new unresolved_indirect_branches field using exactOptional(): absent means the provider did not inspect indirect branches, so Hopper/IDA output and older snapshots parse unchanged. New evidence kind indirect-branch.
  • Projection (NativeApiInspection.ts): one residual unknown per site and indirect-branch-target-recovery in unsupported_branches.
  • Ghidra address validation also covers the new addresses.

Deliberately not attempted: telling a dispatch apart from an indirect tail call. On real Ghidra 12.1.4 both produce the same recovery failure (see Validation), so REA reports the uncertainty instead of guessing. Not included: the decompiler-failure handling from #487 (a separate follow-up), and sites in regions where Ghidra created no function.

Contract and boundary impact

  • Semantic owner and earliest changed stage: Ghidra bridge native API boundary.
  • CLI and MCP/tool-catalog contract: additive. analyze_function/inspect_native_api output gains the optional unresolved_indirect_branches field, the evidence kind indirect-branch, and the unsupported_branches value indirect-branch-target-recovery. Existing fields are unchanged.
  • Provider, bridge, target-format, or platform compatibility: Ghidra only; other providers omit the field.
  • Evidence, artifact, provenance, or reconstruction contract: new facts are listing and decompiler-warning observations with their source recorded; no targets or case values are inferred.
  • Process execution, authorization, cleanup, or containment impact: none.
  • Generated metadata (docs/public/product-catalog.json), package, or installation impact: output schemas regenerate in CI; no tracked metadata changes.

Evidence and regression coverage

  • Tests added or updated:
    • NativeApiInspection.test.ts: residual unknowns and unsupported_branches for both outcomes; empty or absent inventory adds nothing; a branch without evidence is rejected.
    • GhidraFunctionValues.test.ts: a non-canonical branch address is rejected.
    • New real-Ghidra fixture tests/conformance/ghidra/indirect-branches.S (unbounded computed jump plus an indirect tail call) in the aarch64-jump-table lane, as ELF and, on arm64 hosts, Mach-O. The lane asserts the computed jump is reported and not mistaken for a jump table, and records the tail call's classification.
    • The existing relative-switch fixtures now assert that recovered switches are not reported as unresolved.
  • Base reproduction or other evidence: on main the field doesn't exist, so the fixture's computed jump leaves no trace in the dossier.
  • User-visible CLI/MCP output: see "Problem and expected behavior".
  • Remaining proof gaps: not run against the real librenderscript-toolkit.so from the issue thread; no Linux run.

For evidence-bearing changes:

  • Observed, derived, and inferred claims remain distinguishable.
  • Artifact identity, source provenance, and failed attempts remain preserved.
  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

  • npm run test:focused -- src/application/native/NativeApiInspection.test.ts src/ghidra/GhidraFunctionValues.test.ts: passed
  • npm run check:fast: passed
  • npm run lint, npm run knip: passed
  • npm run docs:check: passed
  • npm run verify:ghidra:aarch64-jump-table on an Apple Silicon Mac (Ghidra 12.1.4, JDK 21): ok: true.
    • Computed jump: reported as jump_table_recovery_failed with the decompiler warning, on ELF (0x10000c) and Mach-O (0xc).
    • Indirect tail call: also reported as jump_table_recovery_failed ("Could not recover jumptable at … Too many branches") on both formats.
    • All relative-* switch fixtures passed, including the new "not unresolved" check.

Note on that run: on current main the lane stops early in the host-native native-value check, where compare_functions of a function against itself returns unknown instead of unchanged. Reproduced on main without this PR. The comments facet is empty with no declared coverage, and calls lacks relationship coverage, which looks related to #2450 (b17487f4). I skipped that one assertion locally to reach the fixtures this PR adds, and will report it separately.

Compatibility, safety, and release

  • Breaking changes or migration steps: none; the field is optional and additive.
  • Real Hopper/Ghidra, browser, or OS coverage: real Ghidra on macOS arm64 only.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: static analysis only; reads the existing listing and decompiler output.

Review checklist

  • This PR addresses a concrete problem or an agreed enhancement.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

Author readiness

Human owner: @RishTheFish21

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/2977 **Author:** [@RishTheFish21](https://github.com/RishTheFish21) **Created:** 10/13/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `feat/487-unresolved-indirect-branches` --- ### 📝 Commits (10+) - [`26fd63c`](https://github.com/morluto/rea/commit/26fd63c3966f7845145df8d9ca2437a0134eac22) feat(ghidra): report computed branches without a recovered jump table - [`ac520aa`](https://github.com/morluto/rea/commit/ac520aab522882e75f96fa72b1f04701db634ced) test(ghidra): assert computed branch warning evidence - [`523cad5`](https://github.com/morluto/rea/commit/523cad54fc41c2bb5762679d2b96caef8e02cde4) Merge commit '2895a18ca92f415ae62f87167ba9c7a672e3b7dc' into codex/ci-review-pr2977-20261014 - [`fb77645`](https://github.com/morluto/rea/commit/fb77645644435c7092700f9e1b0f0495466cf80f) fix(ghidra): address review on unresolved indirect branches - [`9a4866d`](https://github.com/morluto/rea/commit/9a4866d0fbb687e631efda477321401a3be00c73) Merge remote-tracking branch 'origin/main' into HEAD - [`d170ed2`](https://github.com/morluto/rea/commit/d170ed2ceaa327a0fd8081735f613b3becf1a695) fix(native): preserve indirect branch unknowns without API coverage - [`50b39ef`](https://github.com/morluto/rea/commit/50b39effa77e3cf15c1f821c5608b2a9d705d6ea) fix(workflows): invalidate stale native API inspection snapshots - [`903e659`](https://github.com/morluto/rea/commit/903e6592d8f7f4bc0f64b678b54e233cfb84831f) fix(ghidra): attribute indirect-branch warnings by source address - [`0e18a17`](https://github.com/morluto/rea/commit/0e18a17dbd7c6d8151bfd25ace37f385b3e96915) merge(main): integrate indirect-branch warning attribution with upstream - [`a02741b`](https://github.com/morluto/rea/commit/a02741b2117f99a179bef1f1ca821fff5fd30711) Merge main into feat/487-unresolved-indirect-branches ### 📊 Changes **27 files changed** (+1716 additions, -44 deletions) <details> <summary>View changed files</summary> 📝 `bridge/ghidra/ReaGhidraBridge.java` (+214 -0) 📝 `docs/native-investigation.md` (+26 -1) 📝 `docs/testing-lanes.md` (+6 -0) 📝 `knip.json` (+1 -0) 📝 `scripts/verify-real-ghidra-assertions.mjs` (+3 -0) 📝 `scripts/verify-real-ghidra-function.mjs` (+153 -0) ➕ `scripts/verify-real-ghidra-warning-attribution.mjs` (+191 -0) 📝 `scripts/verify-real-ghidra.mjs` (+57 -25) 📝 `src/application/WorkflowAnalysisProfile.ts` (+5 -3) 📝 `src/application/WorkflowEvidence.test.ts` (+14 -4) 📝 `src/application/binary/AnalysisSnapshotCache.test.ts` (+35 -0) 📝 `src/application/native/NativeApiInspection.test.ts` (+235 -1) 📝 `src/application/native/NativeApiInspection.ts` (+34 -4) 📝 `src/domain/ghidraValues.fixture.ts` (+1 -0) 📝 `src/domain/native/nativeApiBoundary.ts` (+37 -0) 📝 `src/ghidra/GhidraAnalysisProfile.ts` (+1 -0) 📝 `src/ghidra/GhidraFunctionValues.test.ts` (+63 -0) 📝 `src/ghidra/GhidraNativeAnalysisValues.ts` (+11 -0) 📝 `src/ghidra/GhidraProvider.behavior.test.ts` (+4 -0) 📝 `src/ghidra/GhidraRawProfileDigest.test.ts` (+4 -2) _...and 7 more files_ </details> ### 📄 Description ## Summary Part of #487 (second of the steps proposed there; the first was #1592). When Ghidra cannot recover a jump table for a computed `br`, its decompiler rewrites the branch as a call or return (`FlowInfo::truncateIndirectJump`), so nothing reached REA's output: `jump_tables` and `unsupported_branches` stayed empty and the dossier read as complete. This is the gap @CassieFox23 reported for the RenderScript blur kernels. This PR reports those sites in a new optional field, `native_api.unresolved_indirect_branches`, and surfaces each one as a residual unknown in `inspect_native_api`. ## Problem and expected behavior Before: a function whose only dispatch is an unbounded computed jump (`adr x8, …; add x8, x8, x0, lsl #3; br x8`) produces `jump_tables: []`, `unsupported_branches: []`, and no residual unknown. After, for the same function: - `unresolved_indirect_branches: [{ branch_address, decompiler_outcome: "jump_table_recovery_failed", listing_targets: [], evidence: [...] }]`, with Ghidra's warning ("Could not recover jumptable at … Too many branches") retained as evidence - `unsupported_branches: ["indirect-branch-target-recovery"]` - residual unknown: "Which targets can the indirect branch at 0x… reach? The decompiler could not recover a jump table there and treated it as a call; it may be a dispatch inside this function or an indirect tail call." ## Change and scope - Bridge (`ReaGhidraBridge.java`): every listing instruction in the function body whose flow type is a computed jump (not a call or terminator), and whose address is not a recovered `JumpTable` switch, is reported. `decompiler_outcome` is `jump_table_recovery_failed` when the decompiler's pseudocode contains "Could not recover jumptable at <that address>", otherwise `no_jump_table_warning`. `listing_targets` are the listing's flow references (no case values implied). - Schema (`nativeApiBoundary.ts`): new `unresolved_indirect_branches` field using `exactOptional()`: absent means the provider did not inspect indirect branches, so Hopper/IDA output and older snapshots parse unchanged. New evidence kind `indirect-branch`. - Projection (`NativeApiInspection.ts`): one residual unknown per site and `indirect-branch-target-recovery` in `unsupported_branches`. - Ghidra address validation also covers the new addresses. Deliberately not attempted: telling a dispatch apart from an indirect tail call. On real Ghidra 12.1.4 both produce the same recovery failure (see Validation), so REA reports the uncertainty instead of guessing. Not included: the decompiler-failure handling from #487 (a separate follow-up), and sites in regions where Ghidra created no function. ## Contract and boundary impact - Semantic owner and earliest changed stage: Ghidra bridge native API boundary. - CLI and MCP/tool-catalog contract: additive. `analyze_function`/`inspect_native_api` output gains the optional `unresolved_indirect_branches` field, the evidence kind `indirect-branch`, and the `unsupported_branches` value `indirect-branch-target-recovery`. Existing fields are unchanged. - Provider, bridge, target-format, or platform compatibility: Ghidra only; other providers omit the field. - Evidence, artifact, provenance, or reconstruction contract: new facts are listing and decompiler-warning observations with their source recorded; no targets or case values are inferred. - Process execution, authorization, cleanup, or containment impact: none. - Generated metadata (`docs/public/product-catalog.json`), package, or installation impact: output schemas regenerate in CI; no tracked metadata changes. ## Evidence and regression coverage - Tests added or updated: - `NativeApiInspection.test.ts`: residual unknowns and `unsupported_branches` for both outcomes; empty or absent inventory adds nothing; a branch without evidence is rejected. - `GhidraFunctionValues.test.ts`: a non-canonical branch address is rejected. - New real-Ghidra fixture `tests/conformance/ghidra/indirect-branches.S` (unbounded computed jump plus an indirect tail call) in the `aarch64-jump-table` lane, as ELF and, on arm64 hosts, Mach-O. The lane asserts the computed jump is reported and not mistaken for a jump table, and records the tail call's classification. - The existing relative-switch fixtures now assert that recovered switches are not reported as unresolved. - Base reproduction or other evidence: on `main` the field doesn't exist, so the fixture's computed jump leaves no trace in the dossier. - User-visible CLI/MCP output: see "Problem and expected behavior". - Remaining proof gaps: not run against the real `librenderscript-toolkit.so` from the issue thread; no Linux run. For evidence-bearing changes: - [x] Observed, derived, and inferred claims remain distinguishable. - [x] Artifact identity, source provenance, and failed attempts remain preserved. - [x] Unsupported, incomplete, unavailable, or uncertain outcomes remain visible. ## Validation performed - `npm run test:focused -- src/application/native/NativeApiInspection.test.ts src/ghidra/GhidraFunctionValues.test.ts`: passed - `npm run check:fast`: passed - `npm run lint`, `npm run knip`: passed - `npm run docs:check`: passed - `npm run verify:ghidra:aarch64-jump-table` on an Apple Silicon Mac (Ghidra 12.1.4, JDK 21): `ok: true`. - Computed jump: reported as `jump_table_recovery_failed` with the decompiler warning, on ELF (`0x10000c`) and Mach-O (`0xc`). - Indirect tail call: also reported as `jump_table_recovery_failed` ("Could not recover jumptable at … Too many branches") on both formats. - All `relative-*` switch fixtures passed, including the new "not unresolved" check. Note on that run: on current `main` the lane stops early in the host-native native-value check, where `compare_functions` of a function against itself returns `unknown` instead of `unchanged`. Reproduced on `main` without this PR. The `comments` facet is empty with no declared coverage, and `calls` lacks relationship coverage, which looks related to #2450 (`b17487f4`). I skipped that one assertion locally to reach the fixtures this PR adds, and will report it separately. ## Compatibility, safety, and release - Breaking changes or migration steps: none; the field is optional and additive. - Real Hopper/Ghidra, browser, or OS coverage: real Ghidra on macOS arm64 only. - Package or release metadata impact: none. - Security, privacy, process, or containment review: static analysis only; reads the existing listing and decompiler output. ## Review checklist - [x] This PR addresses a concrete problem or an agreed enhancement. - [x] The PR has one focused outcome and the title follows `type(scope): outcome`. - [x] Related issue is linked, or the reason for not linking one is stated above. - [x] Tests cover changed observable behavior and meaningful failure paths. - [x] Owning docs, contracts, and generated metadata are updated where needed. - [x] User-visible CLI/MCP changes include representative output. - [x] I checked the final diff for secrets, unrelated cleanup, and unsupported claims. ## Author readiness Human owner: @RishTheFish21 - [x] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [x] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [x] I declare this PR ready for initial maintainer review. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#5533
No description provided.