[GH-ISSUE #4306] [Feature] Report per-slice LC_ENCRYPTION_INFO status and ciphertext ranges in inspect_macho #1709

Closed
opened 2026-10-16 21:09:18 +00:00 by bsakaguchi · 1 comment
Owner

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

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

Have inspect_macho report each Mach-O slice's LC_ENCRYPTION_INFO or LC_ENCRYPTION_INFO_64 command (cryptoff, cryptsize, cryptid) as an interpreted encryption status, and state in its limitations that bytes in a range whose cryptid is nonzero are ciphertext, not program code. The request covers detection and reporting in inspect_macho only. Decrypting such binaries is out of scope under REA's DRM boundary.

An analyst inspecting an iOS app executable, or an iOS app installed on a Mac, needs to know whether the __TEXT bytes REA disassembles are the program or still encrypted. REA does not say. Measured with REA 6.5.0 (npx -y rea-agents@6.5.0, tag rea-agents-6.5.0, commit 3b79af28e80785afce9f5998d8f03289402a1847) on macOS 27.0.1 (26A434) arm64 with Xcode 27.0 (27A266a) and Node.js v26.10.0:

  • clang -o enc m.c -Wl,-encryptable produces an LC_ENCRYPTION_INFO_64 command with cryptoff 16384, cryptsize 16384 and cryptid 0 (otool -l enc). Changing the cryptid word to 1 (fixture enc1) is the state the SDK header calls encrypted.
  • rea inspect-macho ./enc1 --json lists the command only as raw otool text fields, {"cmd": "LC_ENCRYPTION_INFO_64", "cmdsize": 24, "cryptoff": 16384, "cryptsize": 16384, "cryptid": 1, "pad": 0}, and its limitations are the two generic import/export and vtool notes. Nothing states that the range is encrypted. The otool parser keeps unrecognized commands as raw fields by design (otool.ts).
  • inspect_macho reports load commands only for the slice it selects (docs/native-investigation.md), so the encryption state of the other slices in a universal file is not reported at all.
  • For a universal file made with lipo -create plain_x86 enc1 -o fat_enc, rea inspect-artifact ./fat_enc --json reports the slices/arm64 entry with encrypted: false (MachOSliceArtifactReader.ts). That field describes container-entry encryption that blocks reading and extraction (ArtifactExtraction.ts), and the slice bytes are readable, so the value is consistent with its meaning; the artifact graph simply has no place to record the slice's Mach-O protection state.
  • A search of the release source finds no code that reads cryptid or any encryption load command.

Use cases

No response

Desired outcome and success criteria

  • inspect_macho returns a normalized encryption entry for every slice, not only the selected one: the command kind, cryptoff, cryptsize and cryptid as recorded, the file range in both slice-relative and container coordinates, the segments and sections the range overlaps, and a status. The status is encrypted when cryptid is nonzero, not-encrypted when it is zero, and absent when the slice has no such command. A malformed command, or a range outside the slice, is reported as such and is never treated as absent.
  • An encrypted slice adds a limitation stating that the bytes in the range are ciphertext and that disassembly, decompilation, strings and references derived from them do not describe program code.
  • Artifact inventory keeps its container-level encrypted flag unchanged, because setting it would block reading and extraction of readable slice bytes. Where the inventory describes a Mach-O slice, it may add the slice's load-command encryption status as a separate fact, so the two meanings stay distinct.
  • Status comes only from the load command. REA does not guess encryption from entropy and does not test whether the bytes look encrypted.

Acceptance criteria

  • Build fixtures with printf 'int main(void){return 0;}\n' > m.c && clang -o plain m.c && clang -o enc m.c -Wl,-encryptable, then make enc1 by copying enc and setting the 32-bit cryptid word, 16 bytes into the LC_ENCRYPTION_INFO_64 command, to 1, then re-signing with codesign -f -s - enc1 (the script below does this). Confirm with otool -l enc1 | grep -A5 LC_ENCRYPTION_INFO.
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
  • plain reports status absent; enc reports not-encrypted with cryptoff, cryptsize and cryptid equal to otool -l; enc1 reports encrypted plus the ciphertext limitation, and its range maps to the same __TEXT sections that otool -l places inside the file range.
  • For lipo -create plain_x86 enc1 -o fat_enc, where plain_x86 comes from clang -arch x86_64 -o plain_x86 m.c, inspect_macho reports absent for x86_64 and encrypted for arm64 whichever slice it selects, and extract_artifact still extracts both slices of fat_enc unchanged.
  • 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

inspect_macho normalization, its schema and limitations, and optionally a separate slice-protection fact in inspect_artifact.

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".
  • Explaining how to obtain decrypted binaries, or linking to tools that do.
  • Export-compliance metadata. ITSAppUsesNonExemptEncryption is an Info.plist key that declares whether the app uses encryption algorithms; it says nothing about whether the executable is encrypted, and the two must not be conflated.
  • Entropy-based detection of packed or encrypted code.
  • Carrying the ciphertext limitation and unexamined coverage into provider-backed disassembly, pseudocode, strings, byte reads and cross-references. That builds on this status and is requested separately in #4313.

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

  • Apple documents encryption_info_command_64 (macOS 10.9+) with fields cmd, cmdsize, cryptid, cryptoff, cryptsize and pad (encryption_info_command_64), and the 32-bit encryption_info_command (macOS 10.6+) with the same fields except pad (encryption_info_command). The pages carry no field descriptions. The macOS 27.0 SDK header mach-o/loader.h defines them: 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).
  • The Apple documentation corpus checked for this draft (developer.apple.com, support.apple.com, security.apple.com) has no page that describes App Store encryption of executables. The draft therefore relies only on the load-command definition. ITSAppUsesNonExemptEncryption is "A Boolean value indicating whether the app uses encryption" for export compliance, a different subject.
  • No stock executable on the measurement host under /System/Applications, /System/Applications/Utilities, /usr/bin or /usr/libexec carries either command, so the acceptance criteria use self-built fixtures. Building a 32-bit LC_ENCRYPTION_INFO fixture would need a hand-built header, because the current toolchain targets only 64-bit Apple platforms.
  • The cited source files are unchanged on main at d52a488, which also leaves MachOSliceArtifactReader.ts unchanged and adds no encryption handling to otool.ts or NativeMachoInspection.ts.
  • 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/4306 ### 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 Have `inspect_macho` report each Mach-O slice's `LC_ENCRYPTION_INFO` or `LC_ENCRYPTION_INFO_64` command (`cryptoff`, `cryptsize`, `cryptid`) as an interpreted encryption status, and state in its limitations that bytes in a range whose `cryptid` is nonzero are ciphertext, not program code. The request covers detection and reporting in `inspect_macho` only. Decrypting such binaries is out of scope under REA's DRM boundary. An analyst inspecting an iOS app executable, or an iOS app installed on a Mac, needs to know whether the `__TEXT` bytes REA disassembles are the program or still encrypted. REA does not say. Measured with REA 6.5.0 (`npx -y rea-agents@6.5.0`, tag `rea-agents-6.5.0`, commit `3b79af28e80785afce9f5998d8f03289402a1847`) on macOS 27.0.1 (26A434) arm64 with Xcode 27.0 (27A266a) and Node.js v26.10.0: - `clang -o enc m.c -Wl,-encryptable` produces an `LC_ENCRYPTION_INFO_64` command with `cryptoff 16384`, `cryptsize 16384` and `cryptid 0` (`otool -l enc`). Changing the `cryptid` word to 1 (fixture `enc1`) is the state the SDK header calls encrypted. - `rea inspect-macho ./enc1 --json` lists the command only as raw otool text fields, `{"cmd": "LC_ENCRYPTION_INFO_64", "cmdsize": 24, "cryptoff": 16384, "cryptsize": 16384, "cryptid": 1, "pad": 0}`, and its limitations are the two generic import/export and vtool notes. Nothing states that the range is encrypted. The otool parser keeps unrecognized commands as raw fields by design ([otool.ts](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/src/native/parsers/otool.ts#L126-L127)). - `inspect_macho` reports load commands only for the slice it selects ([docs/native-investigation.md](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/docs/native-investigation.md#L264-L272)), so the encryption state of the other slices in a universal file is not reported at all. - For a universal file made with `lipo -create plain_x86 enc1 -o fat_enc`, `rea inspect-artifact ./fat_enc --json` reports the `slices/arm64` entry with `encrypted: false` ([MachOSliceArtifactReader.ts](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/src/artifacts/MachOSliceArtifactReader.ts#L180-L186)). That field describes container-entry encryption that blocks reading and extraction ([ArtifactExtraction.ts](https://github.com/morluto/rea/blob/3b79af28e80785afce9f5998d8f03289402a1847/src/artifacts/extraction/ArtifactExtraction.ts#L139-L145)), and the slice bytes are readable, so the value is consistent with its meaning; the artifact graph simply has no place to record the slice's Mach-O protection state. - A search of the release source finds no code that reads `cryptid` or any encryption load command. ### Use cases _No response_ ### Desired outcome and success criteria - `inspect_macho` returns a normalized encryption entry for every slice, not only the selected one: the command kind, `cryptoff`, `cryptsize` and `cryptid` as recorded, the file range in both slice-relative and container coordinates, the segments and sections the range overlaps, and a status. The status is `encrypted` when `cryptid` is nonzero, `not-encrypted` when it is zero, and `absent` when the slice has no such command. A malformed command, or a range outside the slice, is reported as such and is never treated as `absent`. - An `encrypted` slice adds a limitation stating that the bytes in the range are ciphertext and that disassembly, decompilation, strings and references derived from them do not describe program code. - Artifact inventory keeps its container-level `encrypted` flag unchanged, because setting it would block reading and extraction of readable slice bytes. Where the inventory describes a Mach-O slice, it may add the slice's load-command encryption status as a separate fact, so the two meanings stay distinct. - Status comes only from the load command. REA does not guess encryption from entropy and does not test whether the bytes look encrypted. **Acceptance criteria** - Build fixtures with `printf 'int main(void){return 0;}\n' > m.c && clang -o plain m.c && clang -o enc m.c -Wl,-encryptable`, then make `enc1` by copying `enc` and setting the 32-bit `cryptid` word, 16 bytes into the `LC_ENCRYPTION_INFO_64` command, to 1, then re-signing with `codesign -f -s - enc1` (the script below does this). Confirm with `otool -l enc1 | grep -A5 LC_ENCRYPTION_INFO`. ```sh 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 ``` - `plain` reports status `absent`; `enc` reports `not-encrypted` with `cryptoff`, `cryptsize` and `cryptid` equal to `otool -l`; `enc1` reports `encrypted` plus the ciphertext limitation, and its range maps to the same `__TEXT` sections that `otool -l` places inside the file range. - For `lipo -create plain_x86 enc1 -o fat_enc`, where `plain_x86` comes from `clang -arch x86_64 -o plain_x86 m.c`, `inspect_macho` reports `absent` for x86_64 and `encrypted` for arm64 whichever slice it selects, and `extract_artifact` still extracts both slices of `fat_enc` unchanged. - 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** `inspect_macho` normalization, its schema and limitations, and optionally a separate slice-protection fact in `inspect_artifact`. **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". - Explaining how to obtain decrypted binaries, or linking to tools that do. - Export-compliance metadata. `ITSAppUsesNonExemptEncryption` is an `Info.plist` key that declares whether the app uses encryption algorithms; it says nothing about whether the executable is encrypted, and the two must not be conflated. - Entropy-based detection of packed or encrypted code. - Carrying the ciphertext limitation and unexamined coverage into provider-backed disassembly, pseudocode, strings, byte reads and cross-references. That builds on this status and is requested separately in #4313. ### 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 - Apple documents `encryption_info_command_64` (macOS 10.9+) with fields `cmd`, `cmdsize`, `cryptid`, `cryptoff`, `cryptsize` and `pad` ([encryption_info_command_64](https://developer.apple.com/documentation/kernel/encryption_info_command_64)), and the 32-bit `encryption_info_command` (macOS 10.6+) with the same fields except `pad` ([encryption_info_command](https://developer.apple.com/documentation/kernel/encryption_info_command)). The pages carry no field descriptions. The macOS 27.0 SDK header `mach-o/loader.h` defines them: `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). - The Apple documentation corpus checked for this draft (developer.apple.com, support.apple.com, security.apple.com) has no page that describes App Store encryption of executables. The draft therefore relies only on the load-command definition. [ITSAppUsesNonExemptEncryption](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption) is "A Boolean value indicating whether the app uses encryption" for export compliance, a different subject. - No stock executable on the measurement host under `/System/Applications`, `/System/Applications/Utilities`, `/usr/bin` or `/usr/libexec` carries either command, so the acceptance criteria use self-built fixtures. Building a 32-bit `LC_ENCRYPTION_INFO` fixture would need a hand-built header, because the current toolchain targets only 64-bit Apple platforms. - The cited source files are unchanged on main at `d52a488`, which also leaves `MachOSliceArtifactReader.ts` unchanged and adds no encryption handling to `otool.ts` or `NativeMachoInspection.ts`. - 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

@chordpli commented on GitHub (Oct 20, 2026):

I'd like to take this one. Below is the approach I plan to follow.

Current state (from reading main, not yet reproduced at runtime)

  • inspect_macho runs otool -l only for the selected slice (NativeMachoInspection.ts), so other slices of a universal file have no load-command data at all.
  • readMachoImage (MachoLoadCommandReader.ts) already walks every slice's load commands natively, but fails the whole image on the first malformed command or unsupported slice. Adding encryption decoding there would let one bad cryptoff regress inspect_artifact/extract_artifact, dylib resolution and the Electron fuse reader.

Plan

  • Extract non-throwing container / per-slice load-command readers from MachoLoadCommandReader.ts, and rebuild readMachoImage on them with its current strict policy and error precedence (behavior-preserving, with a multi-defect characterization test).
  • Add a per-slice decoder for LC_ENCRYPTION_INFO(_64) plus LC_SEGMENT(_64) on top of those readers. One bad slice does not hide the others.
  • Status per slice: encrypted / not-encrypted / absent as in the issue, plus malformed and unknown, both always with a reason. absent only when the load-command walk completed. Word size comes from the Mach-O magic. cmdsize must be 20/24 and match the image width. More than one encryption command is malformed, with all raw commands kept. cryptid != 0 with cryptsize == 0 is encrypted with an empty range and a limitation.
  • Each slice entry carries CPU type/subtype, offset/size and the lipo name (matched by CPU codes and offset, not by name, so arm64e variants stay distinct), the raw command fields, the range in slice, container and VM coordinates (end-exclusive), overlapped segments/sections (zerofill excluded), a structured unexamined-coverage marker, and the ciphertext limitation.
  • Out of scope: the container-level encrypted flag in inspect_artifact, the optional inventory fact, machoSliceSchema/trace_dylib_resolution output, and anything that decrypts.
  • Tests: macOS-gated CLI and MCP tests using the clang -Wl,-encryptable fixtures from this issue, built at test time (including the otool section cross-check and extract_artifact on fat_enc), plus synthesized byte tests for the edge cases above.

Decisions

  • Target binding: the in-process read is bound to the target by checking file identity (sameRegularFileState) before lipo/otool and after the last read, returning AnalysisArtifactChangedError on change. The file is not re-hashed, matching what inspect_macho does today; the PR will state this as a limitation. Happy to add a streaming sha256 check if you prefer.
  • PR shape: one PR with two commits (behavior-preserving refactor, then the feature), reviewable commit by commit.

Related in-flight work I'll rebase onto rather than duplicate: #5254 (exact arm64e.x1 slice names) and #5170 (64-bit load-command alignment) touch the same reader; slice matching here uses CPU type/subtype and offset, so it stays correct either way.

If any of this conflicts with how you'd like it done, let me know before I open the PR.

@Anvith433, since #4313 builds on this: the per-slice status, the VM ranges and the unexamined marker above are meant to be consumed directly, so #4313 doesn't need to re-decode load commands. Let me know if you need a different shape.

<!-- gh-comment-id:6282077083 --> @chordpli commented on GitHub (Oct 20, 2026): I'd like to take this one. Below is the approach I plan to follow. **Current state (from reading `main`, not yet reproduced at runtime)** - `inspect_macho` runs `otool -l` only for the selected slice (`NativeMachoInspection.ts`), so other slices of a universal file have no load-command data at all. - `readMachoImage` (`MachoLoadCommandReader.ts`) already walks every slice's load commands natively, but fails the whole image on the first malformed command or unsupported slice. Adding encryption decoding there would let one bad `cryptoff` regress `inspect_artifact`/`extract_artifact`, dylib resolution and the Electron fuse reader. **Plan** - Extract non-throwing container / per-slice load-command readers from `MachoLoadCommandReader.ts`, and rebuild `readMachoImage` on them with its current strict policy and error precedence (behavior-preserving, with a multi-defect characterization test). - Add a per-slice decoder for `LC_ENCRYPTION_INFO(_64)` plus `LC_SEGMENT(_64)` on top of those readers. One bad slice does not hide the others. - Status per slice: `encrypted` / `not-encrypted` / `absent` as in the issue, plus `malformed` and `unknown`, both always with a reason. `absent` only when the load-command walk completed. Word size comes from the Mach-O magic. `cmdsize` must be 20/24 and match the image width. More than one encryption command is `malformed`, with all raw commands kept. `cryptid != 0` with `cryptsize == 0` is `encrypted` with an empty range and a limitation. - Each slice entry carries CPU type/subtype, offset/size and the lipo name (matched by CPU codes and offset, not by name, so arm64e variants stay distinct), the raw command fields, the range in slice, container and VM coordinates (end-exclusive), overlapped segments/sections (zerofill excluded), a structured unexamined-coverage marker, and the ciphertext limitation. - Out of scope: the container-level `encrypted` flag in `inspect_artifact`, the optional inventory fact, `machoSliceSchema`/`trace_dylib_resolution` output, and anything that decrypts. - Tests: macOS-gated CLI and MCP tests using the `clang -Wl,-encryptable` fixtures from this issue, built at test time (including the otool section cross-check and `extract_artifact` on `fat_enc`), plus synthesized byte tests for the edge cases above. **Decisions** - Target binding: the in-process read is bound to the target by checking file identity (`sameRegularFileState`) before `lipo`/`otool` and after the last read, returning `AnalysisArtifactChangedError` on change. The file is not re-hashed, matching what `inspect_macho` does today; the PR will state this as a limitation. Happy to add a streaming sha256 check if you prefer. - PR shape: one PR with two commits (behavior-preserving refactor, then the feature), reviewable commit by commit. Related in-flight work I'll rebase onto rather than duplicate: #5254 (exact `arm64e.x1` slice names) and #5170 (64-bit load-command alignment) touch the same reader; slice matching here uses CPU type/subtype and offset, so it stays correct either way. If any of this conflicts with how you'd like it done, let me know before I open the PR. @Anvith433, since #4313 builds on this: the per-slice status, the VM ranges and the unexamined marker above are meant to be consumed directly, so #4313 doesn't need to re-decode load commands. Let me know if you need a different shape.
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#1709
No description provided.