[GH-ISSUE #656] [Bug] Ghidra provider analyzes the x86_64 slice of a universal Mach-O while reporting arm64 #311

Closed
opened 2026-10-15 18:23:45 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 6, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/656

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

On an arm64 macOS host, the Ghidra provider analyzes the x86_64 slice of a universal (FAT) Mach-O that also contains an arm64 slice, while every result records the target and the analysis profile as arm64. analyze, decompile, and inspect-native-instruction therefore return x86_64 segments, pseudocode, and instructions under an arm64 label, and none of these commands offers a way to choose the slice. A copy thinned to the arm64e slice with lipo is analyzed correctly.

All runs below exited 0. Results for the universal file (analyze took about 10 s):

subject.architecture = arm64
analysis_profile...upstream_analysis_profile.parameters.architecture = arm64
analysis_profile...upstream_analysis_profile.parameters.available_architectures = ["arm64","x86_64"]
analysis_profile...upstream_analysis_profile.parameters.language_id = auto-from-header
normalized_result.procedure_count = 220
segments (excerpt): __text@0x100000718 __stubs@0x10000420f __stub_helper@0x100004408 __const@0x100004810 __cstring@0x100004933 __unwind_info@0x100004e38 __la_symbol_ptr@0x100005000

These addresses match otool -l -arch x86_64 exactly (__text 0x100000718, __stubs 0x10000420f, __stub_helper 0x100004408, __la_symbol_ptr 0x100005000), not the arm64e slice.

inspect-native-instruction on the universal file:

0x100000978 (x86_64 LC_MAIN entry): subject.architecture=arm64  normalized_result.architecture=x86:LE:64:default  bytes=55  mnemonic=PUSH (RBP)
0x100000960 (arm64e LC_MAIN entry): subject.architecture=arm64  normalized_result.architecture=x86:LE:64:default  mnemonic=JGE

decompile ./ls-universal 0x100000960 returned int FUN_10000093d(long param_1,long param_2), an x86_64 function, rather than the arm64e entry point.

Thinned arm64e control, same builds and environment:

subject.architecture = arm64, procedure_count = 218
segments (excerpt): __text@0x100000700 __auth_stubs@0x100004318 __const@0x100004858 ...
inspect-native-instruction 0x100000960: normalized_result.architecture=AARCH64:LE:64:AppleSilicon  bytes=7f2303d5  mnemonic=pacibsp
decompile 0x100000960: void entry(undefined8 param_1,long param_2)

rea analyze --help, rea decompile --help, and rea inspect-native-instruction --help list only --snapshot and/or --provider as command options; there is no architecture, slice, or provider-options input.

Reproduction

  1. Install the Ghidra release and JDK listed under Runtime and platform, build the Ghidra natives, then export GHIDRA_INSTALL_DIR and JAVA_HOME so that rea doctor --provider ghidra passes.
  2. Prepare the universal target and a thinned control:
cp /bin/ls ./ls-universal
lipo ./ls-universal -thin arm64e -output ./ls-arm64e
lipo -archs ./ls-universal   # x86_64 arm64e arm64e.x1
otool -l -arch x86_64 ./ls-universal | grep -A3 LC_MAIN   # entryoff 2424 -> 0x100000978
otool -l -arch arm64e ./ls-universal | grep -A3 LC_MAIN   # entryoff 2400 -> 0x100000960
  1. Run the Ghidra provider on both files:
rea analyze ./ls-universal --provider ghidra --json
rea analyze ./ls-arm64e --provider ghidra --json
rea inspect-native-instruction ./ls-universal 0x100000978 --provider ghidra --json
rea inspect-native-instruction ./ls-universal 0x100000960 --provider ghidra --json
rea inspect-native-instruction ./ls-arm64e 0x100000960 --provider ghidra --json
rea decompile ./ls-universal 0x100000960 --provider ghidra --json
rea decompile ./ls-arm64e 0x100000960 --provider ghidra --json
  1. Compare subject.architecture, analysis_profile.parameters.upstream_analysis_profile.parameters.architecture, the analyze output's normalized_result.segments (Ghidra memory blocks, which include Mach-O sections), and the instruction output's normalized_result.architecture with the section layouts printed by otool -l -arch x86_64 and otool -l -arch arm64e.

Expected behavior

The evidence for a universal target should describe the slice REA records as the selected architecture. docs/adr/0001-provider-selection-and-analysis-profiles.md §5 says BinaryTarget carries the "selected architecture, and available architectures"; §6 says the selected adapter "normalizes every semantic input that can affect analysis", naming the Ghidra language ID as an example, says "implicit host-dependent choices are resolved", and requires that profile resolution "complete before the provider imports the target"; §7 keys snapshot reuse on "target digest, generic format and kind, and selected architecture". For this target REA selects arm64 (it reports architecture: "arm64" with available_architectures: ["arm64", "x86_64"]; both arm64e and arm64e.x1 map to arm64), so segments, pseudocode, and instructions should come from an arm64-family slice. If the provider cannot import the selected slice, it should return a typed refusal instead of labelling another slice's results as arm64.

Impact and scope

  • Impact level: P1
  • Assessment status: supported
  • Impact basis: For the tested universal binary, on both tested builds, Ghidra-backed analyze, decompile, and instruction inspection (a core workflow) silently returned x86_64 results labelled arm64, with exit 0 and no recorded limitation, so callers and agents can draw wrong conclusions about the code they believe they analyzed; the harm is limited to universal Mach-O inputs with an x86_64 slice on arm64 hosts. That other universal binaries behave the same is an inference from the import path passing no slice choice, not a separate observation.
  • Workaround status: available
  • Workaround basis: Thinning a copy with lipo <file> -thin arm64e -output <copy> before analysis was observed to produce correct, consistently labelled results using only the Xcode command-line tools and without changing the original file; it helps only callers who already know about the defect, and the resulting evidence names the thinned copy's digest rather than the original artifact's.

REA version

rea-agents 4.0.1 from npm (latest; tag rea-agents-4.0.1, commit feeb19b8b3172b2827b80fde8f09570ce5aeed88), and a local build of main at 19f8cddfd6f283188567b8ff1e49b810d79952ee; both builds produced the results in this report.

Runtime and platform

Ghidra 12.1.4 PUBLIC (ghidra_12.1.4_PUBLIC_20260921.zip, SHA-256 ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db) with macOS arm64 natives built by support/gradle/gradlew buildNatives; Homebrew openjdk@21 21.0.12.1. Node.js 24.21.0 on macOS 27.0.1, Apple silicon (arm64 host).

Target and artifact context

A copy of the system /bin/ls from macOS 27.0.1 build 26A434 (SHA-256 13f44bfe1c4af602da4465c834dbe7b16a5441ccbd843178b36116ee23422bfb), a universal Mach-O whose lipo -archs output is x86_64 arm64e arm64e.x1. The addresses in this report are specific to this file; on another macOS build, re-derive them with the otool commands in step 2.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

  • Expected source: docs/adr/0001-provider-selection-and-analysis-profiles.md §5 ("Keep provider open options adapter-owned"), §6 ("Commit a canonical analysis profile"), and §7 ("Make snapshots provider- and profile-exact").
  • Failure source: local runs of both tested builds against a copy of /bin/ls, with the section, language, and pseudocode output quoted under Observed behavior.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Typed outcome or recovery expected

With the same universal copy of /bin/ls on an arm64 host, a Ghidra-backed result that records architecture: "arm64" in the subject and profile must come from an arm64-family slice: analyze reports __text at 0x100000700 and an __auth_stubs segment, with no __stubs, __stub_helper, or __la_symbol_ptr; inspect-native-instruction ... 0x100000960 reports an AARCH64 language (the thinned arm64e control shows pacibsp; the arm64e.x1 slice has pacibsppc there); and decompile ... 0x100000960 returns the entry function. A typed refusal to analyze the universal file also restores the contract. A result labelled arm64 that contains x86:LE:64 instructions or the x86_64 section layout does not, and the thinned arm64e control must keep producing the results shown above.

Analysis or suspected cause

Source-derived reasoning, not a verified cause or a prescribed fix. At the release tag, src/domain/binaryTarget.ts lines 87-103 choose arm64 for a FAT target on an arm64 host from the FAT table (lines 205-216 map CPU type 0x0100000c, which includes arm64e, to arm64); src/ghidra/GhidraLauncher.ts line 273 passes the target path to analyzeHeadless -import with -loader/-processor arguments only for DOS MZ targets, so nothing tells Ghidra which slice of a universal Mach-O to import. Neither file changed in this respect on main. The profile also records loader: auto-from-header and language_id: auto-from-header, so the slice choice is left to Ghidra rather than resolved in the profile. The native provider's inspect-macho behavior on universal binaries is out of scope here; this report covers only the Ghidra slice choice and label.

Originally created by @coygeek on GitHub (Oct 6, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/656 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior On an arm64 macOS host, the Ghidra provider analyzes the `x86_64` slice of a universal (FAT) Mach-O that also contains an arm64 slice, while every result records the target and the analysis profile as `arm64`. `analyze`, `decompile`, and `inspect-native-instruction` therefore return x86_64 segments, pseudocode, and instructions under an `arm64` label, and none of these commands offers a way to choose the slice. A copy thinned to the arm64e slice with `lipo` is analyzed correctly. All runs below exited 0. Results for the universal file (analyze took about 10 s): ```text subject.architecture = arm64 analysis_profile...upstream_analysis_profile.parameters.architecture = arm64 analysis_profile...upstream_analysis_profile.parameters.available_architectures = ["arm64","x86_64"] analysis_profile...upstream_analysis_profile.parameters.language_id = auto-from-header normalized_result.procedure_count = 220 segments (excerpt): __text@0x100000718 __stubs@0x10000420f __stub_helper@0x100004408 __const@0x100004810 __cstring@0x100004933 __unwind_info@0x100004e38 __la_symbol_ptr@0x100005000 ``` These addresses match `otool -l -arch x86_64` exactly (`__text` 0x100000718, `__stubs` 0x10000420f, `__stub_helper` 0x100004408, `__la_symbol_ptr` 0x100005000), not the arm64e slice. `inspect-native-instruction` on the universal file: ```text 0x100000978 (x86_64 LC_MAIN entry): subject.architecture=arm64 normalized_result.architecture=x86:LE:64:default bytes=55 mnemonic=PUSH (RBP) 0x100000960 (arm64e LC_MAIN entry): subject.architecture=arm64 normalized_result.architecture=x86:LE:64:default mnemonic=JGE ``` `decompile ./ls-universal 0x100000960` returned `int FUN_10000093d(long param_1,long param_2)`, an x86_64 function, rather than the arm64e entry point. Thinned arm64e control, same builds and environment: ```text subject.architecture = arm64, procedure_count = 218 segments (excerpt): __text@0x100000700 __auth_stubs@0x100004318 __const@0x100004858 ... inspect-native-instruction 0x100000960: normalized_result.architecture=AARCH64:LE:64:AppleSilicon bytes=7f2303d5 mnemonic=pacibsp decompile 0x100000960: void entry(undefined8 param_1,long param_2) ``` `rea analyze --help`, `rea decompile --help`, and `rea inspect-native-instruction --help` list only `--snapshot` and/or `--provider` as command options; there is no architecture, slice, or provider-options input. ### Reproduction 1. Install the Ghidra release and JDK listed under Runtime and platform, build the Ghidra natives, then export `GHIDRA_INSTALL_DIR` and `JAVA_HOME` so that `rea doctor --provider ghidra` passes. 2. Prepare the universal target and a thinned control: ```bash cp /bin/ls ./ls-universal lipo ./ls-universal -thin arm64e -output ./ls-arm64e lipo -archs ./ls-universal # x86_64 arm64e arm64e.x1 otool -l -arch x86_64 ./ls-universal | grep -A3 LC_MAIN # entryoff 2424 -> 0x100000978 otool -l -arch arm64e ./ls-universal | grep -A3 LC_MAIN # entryoff 2400 -> 0x100000960 ``` 3. Run the Ghidra provider on both files: ```bash rea analyze ./ls-universal --provider ghidra --json rea analyze ./ls-arm64e --provider ghidra --json rea inspect-native-instruction ./ls-universal 0x100000978 --provider ghidra --json rea inspect-native-instruction ./ls-universal 0x100000960 --provider ghidra --json rea inspect-native-instruction ./ls-arm64e 0x100000960 --provider ghidra --json rea decompile ./ls-universal 0x100000960 --provider ghidra --json rea decompile ./ls-arm64e 0x100000960 --provider ghidra --json ``` 4. Compare `subject.architecture`, `analysis_profile.parameters.upstream_analysis_profile.parameters.architecture`, the analyze output's `normalized_result.segments` (Ghidra memory blocks, which include Mach-O sections), and the instruction output's `normalized_result.architecture` with the section layouts printed by `otool -l -arch x86_64` and `otool -l -arch arm64e`. ### Expected behavior The evidence for a universal target should describe the slice REA records as the selected architecture. `docs/adr/0001-provider-selection-and-analysis-profiles.md` §5 says `BinaryTarget` carries the "selected architecture, and available architectures"; §6 says the selected adapter "normalizes every semantic input that can affect analysis", naming the Ghidra language ID as an example, says "implicit host-dependent choices are resolved", and requires that profile resolution "complete before the provider imports the target"; §7 keys snapshot reuse on "target digest, generic format and kind, and selected architecture". For this target REA selects `arm64` (it reports `architecture: "arm64"` with `available_architectures: ["arm64", "x86_64"]`; both `arm64e` and `arm64e.x1` map to `arm64`), so segments, pseudocode, and instructions should come from an arm64-family slice. If the provider cannot import the selected slice, it should return a typed refusal instead of labelling another slice's results as `arm64`. ### Impact and scope - Impact level: P1 - Assessment status: supported - Impact basis: For the tested universal binary, on both tested builds, Ghidra-backed analyze, decompile, and instruction inspection (a core workflow) silently returned x86_64 results labelled `arm64`, with exit 0 and no recorded limitation, so callers and agents can draw wrong conclusions about the code they believe they analyzed; the harm is limited to universal Mach-O inputs with an x86_64 slice on arm64 hosts. That other universal binaries behave the same is an inference from the import path passing no slice choice, not a separate observation. - Workaround status: available - Workaround basis: Thinning a copy with `lipo <file> -thin arm64e -output <copy>` before analysis was observed to produce correct, consistently labelled results using only the Xcode command-line tools and without changing the original file; it helps only callers who already know about the defect, and the resulting evidence names the thinned copy's digest rather than the original artifact's. ### REA version `rea-agents` 4.0.1 from npm (`latest`; tag `rea-agents-4.0.1`, commit `feeb19b8b3172b2827b80fde8f09570ce5aeed88`), and a local build of `main` at `19f8cddfd6f283188567b8ff1e49b810d79952ee`; both builds produced the results in this report. ### Runtime and platform Ghidra 12.1.4 PUBLIC (`ghidra_12.1.4_PUBLIC_20260921.zip`, SHA-256 `ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db`) with macOS arm64 natives built by `support/gradle/gradlew buildNatives`; Homebrew `openjdk@21` 21.0.12.1. Node.js 24.21.0 on macOS 27.0.1, Apple silicon (arm64 host). ### Target and artifact context A copy of the system `/bin/ls` from macOS 27.0.1 build 26A434 (SHA-256 `13f44bfe1c4af602da4465c834dbe7b16a5441ccbd843178b36116ee23422bfb`), a universal Mach-O whose `lipo -archs` output is `x86_64 arm64e arm64e.x1`. The addresses in this report are specific to this file; on another macOS build, re-derive them with the `otool` commands in step 2. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence - Expected source: `docs/adr/0001-provider-selection-and-analysis-profiles.md` §5 ("Keep provider open options adapter-owned"), §6 ("Commit a canonical analysis profile"), and §7 ("Make snapshots provider- and profile-exact"). - Failure source: local runs of both tested builds against a copy of `/bin/ls`, with the section, language, and pseudocode output quoted under Observed behavior. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete ### Typed outcome or recovery expected With the same universal copy of `/bin/ls` on an arm64 host, a Ghidra-backed result that records `architecture: "arm64"` in the subject and profile must come from an arm64-family slice: `analyze` reports `__text` at 0x100000700 and an `__auth_stubs` segment, with no `__stubs`, `__stub_helper`, or `__la_symbol_ptr`; `inspect-native-instruction ... 0x100000960` reports an `AARCH64` language (the thinned arm64e control shows `pacibsp`; the `arm64e.x1` slice has `pacibsppc` there); and `decompile ... 0x100000960` returns the entry function. A typed refusal to analyze the universal file also restores the contract. A result labelled `arm64` that contains `x86:LE:64` instructions or the x86_64 section layout does not, and the thinned arm64e control must keep producing the results shown above. ### Analysis or suspected cause Source-derived reasoning, not a verified cause or a prescribed fix. At the release tag, `src/domain/binaryTarget.ts` lines 87-103 choose `arm64` for a FAT target on an arm64 host from the FAT table (lines 205-216 map CPU type `0x0100000c`, which includes arm64e, to `arm64`); `src/ghidra/GhidraLauncher.ts` line 273 passes the target path to `analyzeHeadless -import` with `-loader`/`-processor` arguments only for DOS MZ targets, so nothing tells Ghidra which slice of a universal Mach-O to import. Neither file changed in this respect on `main`. The profile also records `loader: auto-from-header` and `language_id: auto-from-header`, so the slice choice is left to Ghidra rather than resolved in the profile. The native provider's `inspect-macho` behavior on universal binaries is out of scope here; this report covers only the Ghidra slice choice and label.
bsakaguchi 2026-10-15 18:23:45 +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#311
No description provided.