[GH-ISSUE #3870] [Bug] Hopper ordinary executable opens can attribute replacement bytes to the original digest #1490

Closed
opened 2026-10-16 21:06:08 +00:00 by bsakaguchi · 3 comments
Owner

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

Before submitting

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

Area

Evidence, artifacts, or reconstruction

Observed behavior

For thin Mach-O, ELF, and PE targets opened through the Hopper provider, the launcher receives only the caller's source path, not the SHA-256 selected at target resolution or a verified private copy. If another local process replaces that file in the window between REA hashing it and the Hopper helper opening it, the bridge reads the replacement bytes while the session still attaches the original digest to the successful result, so the recorded Evidence pairs the replacement's bytes with the original artifact's digest. This is a source-only provenance finding; no Hopper process was launched.

The ordinary executable launch path carries no selected digest, so after a source replacement during the open window read_bytes (and decompilation and other ordinary observations) returns the replacement's bytes while the session binds the original artifact's SHA-256 into the successful result and the recorded Evidence. The address_to_file_offset path already re-verifies the source, but the other provider observations do not.

Reproduction

Source-only proof chain for open_binary with provider_id: "hopper" on a thin Mach-O, ELF, or PE fixture, followed by read_bytes for an address in that image, where another local process replaces the source file with a different valid executable of the same format and architecture after target resolution and before the helper opens it:

  1. The MCP lifecycle handler calls session.open(input.path, ...) at src/server/registerSessionTools.ts:192; target resolution hashes executable A at src/application/BinaryTargetResolver.ts:213-222, returns its path and digest, and closes the file handle, which src/application/binary/BinarySessionOpen.ts:70-89 passes onward.
  2. The session builds the provider client and starts it via health at src/application/binary/BinarySession.ts:319-321; a thin Mach-O gets no preparedImage because preparation is restricted to FAT32/FAT64 without a loader override at src/hopper/HopperAnalysisProfile.ts:54-62, and ELF and PE likewise skip that branch.
  3. src/hopper/HopperProvider.ts:237-249 passes targetPath and targetKind to the launcher but passes target.sha256 only inside the optional prepared-image branch, so the ordinary launcher has no expected digest to validate.
  4. src/hopper/BridgeLauncher.ts:191-202 leaves loadedPath equal to the caller's source path when no prepared image exists, and lines 240-249 supply it as the --executable argument; on a cold macOS install, lines 282-295 first call prepareHopperApplication, which waits 5,000 ms (src/hopper/BridgeLauncher.ts:43 and 574-593) before spawning the helper, giving a concrete window to replace A with a different valid executable B after A's handle is closed and before the helper opens the path.
  5. The bridge binds its document by real path only at bridge/hopper_bridge.py:108-120, and its health reply carries the bridge name, protocol version, run ID, and process ID with no source digest at bridge/hopper_bridge.py:738-744, which src/hopper/HopperClient.ts:440-441 accepts without comparing B's loaded bytes to A's selected digest.
  6. The session retains A's original target at src/application/binary/BinarySession.ts:342-348; read_bytes reads the loaded document at bridge/hopper_bridge.py:787-816 and src/hopper/HopperProvider.ts:309-320 returns the successful result directly, while the source verification in mapHopperFileOffset runs only for address_to_file_offset.
  7. Hopper creates its execution with no subject at src/hopper/HopperProvider.ts:273-306, so src/application/AnalysisProvider.ts:118 supplies subject: null, src/application/binary/BinarySessionExecution.ts:125-147 fills the missing subject with A's retained SHA-256, and src/server/registerOfficialTools.ts:80-105 with src/domain/evidence.ts:387-395 records Evidence whose subject.digest.sha256 is A's digest over B's observed bytes.

Expected behavior

Analysis attributed to the selected digest must describe the bytes of that digest, or the operation must fail closed by reporting that the selected source changed. The selected target is the immutable artifact identity captured before the provider starts.

Impact and scope

The ordinary executable launch path has no selected-digest validation, so a source replacement during the open window can silently bind replacement bytes, names, assembly, or decompilation to the wrong artifact identity and retain misleading Evidence; the gap is established from source, but its real-provider frequency and the race window are unmeasured.

Workaround: available. Analyzing a stable private copy that no other process rebuilds or replaces removes the replacement window, and the default FAT image preparation path is already outside the gap because it copies and checks the original digest; this workaround follows from the mechanism and was not exercised against Hopper.

Reporter's impact estimate: P2 (provisional).

REA version

6.5.0, built from source at main commit 034342ba28 (latest npm release is 6.5.0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1 arm64; This finding is established from source at the tested build. Runtime reproduction was not run.

Target and artifact context

Thin Mach-O, ELF, or PE executable; Evidence v2 subject

Interface

MCP

Earliest failure stage

Setup or provider admission

Relevant output or evidence

  • Expected source: src/application/binary/BinarySession.ts:593 describes the selected target as the immutable artifact identity captured before its provider starts, and AGENTS.md:5 and AGENTS.md:13 require preserving Evidence provenance and the caller-selected digest and metadata.
  • Failure source: the static chain above, in particular src/hopper/HopperAnalysisProfile.ts:54-62 restricting preparation to FAT images, src/hopper/HopperProvider.ts:237-249 passing the digest only inside the optional prepared-image branch, src/hopper/BridgeLauncher.ts:191-202 and 240-249 launching the caller path unchanged, bridge/hopper_bridge.py:738-744 returning a health reply with no source digest, and src/application/binary/BinarySessionExecution.ts:125-147 with src/domain/evidence.ts:387-395 writing the retained original digest into the Evidence subject.
  • Evidence provenance: static
  • Local verification: static-proof
  • Reproduction completeness: complete

Typed outcome or recovery expected

read_bytes and other ordinary observations should have described the bytes of the selected SHA-256 or failed closed reporting the selected source changed, instead of binding the original digest to the replacement's bytes in the recorded Evidence.

Restoration check:

Failing observable: after the source replacement, read_bytes returns the replacement's bytes while the recorded Evidence subject.digest.sha256 equals the original artifact's SHA-256. Passing observable: on the same interleaving, either the provider reads the original bytes or the operation fails closed by reporting that the selected source changed, so no replacement bytes are attributed to the original digest. Control: address_to_file_offset already re-verifies the source through mapHopperFileOffset and would surface the change, showing the intended contract exists but is not applied to read_bytes, decompilation, or other ordinary observations.

Analysis or suspected cause

The ordinary executable launch path never forwards target.sha256 (only the FAT prepared-image branch does at src/hopper/HopperProvider.ts:237-249), BridgeLauncher launches the caller path unchanged (src/hopper/BridgeLauncher.ts:191-202, 240-249) after a 5,000 ms prepare wait (:282-295, :43, :574-593), the Python bridge binds by real path with a digest-free health reply (bridge/hopper_bridge.py:108-120, :738-744), and BinarySessionExecution fills the missing subject with the retained original SHA-256 (src/application/binary/BinarySessionExecution.ts:125-147) written into Evidence at src/domain/evidence.ts:387-395. Only address_to_file_offset uses the source verification in mapHopperFileOffset.

Additional context

No open or closed issue reports this replacement-byte provenance gap. Closed #3072 concerns FAT architecture selection and closed #352 concerns bounded reads and file-offset mapping; open #479 concerns GUI document reuse rather than replacement-byte provenance; the audit tracker #3537 lists those reports but not this failure.

This report came from AI-assisted source review with local reproduction where stated above; the evidence labels distinguish observed runs from source-only reasoning.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3870 ### 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 Evidence, artifacts, or reconstruction ### Observed behavior For thin Mach-O, ELF, and PE targets opened through the Hopper provider, the launcher receives only the caller's source path, not the SHA-256 selected at target resolution or a verified private copy. If another local process replaces that file in the window between REA hashing it and the Hopper helper opening it, the bridge reads the replacement bytes while the session still attaches the original digest to the successful result, so the recorded Evidence pairs the replacement's bytes with the original artifact's digest. This is a source-only provenance finding; no Hopper process was launched. The ordinary executable launch path carries no selected digest, so after a source replacement during the open window `read_bytes` (and decompilation and other ordinary observations) returns the replacement's bytes while the session binds the original artifact's SHA-256 into the successful result and the recorded Evidence. The `address_to_file_offset` path already re-verifies the source, but the other provider observations do not. ### Reproduction Source-only proof chain for `open_binary` with `provider_id: "hopper"` on a thin Mach-O, ELF, or PE fixture, followed by `read_bytes` for an address in that image, where another local process replaces the source file with a different valid executable of the same format and architecture after target resolution and before the helper opens it: 1. The MCP lifecycle handler calls `session.open(input.path, ...)` at src/server/registerSessionTools.ts:192; target resolution hashes executable A at src/application/BinaryTargetResolver.ts:213-222, returns its path and digest, and closes the file handle, which src/application/binary/BinarySessionOpen.ts:70-89 passes onward. 2. The session builds the provider client and starts it via `health` at src/application/binary/BinarySession.ts:319-321; a thin Mach-O gets no `preparedImage` because preparation is restricted to FAT32/FAT64 without a loader override at src/hopper/HopperAnalysisProfile.ts:54-62, and ELF and PE likewise skip that branch. 3. src/hopper/HopperProvider.ts:237-249 passes `targetPath` and `targetKind` to the launcher but passes `target.sha256` only inside the optional prepared-image branch, so the ordinary launcher has no expected digest to validate. 4. src/hopper/BridgeLauncher.ts:191-202 leaves `loadedPath` equal to the caller's source path when no prepared image exists, and lines 240-249 supply it as the `--executable` argument; on a cold macOS install, lines 282-295 first call `prepareHopperApplication`, which waits 5,000 ms (src/hopper/BridgeLauncher.ts:43 and 574-593) before spawning the helper, giving a concrete window to replace A with a different valid executable B after A's handle is closed and before the helper opens the path. 5. The bridge binds its document by real path only at bridge/hopper_bridge.py:108-120, and its health reply carries the bridge name, protocol version, run ID, and process ID with no source digest at bridge/hopper_bridge.py:738-744, which src/hopper/HopperClient.ts:440-441 accepts without comparing B's loaded bytes to A's selected digest. 6. The session retains A's original target at src/application/binary/BinarySession.ts:342-348; `read_bytes` reads the loaded document at bridge/hopper_bridge.py:787-816 and src/hopper/HopperProvider.ts:309-320 returns the successful result directly, while the source verification in `mapHopperFileOffset` runs only for `address_to_file_offset`. 7. Hopper creates its execution with no subject at src/hopper/HopperProvider.ts:273-306, so src/application/AnalysisProvider.ts:118 supplies `subject: null`, src/application/binary/BinarySessionExecution.ts:125-147 fills the missing subject with A's retained SHA-256, and src/server/registerOfficialTools.ts:80-105 with src/domain/evidence.ts:387-395 records Evidence whose `subject.digest.sha256` is A's digest over B's observed bytes. ### Expected behavior Analysis attributed to the selected digest must describe the bytes of that digest, or the operation must fail closed by reporting that the selected source changed. The selected target is the immutable artifact identity captured before the provider starts. ### Impact and scope The ordinary executable launch path has no selected-digest validation, so a source replacement during the open window can silently bind replacement bytes, names, assembly, or decompilation to the wrong artifact identity and retain misleading Evidence; the gap is established from source, but its real-provider frequency and the race window are unmeasured. Workaround: available. Analyzing a stable private copy that no other process rebuilds or replaces removes the replacement window, and the default FAT image preparation path is already outside the gap because it copies and checks the original digest; this workaround follows from the mechanism and was not exercised against Hopper. Reporter's impact estimate: P2 (provisional). ### REA version 6.5.0, built from source at main commit 034342ba28c8e56c3746be92b78aa7299afa02bd (latest npm release is 6.5.0) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1 arm64; This finding is established from source at the tested build. Runtime reproduction was not run. ### Target and artifact context Thin Mach-O, ELF, or PE executable; Evidence v2 subject ### Interface MCP ### Earliest failure stage Setup or provider admission ### Relevant output or evidence - Expected source: src/application/binary/BinarySession.ts:593 describes the selected target as the immutable artifact identity captured before its provider starts, and AGENTS.md:5 and AGENTS.md:13 require preserving Evidence provenance and the caller-selected digest and metadata. - Failure source: the static chain above, in particular src/hopper/HopperAnalysisProfile.ts:54-62 restricting preparation to FAT images, src/hopper/HopperProvider.ts:237-249 passing the digest only inside the optional prepared-image branch, src/hopper/BridgeLauncher.ts:191-202 and 240-249 launching the caller path unchanged, bridge/hopper_bridge.py:738-744 returning a health reply with no source digest, and src/application/binary/BinarySessionExecution.ts:125-147 with src/domain/evidence.ts:387-395 writing the retained original digest into the Evidence subject. - Evidence provenance: static - Local verification: static-proof - Reproduction completeness: complete ### Typed outcome or recovery expected read_bytes and other ordinary observations should have described the bytes of the selected SHA-256 or failed closed reporting the selected source changed, instead of binding the original digest to the replacement's bytes in the recorded Evidence. Restoration check: Failing observable: after the source replacement, `read_bytes` returns the replacement's bytes while the recorded Evidence `subject.digest.sha256` equals the original artifact's SHA-256. Passing observable: on the same interleaving, either the provider reads the original bytes or the operation fails closed by reporting that the selected source changed, so no replacement bytes are attributed to the original digest. Control: `address_to_file_offset` already re-verifies the source through `mapHopperFileOffset` and would surface the change, showing the intended contract exists but is not applied to `read_bytes`, decompilation, or other ordinary observations. ### Analysis or suspected cause The ordinary executable launch path never forwards target.sha256 (only the FAT prepared-image branch does at src/hopper/HopperProvider.ts:237-249), BridgeLauncher launches the caller path unchanged (src/hopper/BridgeLauncher.ts:191-202, 240-249) after a 5,000 ms prepare wait (:282-295, :43, :574-593), the Python bridge binds by real path with a digest-free health reply (bridge/hopper_bridge.py:108-120, :738-744), and BinarySessionExecution fills the missing subject with the retained original SHA-256 (src/application/binary/BinarySessionExecution.ts:125-147) written into Evidence at src/domain/evidence.ts:387-395. Only address_to_file_offset uses the source verification in mapHopperFileOffset. ### Additional context No open or closed issue reports this replacement-byte provenance gap. Closed #3072 concerns FAT architecture selection and closed #352 concerns bounded reads and file-offset mapping; open #479 concerns GUI document reuse rather than replacement-byte provenance; the audit tracker #3537 lists those reports but not this failure. This report came from AI-assisted source review with local reproduction where stated above; the evidence labels distinguish observed runs from source-only reasoning.
Author
Owner

@kaoru0822-kitauji commented on GitHub (Oct 17, 2026):

Reproduced on main 9c56d09c0, at the launcher boundary only. A fake-launcher test following tests/boundary/providers/hopper/bridgeLauncher.test.ts: the fake Hopper (node) hashes the file named by --executable. The test hashes target A, replaces the file with B, then calls HopperApplicationLauncher.launch. The launch succeeds and the fake Hopper reads B (e230ca23…e6f4) instead of the selected A (25f0f1a3…fcb1); both digests were checked with shasum. The test is not committed.

Source-derived cause: for thin Mach-O/ELF/PE targets, HopperProvider.createClient only passes target.sha256 in the FAT prepared-image branch. HopperApplicationLauncher.launch then gives Hopper the live source path with no expected digest, and nothing compares the bytes Hopper loads against the session's SHA-256.

I am not opening a PR because the sound fix looks like a scope decision:

  1. Private digest-verified copy for every executable (as Ghidra's createGhidraTargetSnapshot, or the existing FAT prepareHopperMachOImage path). This closes the gap, but prepared-image launches currently always close their Hopper document at shutdown (HopperCleanup.ts). That would change the documented "Hopper keeps its document open" behaviour (docs/installation.md), show private paths in list_documents, and copy every binary.
  2. Re-hash the source after Hopper opens it. This keeps the current behaviour but is unsound: A→B→A goes undetected, and Hopper's read timing is unknown.
  3. Bridge-reported digest of the loaded bytes. This depends on a Hopper API that needs real-Hopper confirmation.

No real Hopper or full open_binary→Evidence MCP flow was run; the Evidence attribution step is source-derived.

<!-- gh-comment-id:6233156998 --> @kaoru0822-kitauji commented on GitHub (Oct 17, 2026): Reproduced on main 9c56d09c0, at the launcher boundary only. A fake-launcher test following `tests/boundary/providers/hopper/bridgeLauncher.test.ts`: the fake Hopper (node) hashes the file named by `--executable`. The test hashes target A, replaces the file with B, then calls `HopperApplicationLauncher.launch`. The launch succeeds and the fake Hopper reads B (`e230ca23…e6f4`) instead of the selected A (`25f0f1a3…fcb1`); both digests were checked with `shasum`. The test is not committed. Source-derived cause: for thin Mach-O/ELF/PE targets, `HopperProvider.createClient` only passes `target.sha256` in the FAT prepared-image branch. `HopperApplicationLauncher.launch` then gives Hopper the live source path with no expected digest, and nothing compares the bytes Hopper loads against the session's SHA-256. I am not opening a PR because the sound fix looks like a scope decision: 1. **Private digest-verified copy for every executable** (as Ghidra's `createGhidraTargetSnapshot`, or the existing FAT `prepareHopperMachOImage` path). This closes the gap, but prepared-image launches currently always close their Hopper document at shutdown (`HopperCleanup.ts`). That would change the documented "Hopper keeps its document open" behaviour (`docs/installation.md`), show private paths in `list_documents`, and copy every binary. 2. **Re-hash the source after Hopper opens it.** This keeps the current behaviour but is unsound: A→B→A goes undetected, and Hopper's read timing is unknown. 3. **Bridge-reported digest of the loaded bytes.** This depends on a Hopper API that needs real-Hopper confirmation. No real Hopper or full `open_binary`→Evidence MCP flow was run; the Evidence attribution step is source-derived.
Author
Owner

@faizansaiyed123 commented on GitHub (Oct 19, 2026):

@kaoru0822-kitauji I’d like to take this one.

I’m going to keep it focused on the provenance gap for ordinary Hopper launches: make sure the bytes Hopper actually opens cannot be associated with the wrong SHA-256, add a regression test for the replacement race, and keep the existing prepared-image path intact.

I’ll first verify the right boundary for checking the loaded artifact so the fix is sound rather than just re-hashing the source afterward. I’ll also run the relevant focused tests and report any real Hopper/platform coverage separately.

I’ll keep unrelated Hopper changes out of the PR.

<!-- gh-comment-id:6258675056 --> @faizansaiyed123 commented on GitHub (Oct 19, 2026): @kaoru0822-kitauji I’d like to take this one. I’m going to keep it focused on the provenance gap for ordinary Hopper launches: make sure the bytes Hopper actually opens cannot be associated with the wrong SHA-256, add a regression test for the replacement race, and keep the existing prepared-image path intact. I’ll first verify the right boundary for checking the loaded artifact so the fix is sound rather than just re-hashing the source afterward. I’ll also run the relevant focused tests and report any real Hopper/platform coverage separately. I’ll keep unrelated Hopper changes out of the PR.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

Checked the reported fix on main at d87e73f2db.

HopperProvider now supplies selected target.sha256 for every executable. Launcher prepares an ordinary private image when no Mach-O slice preparation is needed, verifies its copied digest before launch, and launches that loadedPath. Replacing source after copy cannot change loaded bytes; replacement before/during copy is refused on digest mismatch.

Implementation: HopperProvider.ts, BridgeLauncher.ts.

The relevant regression checks in bridgeLauncher.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380769127 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. HopperProvider now supplies selected target.sha256 for every executable. Launcher prepares an ordinary private image when no Mach-O slice preparation is needed, verifies its copied digest before launch, and launches that loadedPath. Replacing source after copy cannot change loaded bytes; replacement before/during copy is refused on digest mismatch. Implementation: [HopperProvider.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/hopper/HopperProvider.ts#L285), [BridgeLauncher.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/hopper/BridgeLauncher.ts#L294). The relevant regression checks in [bridgeLauncher.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/tests/boundary/providers/hopper/bridgeLauncher.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
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#1490
No description provided.