[PR #1592] [MERGED] feat(ghidra): recover signed AArch64 relative jump tables #2992

Closed
opened 2026-10-17 21:12:39 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/1592
Author: @RishTheFish21
Created: 10/10/2026
Status: ✅ Merged
Merged: 10/11/2026
Merged by: @morluto

Base: main ← Head: fix/487-signed-relative-tables


📝 Commits (4)

  • 8ab1453 feat(ghidra): recover signed AArch64 relative jump tables
  • c7bd2cb chore: merge main into signed relative table recovery
  • 4ca6d8e chore: sync latest main after conflict resolution
  • df04a99 fix(ghidra): preserve typed labels for normalized switch selectors

📊 Changes

6 files changed (+649 additions, -210 deletions)

View changed files

📝 bridge/ghidra/ReaGhidraBridge.java (+389 -184)
📝 docs/testing.md (+9 -9)
📝 scripts/verify-real-ghidra-function.mjs (+107 -0)
📝 scripts/verify-real-ghidra.mjs (+53 -17)
📝 tests/conformance/ghidra/README.md (+8 -0)
➕ tests/conformance/ghidra/relative-switch-signed.S (+83 -0)

📄 Description

Summary

Part of #487. REA's AArch64 relative jump-table matcher only recognised one shape: unsigned ldrb/ldrh entries, lsl #2 offsets and a b.hi bound. Hand-written code like the RenderScript blend kernel from the issue thread uses ldrsh, a uxtw index, plain byte offsets, a b.cs bound and a cbz that skips zero entries. This PR extends the matcher to those forms, still only when the bound, registers and table bytes check out exactly.

Problem and expected behavior

On the new fixture (same instruction shape as rsdIntrinsicBlend_K), main maps cases 0, 1, 2 and 4 from Ghidra's typed case labels. Case 3 is a zero entry that the cbz sends to its own path. Ghidra doesn't model the cbz, so it decodes that entry as a branch to the table's base address, which the br can never reach. REA then reported the base as a target with an unknown case, and case 3's real destination was missing.

Expected: every case maps to the code it actually reaches, with evidence, and anything REA can't prove stays unknown.

Change and scope

  • The matcher accepts signed byte/halfword entries, uxtw indexes, byte or lsl #2 offsets, b.hi or b.cs/b.hs bounds, and an optional cbz on the loaded entry. It still requires one straight-line path with no other branches into it, and an index bounded by the compare.
  • It now also runs when Ghidra exposes its own load table. Its result is only used when both agree on table address, entry size and count; otherwise Ghidra's view is kept unchanged.
  • Zero entries routed by cbz map to the cbz target. The base address Ghidra decodes for them is no longer listed as an unknown target; a limitation explains why it's unreachable.
  • New fixture tests/conformance/ghidra/relative-switch-signed.S, wired into the aarch64-jump-table lane as ELF, plus Mach-O on arm64 hosts.

Not included: reporting computed branches Ghidra can't resolve, and returning a partial dossier when the decompiler fails. Both are planned as a follow-up.

Contract and boundary impact

  • Semantic owner and earliest changed stage: Ghidra bridge (ReaGhidraBridge.java), jump-table inference.
  • CLI and MCP/tool-catalog contract: none. No schema changes; only evidence and limitation text and which mappings are filled in.
  • Provider, bridge, target-format, or platform compatibility: AArch64 only; other architectures are untouched.
  • Evidence, artifact, provenance, or reconstruction contract: uses the existing provenance values. More cases can now be mapped with high confidence; a decoded result never overrides Ghidra when they disagree.
  • Process execution, authorization, cleanup, or containment impact: none.
  • Generated metadata, package, or installation impact: none.

Evidence and regression coverage

  • Tests added or updated: the new fixture and verifyRelativeSignedSwitch, which checks every case target against the immediate written in the source.
  • Base reproduction or other evidence: with the matcher not running (current main behavior when Ghidra exposes a load table), the new check fails with Signed relative switch case 3 was not verified, and 0x100030 is reported as an unlabelled target.
  • User-visible CLI/MCP output: inspect_native_api now reports case 3 → the cbz target with high confidence, plus a limitation naming the unreachable base address.
  • Remaining proof gaps: see Validation.

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 verify:ghidra:aarch64-jump-table on an Apple Silicon Mac (Ghidra 12.1.4, JDK 21, decompiler built with buildNatives): passed. relative-signed was verified for ELF and Mach-O; the existing relative-1/relative-2 fixtures and the cross-format AArch64 fixture still pass.
  • npm run check:

I ran the lane with the entry-alias boundary check skipped locally (not part of this PR). That check already fails on macOS arm64 on main: the fixture's interior label lands on the function entry, and even when moved, the Mach-O linker records it as a function start, so Ghidra makes it a function. I'll open a separate issue. Because of the skip, the imported_entry_alias_selection: true in the report is not evidence. Not run against the real librenderscript-toolkit.so, only against a fixture with the same shape. Not run on Linux.

Compatibility, safety, and release

  • Breaking changes or migration steps: none.
  • Real Hopper/Ghidra, browser, or OS coverage: real Ghidra 12.1.4 on macOS arm64 (see above). No Linux run.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: static analysis only; no new process or file access.

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.

🔄 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/1592 **Author:** [@RishTheFish21](https://github.com/RishTheFish21) **Created:** 10/10/2026 **Status:** ✅ Merged **Merged:** 10/11/2026 **Merged by:** [@morluto](https://github.com/morluto) **Base:** `main` ← **Head:** `fix/487-signed-relative-tables` --- ### 📝 Commits (4) - [`8ab1453`](https://github.com/morluto/rea/commit/8ab1453e513b16d964975601aca1a6c35057f6ab) feat(ghidra): recover signed AArch64 relative jump tables - [`c7bd2cb`](https://github.com/morluto/rea/commit/c7bd2cbb5d862e7996a29d89bfe9a53adb5ffc57) chore: merge main into signed relative table recovery - [`4ca6d8e`](https://github.com/morluto/rea/commit/4ca6d8e43bf923c2648b0c3ba636a6600a389607) chore: sync latest main after conflict resolution - [`df04a99`](https://github.com/morluto/rea/commit/df04a99f204621fa5175b7d7cdcb98b2ca2846f4) fix(ghidra): preserve typed labels for normalized switch selectors ### 📊 Changes **6 files changed** (+649 additions, -210 deletions) <details> <summary>View changed files</summary> 📝 `bridge/ghidra/ReaGhidraBridge.java` (+389 -184) 📝 `docs/testing.md` (+9 -9) 📝 `scripts/verify-real-ghidra-function.mjs` (+107 -0) 📝 `scripts/verify-real-ghidra.mjs` (+53 -17) 📝 `tests/conformance/ghidra/README.md` (+8 -0) ➕ `tests/conformance/ghidra/relative-switch-signed.S` (+83 -0) </details> ### 📄 Description ## Summary Part of #487. REA's AArch64 relative jump-table matcher only recognised one shape: unsigned `ldrb`/`ldrh` entries, `lsl #2` offsets and a `b.hi` bound. Hand-written code like the RenderScript blend kernel from the issue thread uses `ldrsh`, a `uxtw` index, plain byte offsets, a `b.cs` bound and a `cbz` that skips zero entries. This PR extends the matcher to those forms, still only when the bound, registers and table bytes check out exactly. ## Problem and expected behavior On the new fixture (same instruction shape as `rsdIntrinsicBlend_K`), `main` maps cases 0, 1, 2 and 4 from Ghidra's typed case labels. Case 3 is a zero entry that the `cbz` sends to its own path. Ghidra doesn't model the `cbz`, so it decodes that entry as a branch to the table's base address, which the `br` can never reach. REA then reported the base as a target with an unknown case, and case 3's real destination was missing. Expected: every case maps to the code it actually reaches, with evidence, and anything REA can't prove stays unknown. ## Change and scope - The matcher accepts signed byte/halfword entries, `uxtw` indexes, byte or `lsl #2` offsets, `b.hi` or `b.cs`/`b.hs` bounds, and an optional `cbz` on the loaded entry. It still requires one straight-line path with no other branches into it, and an index bounded by the compare. - It now also runs when Ghidra exposes its own load table. Its result is only used when both agree on table address, entry size and count; otherwise Ghidra's view is kept unchanged. - Zero entries routed by `cbz` map to the `cbz` target. The base address Ghidra decodes for them is no longer listed as an unknown target; a limitation explains why it's unreachable. - New fixture `tests/conformance/ghidra/relative-switch-signed.S`, wired into the `aarch64-jump-table` lane as ELF, plus Mach-O on arm64 hosts. Not included: reporting computed branches Ghidra can't resolve, and returning a partial dossier when the decompiler fails. Both are planned as a follow-up. ## Contract and boundary impact - Semantic owner and earliest changed stage: Ghidra bridge (`ReaGhidraBridge.java`), jump-table inference. - CLI and MCP/tool-catalog contract: none. No schema changes; only evidence and limitation text and which mappings are filled in. - Provider, bridge, target-format, or platform compatibility: AArch64 only; other architectures are untouched. - Evidence, artifact, provenance, or reconstruction contract: uses the existing provenance values. More cases can now be mapped with high confidence; a decoded result never overrides Ghidra when they disagree. - Process execution, authorization, cleanup, or containment impact: none. - Generated metadata, package, or installation impact: none. ## Evidence and regression coverage - Tests added or updated: the new fixture and `verifyRelativeSignedSwitch`, which checks every case target against the immediate written in the source. - Base reproduction or other evidence: with the matcher not running (current `main` behavior when Ghidra exposes a load table), the new check fails with `Signed relative switch case 3 was not verified`, and `0x100030` is reported as an unlabelled target. - User-visible CLI/MCP output: `inspect_native_api` now reports case 3 → the `cbz` target with high confidence, plus a limitation naming the unreachable base address. - Remaining proof gaps: see Validation. 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 verify:ghidra:aarch64-jump-table` on an Apple Silicon Mac (Ghidra 12.1.4, JDK 21, decompiler built with `buildNatives`): passed. `relative-signed` was verified for ELF and Mach-O; the existing `relative-1`/`relative-2` fixtures and the cross-format AArch64 fixture still pass. - `npm run check`: <!-- result --> I ran the lane with the entry-alias boundary check skipped locally (not part of this PR). That check already fails on macOS arm64 on `main`: the fixture's interior label lands on the function entry, and even when moved, the Mach-O linker records it as a function start, so Ghidra makes it a function. I'll open a separate issue. Because of the skip, the `imported_entry_alias_selection: true` in the report is not evidence. Not run against the real `librenderscript-toolkit.so`, only against a fixture with the same shape. Not run on Linux. ## Compatibility, safety, and release - Breaking changes or migration steps: none. - Real Hopper/Ghidra, browser, or OS coverage: real Ghidra 12.1.4 on macOS arm64 (see above). No Linux run. - Package or release metadata impact: none. - Security, privacy, process, or containment review: static analysis only; no new process or file access. ## 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. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-17 21:12:39 +00:00
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#2992
No description provided.