[GH-ISSUE #4313] [Feature] Mark encrypted Mach-O ranges as unexamined in provider analysis results #1719

Open
opened 2026-10-16 21:09:24 +00:00 by bsakaguchi · 3 comments
Owner

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

Before submitting

  • I searched the open and closed issues and pull requests for a related proposal.
  • I understand the contribution scope must be reviewed before integration; open questions are welcome here.
  • This request does not include credentials, private samples, or sensitive target data.

Area

Native analysis or decompilation

Current limitation

When a Mach-O slice's LC_ENCRYPTION_INFO or LC_ENCRYPTION_INFO_64 command has a nonzero cryptid, the bytes in its cryptoff/cryptsize range are ciphertext, yet provider-backed disassembly, pseudocode, strings, byte reads and cross-references over that range would be presented as program code. This request asks REA to attach a ciphertext limitation to those results and mark the overlapped part of their coverage as unexamined, using the per-slice encryption status that the blocking request adds to inspect_macho. It covers detection and labelling only; decryption stays out of scope.

An analyst who opens an encrypted iOS executable in Hopper, Ghidra or IDA through REA and asks for the assembly or pseudocode of a function inside the encrypted range gets output derived from ciphertext, with nothing in the Evidence saying so. They have to remember to check the load command separately and compare offsets by hand.

Baseline: REA 6.5.0 (tag rea-agents-6.5.0, commit 3b79af28e80785afce9f5998d8f03289402a1847) on macOS 27.0.1 (26A434) arm64 with Xcode 27.0 (27A266a). The cited source files are unchanged on main at d52a488.

  • A search of the release source finds no code that reads cryptid or any encryption load command, so provider disassembly, pseudocode, strings, byte reads and cross-references for addresses inside the range carry no warning. This is a source-only finding; a provider run on the enc1 fixture below was not performed.
  • Even after inspect_macho reports the status, a caller who goes straight to procedure_assembly, procedure_pseudo_code, list_strings, read_bytes or xrefs would not see it unless those results carry it too.
  • REA already has a convention for this situation: firmware extraction treats an encrypted region "as unexamined rather than empty or corrupt" (FirmwarePublication.ts). Mach-O analysis does not apply it.

Use cases

No response

Desired outcome and success criteria

  • Provider-backed results on a Mach-O target whose subject overlaps a range that the slice's encryption command marks encrypted carry a limitation stating that the overlapped bytes are ciphertext and that the result does not describe program code there. This covers disassembly, pseudocode, function and string listings, byte reads and cross-references, for every provider that serves them (Hopper, Ghidra, IDA).
  • Coverage for such a result marks the overlapped part as unexamined, following the firmware convention, rather than complete.
  • Results whose subject lies entirely outside every encrypted range, and results on slices whose status is not-encrypted or absent, are unchanged.
  • When the encryption status of the slice cannot be determined (malformed command or range outside the slice), the result says so instead of omitting the limitation silently.
  • The status comes only from the load command, as reported by the blocking request. REA does not guess encryption from entropy and does not test whether the bytes look encrypted.

Acceptance criteria

Fixtures, as in the request "Report per-slice LC_ENCRYPTION_INFO status and ciphertext ranges in inspect_macho" (enc has cryptid 0; enc1 is the same file with cryptid set to 1 and re-signed; _main lies at file offset 16384, inside the range cryptoff 16384, cryptsize 16384):

printf 'int main(void){return 0;}\n' > m.c && clang -o enc m.c -Wl,-encryptable
cp enc enc1 && python3 -c 'import struct,sys
p=sys.argv[1]; b=bytearray(open(p,"rb").read()); n=struct.unpack_from("<I",b,16)[0]; o=32
for _ in range(n):
    c,s=struct.unpack_from("<II",b,o)
    if c==0x2C: struct.pack_into("<I",b,o+16,1)
    o+=s
open(p,"wb").write(b)' enc1 && codesign -f -s - enc1
otool -l enc1 | grep -A5 LC_ENCRYPTION_INFO
  • With enc1 opened in a provider (Hopper, Ghidra or IDA), the assembly and pseudocode results for _main carry the ciphertext limitation and mark coverage of the overlapped range as unexamined. The same requests on enc carry neither.
  • On enc1, a byte read at the start of _main carries the limitation; a byte read of the Mach-O header at file offset 0, outside the range, does not.
  • On enc1, string listings and cross-references whose addresses fall inside the range carry the limitation; on enc they do not.
  • No result contains decrypted bytes, and no new process launch, memory read of a running process, or file write is introduced.

Expected interaction with targets

Read existing files or captured data

Target and scope details (optional)

Affected area

The shared limitation and coverage path for provider-backed disassembly, decompilation, string, byte and cross-reference results on Mach-O targets (Hopper, Ghidra and IDA), and the tests for those results.

Non-goals

  • Decrypting, dumping or reconstructing encrypted code by any route: from a running process, a jailbroken device, a debugger, or patching. CONTRIBUTING.md separates "Static protection detection" from defeating a protection, and excludes "DRM, license, anti-cheat, or access-control bypass workflows".
  • Entropy-based detection of packed or encrypted code.
  • Reporting the encryption command and per-slice status in inspect_macho itself; the blocking request covers that.

Verification available so far

I can suggest a sample or manual check

Verification details (optional)

No response

Alternatives considered

No response

Possible direction

No response

References

Depends on:

  • Report per-slice LC_ENCRYPTION_INFO status and ciphertext ranges in inspect_macho (#4306): this request consumes the per-slice encryption status and range that it adds.

  • The macOS 27.0 SDK header mach-o/loader.h defines the encryption_info_command_64 fields: cryptoff "file offset of encrypted range", cryptsize "file size of encrypted range", cryptid "which enryption system, 0 means not-encrypted yet" (spelling as in the header). Apple's encryption_info_command_64 page lists the fields without descriptions.

  • GitHub searches for "LC_ENCRYPTION_INFO", "cryptid", "encryption info", "encrypted App Store", "App Store encrypted" and "FairPlay" found no existing issue or pull request.

Originally created by @coygeek on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4313 ### Before submitting - [X] I searched the open and closed issues and pull requests for a related proposal. - [X] I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. - [X] This request does not include credentials, private samples, or sensitive target data. ### Area Native analysis or decompilation ### Current limitation When a Mach-O slice's `LC_ENCRYPTION_INFO` or `LC_ENCRYPTION_INFO_64` command has a nonzero `cryptid`, the bytes in its `cryptoff`/`cryptsize` range are ciphertext, yet provider-backed disassembly, pseudocode, strings, byte reads and cross-references over that range would be presented as program code. This request asks REA to attach a ciphertext limitation to those results and mark the overlapped part of their coverage as unexamined, using the per-slice encryption status that the blocking request adds to `inspect_macho`. It covers detection and labelling only; decryption stays out of scope. An analyst who opens an encrypted iOS executable in Hopper, Ghidra or IDA through REA and asks for the assembly or pseudocode of a function inside the encrypted range gets output derived from ciphertext, with nothing in the Evidence saying so. They have to remember to check the load command separately and compare offsets by hand. Baseline: REA 6.5.0 (tag `rea-agents-6.5.0`, commit `3b79af28e80785afce9f5998d8f03289402a1847`) on macOS 27.0.1 (26A434) arm64 with Xcode 27.0 (27A266a). The cited source files are unchanged on main at `d52a488`. - A search of the release source finds no code that reads `cryptid` or any encryption load command, so provider disassembly, pseudocode, strings, byte reads and cross-references for addresses inside the range carry no warning. This is a source-only finding; a provider run on the `enc1` fixture below was not performed. - Even after `inspect_macho` reports the status, a caller who goes straight to `procedure_assembly`, `procedure_pseudo_code`, `list_strings`, `read_bytes` or `xrefs` would not see it unless those results carry it too. - REA already has a convention for this situation: firmware extraction treats an encrypted region "as unexamined rather than empty or corrupt" ([FirmwarePublication.ts](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/src/firmware/FirmwarePublication.ts#L23-L24)). Mach-O analysis does not apply it. ### Use cases _No response_ ### Desired outcome and success criteria - Provider-backed results on a Mach-O target whose subject overlaps a range that the slice's encryption command marks `encrypted` carry a limitation stating that the overlapped bytes are ciphertext and that the result does not describe program code there. This covers disassembly, pseudocode, function and string listings, byte reads and cross-references, for every provider that serves them (Hopper, Ghidra, IDA). - Coverage for such a result marks the overlapped part as unexamined, following the firmware convention, rather than complete. - Results whose subject lies entirely outside every encrypted range, and results on slices whose status is `not-encrypted` or `absent`, are unchanged. - When the encryption status of the slice cannot be determined (malformed command or range outside the slice), the result says so instead of omitting the limitation silently. - The status comes only from the load command, as reported by the blocking request. REA does not guess encryption from entropy and does not test whether the bytes look encrypted. **Acceptance criteria** Fixtures, as in the request "Report per-slice LC_ENCRYPTION_INFO status and ciphertext ranges in inspect_macho" (`enc` has `cryptid 0`; `enc1` is the same file with `cryptid` set to 1 and re-signed; `_main` lies at file offset 16384, inside the range `cryptoff 16384`, `cryptsize 16384`): ```sh printf 'int main(void){return 0;}\n' > m.c && clang -o enc m.c -Wl,-encryptable cp enc enc1 && python3 -c 'import struct,sys p=sys.argv[1]; b=bytearray(open(p,"rb").read()); n=struct.unpack_from("<I",b,16)[0]; o=32 for _ in range(n): c,s=struct.unpack_from("<II",b,o) if c==0x2C: struct.pack_into("<I",b,o+16,1) o+=s open(p,"wb").write(b)' enc1 && codesign -f -s - enc1 otool -l enc1 | grep -A5 LC_ENCRYPTION_INFO ``` - [ ] With `enc1` opened in a provider (Hopper, Ghidra or IDA), the assembly and pseudocode results for `_main` carry the ciphertext limitation and mark coverage of the overlapped range as unexamined. The same requests on `enc` carry neither. - [ ] On `enc1`, a byte read at the start of `_main` carries the limitation; a byte read of the Mach-O header at file offset 0, outside the range, does not. - [ ] On `enc1`, string listings and cross-references whose addresses fall inside the range carry the limitation; on `enc` they do not. - [ ] No result contains decrypted bytes, and no new process launch, memory read of a running process, or file write is introduced. ### Expected interaction with targets Read existing files or captured data ### Target and scope details (optional) **Affected area** The shared limitation and coverage path for provider-backed disassembly, decompilation, string, byte and cross-reference results on Mach-O targets (Hopper, Ghidra and IDA), and the tests for those results. **Non-goals** - Decrypting, dumping or reconstructing encrypted code by any route: from a running process, a jailbroken device, a debugger, or patching. [CONTRIBUTING.md](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/CONTRIBUTING.md#L111-L131) separates "Static protection detection" from defeating a protection, and excludes "DRM, license, anti-cheat, or access-control bypass workflows". - Entropy-based detection of packed or encrypted code. - Reporting the encryption command and per-slice status in `inspect_macho` itself; the blocking request covers that. ### Verification available so far I can suggest a sample or manual check ### Verification details (optional) _No response_ ### Alternatives considered _No response_ ### Possible direction _No response_ ### References Depends on: - `Report per-slice LC_ENCRYPTION_INFO status and ciphertext ranges in inspect_macho` (#4306): this request consumes the per-slice encryption status and range that it adds. - The macOS 27.0 SDK header `mach-o/loader.h` defines the `encryption_info_command_64` fields: `cryptoff` "file offset of encrypted range", `cryptsize` "file size of encrypted range", `cryptid` "which enryption system, 0 means not-encrypted yet" (spelling as in the header). Apple's [encryption_info_command_64](https://developer.apple.com/documentation/kernel/encryption_info_command_64) page lists the fields without descriptions. - GitHub searches for "LC_ENCRYPTION_INFO", "cryptid", "encryption info", "encrypted App Store", "App Store encrypted" and "FairPlay" found no existing issue or pull request.
Author
Owner

@XinwangTao commented on GitHub (Oct 16, 2026):

Strongly agree with this one — presenting ciphertext-derived assembly/pseudocode as if it were real program code is exactly the kind of silent Evidence corruption that misleads downstream analysis, so labelling the overlapped coverage as unexamined with an explicit ciphertext limitation is the right minimal fix. A couple of implementation notes: (1) prefer surfacing the limitation at the evidence-record level (structured field like coverage: unexamined(ciphertext, cryptoff..cryptsize)) rather than only as a human-readable note, so downstream consumers can filter or propagate it; (2) define the behavior when cryptsize == 0 or when a slice has both LC_ENCRYPTION_INFO and _64 variants (overlapping ranges) — pick the strongest applicable range; (3) when the provider still offers function-level queries inside the range, REA should probably return the limitation with the result rather than silently dropping it, so analysts see why. Happy to help spec the evidence schema if needed.

<!-- gh-comment-id:6207011759 --> @XinwangTao commented on GitHub (Oct 16, 2026): Strongly agree with this one — presenting ciphertext-derived assembly/pseudocode as if it were real program code is exactly the kind of silent Evidence corruption that misleads downstream analysis, so labelling the overlapped coverage as unexamined with an explicit ciphertext limitation is the right minimal fix. A couple of implementation notes: (1) prefer surfacing the limitation at the evidence-record level (structured field like `coverage: unexamined(ciphertext, cryptoff..cryptsize)`) rather than only as a human-readable note, so downstream consumers can filter or propagate it; (2) define the behavior when cryptsize == 0 or when a slice has both LC_ENCRYPTION_INFO and _64 variants (overlapping ranges) — pick the strongest applicable range; (3) when the provider still offers function-level queries inside the range, REA should probably return the limitation with the result rather than silently dropping it, so analysts see why. Happy to help spec the evidence schema if needed.
Author
Owner

@Anvith433 commented on GitHub (Oct 16, 2026):

Hi! I'd like to work on this issue and contribute a fix.

My plan is to trace how Mach-O encryption metadata flows into provider-backed analysis results, identify the shared limitation and coverage path, and add focused regression tests before implementing the smallest appropriate change.

I'll also check the dependency on #4306 first, so the implementation uses the authoritative per-slice encryption status rather than duplicating detection logic. I'll keep the scope limited to accurately labelling ciphertext-derived results and marking affected coverage as unexamined—no decryption or protection bypass.

Would it be okay to assign this issue to me? I'm happy to share my findings and proposed approach before opening a PR if that would be helpful.

<!-- gh-comment-id:6208117244 --> @Anvith433 commented on GitHub (Oct 16, 2026): Hi! I'd like to work on this issue and contribute a fix. My plan is to trace how Mach-O encryption metadata flows into provider-backed analysis results, identify the shared limitation and coverage path, and add focused regression tests before implementing the smallest appropriate change. I'll also check the dependency on #4306 first, so the implementation uses the authoritative per-slice encryption status rather than duplicating detection logic. I'll keep the scope limited to accurately labelling ciphertext-derived results and marking affected coverage as unexamined—no decryption or protection bypass. Would it be okay to assign this issue to me? I'm happy to share my findings and proposed approach before opening a PR if that would be helpful.
Author
Owner

@XinwangTao commented on GitHub (Oct 16, 2026):

Anvith433, your plan sounds solid — tracing the metadata flow and adding focused regression tests before the smallest change is the right order, and checking the #4306 dependency first avoids duplicating detection logic. Two design points to keep in mind: (1) carry the ciphertext limitation as a structured evidence field (e.g. coverage: unexamined(ciphertext, range)) so downstream consumers can filter it, and (2) pin down the cryptsize == 0 and overlapping LC_ENCRYPTION_INFO/_64 behavior up front. Happy to review your proposal once posted.

<!-- gh-comment-id:6208513853 --> @XinwangTao commented on GitHub (Oct 16, 2026): Anvith433, your plan sounds solid — tracing the metadata flow and adding focused regression tests before the smallest change is the right order, and checking the #4306 dependency first avoids duplicating detection logic. Two design points to keep in mind: (1) carry the ciphertext limitation as a structured evidence field (e.g. coverage: unexamined(ciphertext, range)) so downstream consumers can filter it, and (2) pin down the cryptsize == 0 and overlapping LC_ENCRYPTION_INFO/_64 behavior up front. Happy to review your proposal once posted.
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#1719
No description provided.