The sandbox is not a slogan on the lab app. It is a Seatbelt profile derived from the code-signature entitlements blob, plus a container path dyld and NSHomeDirectory() already agree on. This note is what I actually dump: codesign -d --entitlements, the two keys that matter for a debug build, where the simulator puts Documents/, and how a missing entitlement shows up as a deny(1) line instead of a file handle. Self-signed LabSession only. cryptid=1 stops the lab. I do not bypass AMFI and I do not jailbreak.

Mach-O load commands
Figure 1. LC_CODE_SIGNATURE holds the entitlements blob. cryptid=1 is a hard stop before any of this.

Same first checks as every other lab

1$ file LabSession
2LabSession: Mach-O 64-bit executable arm64
3
4$ otool -l LabSession | egrep 'cmd LC_|cryptid|segname __TEXT|LC_CODE|LC_ENCRYPT'
5      cmd LC_SEGMENT_64
6  segname __TEXT
7      cmd LC_ENCRYPTION_INFO_64
8  cryptid 0
9      cmd LC_CODE_SIGNATURE

If cryptid is 1 I stop. FairPlay unwrap is out of scope. LC_CODE_SIGNATURE is required for codesign -d to have a blob to print; an unsigned intermediate from ld will just error.

 1$ class-dump LabSession | sed -n '/LabSession/,+18p'
 2@interface LabSession : NSObject
 3{
 4    NSString *_token;
 5}
 6- (id)initWithEnvironment:(id)env;
 7- (void)startWithToken:(id)token;
 8- (BOOL)readLabFile:(id)name;          ; inside container
 9- (BOOL)readOutside:(id)absPath;       ; lab: should deny
10@end

codesign -d --entitlements

 1$ codesign -d --entitlements :- LabSession 2>/dev/null
 2<?xml version="1.0" encoding="UTF-8"?>
 3<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
 4 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 5<plist version="1.0">
 6<dict>
 7    <key>application-identifier</key>
 8    <string>XXXXXX.com.lab.session</string>   <!-- team id redacted -->
 9    <key>com.apple.developer.team-identifier</key>
10    <string>XXXXXX</string>
11    <key>get-task-allow</key>
12    <true/>                                   <!-- debugable lab build -->
13    <key>keychain-access-groups</key>
14    <array>
15        <string>XXXXXX.com.lab.session</string>
16    </array>
17</dict>
18</plist>

get-task-allow is why lldb can task_for_pid this process on a dev-signed build. I do not put that key on a distribution profile and I do not try to graft it onto someone else’s binary.

application-identifier is <TEAM>.<bundle id>. Team id is redacted to XXXXXX in every log I keep. The sandbox uses this string as the primary subject; two apps with different team ids do not share a container, even if the bundle id suffix looks similar.

Keys not in this plist are as important as keys that are. There is no com.apple.security.exception.files.absolute-path.read-only, no app-group, no iCloud. A later open("/etc/passwd") is not going to grow a new entitlement at runtime.

On device, codesign -d --entitlements reads the same CMS blob from LC_CODE_SIGNATURE. I still only do this on a binary I signed.

Container paths on the simulator

1(lldb) po NSHomeDirectory()
2/Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED]
3
4(lldb) po NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)
5<[REDACTED]/Documents>
6
7(lldb) po NSTemporaryDirectory()
8<[REDACTED]/tmp/>

Three directories I treat as in-container: Documents/, Library/, tmp/. Everything else is outside, including:

  • /etc/passwd, /private/var/… (device-shaped paths that the simulator still rejects)
  • /Users/[REDACTED]/Desktop/outside.txt (host Desktop, not the container)
  • another app’s Application/[OTHER-UUID]/

The simulator is weaker than a device (it is a host process under a Seatbelt profile, not a fully entitled iOS task), but file-read denials still show up. I do not use that gap as an AMFI or jailbreak footnote; I use it as a place I can tail sandbox logs without a special device.

sandbox-exec as a reduced host stand-in

sandbox-exec is a macOS tool. I use it on the host copy of a tiny helper that calls the same open() the app calls, with a profile that only allows the container subpath. It is not a replacement for the iOS profile, and it is not an exploit.

 1$ cat lab-container.sb
 2(version 1)
 3(deny default)
 4(allow file-read-metadata)
 5(allow file-read* file-write*
 6    (subpath "/Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED]"))
 7(allow process-exec (literal "/usr/bin/true"))
 8
 9$ sandbox-exec -p "$(cat lab-container.sb)" \
10    /usr/bin/stat /Users/[REDACTED]/Desktop/outside.txt
11stat: /Users/[REDACTED]/Desktop/outside.txt: Operation not permitted

Same profile, path inside the container:

1$ sandbox-exec -p "$(cat lab-container.sb)" \
2    /usr/bin/stat /Users/[REDACTED]/Library/Developer/CoreSimulator/Devices/[REDACTED]/data/Containers/Data/Application/[REDACTED]/Documents/token.len
3# size: 3   (I store the decimal length, not the token)

ARM64 at readOutside:

 1; otool -tV LabSession  (file addresses, slide 0)
 2; -[LabSession readOutside:]
 30000000100001f00  pacibsp
 40000000100001f04  stp    x20, x19, [sp, #-0x20]!
 50000000100001f08  stp    x29, x30, [sp, #0x10]
 60000000100001f0c  add    x29, sp, #0x10
 70000000100001f10  mov    x19, x2              ; NSString * absPath
 80000000100001f14  mov    x0, x19
 90000000100001f18  bl     0x1000024a0          ; -[NSString UTF8String]
100000000100001f1c  mov    x1, #0x0             ; O_RDONLY
110000000100001f20  bl     0x100002510          ; _open stub
120000000100001f24  cmn    x0, #0x1             ; fd == -1 ?
130000000100001f28  cset   w0, ne               ; BOOL
140000000100001f2c  ldp    x29, x30, [sp, #0x10]
150000000100001f30  ldp    x20, x19, [sp], #0x20
160000000100001f34  retab

x0 = path, x1 = O_RDONLY. No O_NOFOLLOW even, because this is a lab. The interesting part is not the libc wrapper, it is that open returns -1 with EPERM / EACCES when Seatbelt denies, and the ObjC method turns that into NO.

AArch64 frame
Figure 2. Frame at readOutside: saved fp/lr, x19 holds the path NSString, then open.

lldb: break on open, log length, not the path

 1(lldb) process launch --stop-at-entry
 2(lldb) breakpoint set -n open
 3(lldb) breakpoint command add
 4> script
 5import lldb
 6f = lldb.debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetFrameAtIndex(0)
 7p = f.GetRegisters().GetFirstValueByName('x0').GetValueAsUnsigned()
 8err = lldb.SBError()
 9s = f.GetThread().GetProcess().ReadCStringFromMemory(p, 256, err)
10print('[open] len=%d prefix=%s' % (len(s), s[:24] if s.startswith('/Users') else s[:8]))
11# continue always — this is a log, not a stop
12lldb.debugger.HandleCommand('continue')
13> DONE
14(lldb) c
15[open] len=118 prefix=/Users/[REDACTED]/Librar
16[open] len=48  prefix=/Users/[REDACTED]/Desktop

I print length + a redacted prefix. I do not paste full host paths or usernames into a note. The second line is the out-of-container attempt.

How a missing entitlement shows up as deny

Simulator log stream, lab process name LabSession, host Desktop file:

1$ xcrun simctl spawn booted log stream --style compact \
2    --predicate 'eventMessage CONTAINS "Sandbox" AND eventMessage CONTAINS "LabSession"'
3error	10:14:22.401 kernel	Sandbox: LabSession(412) deny(1) file-read-data /Users/[REDACTED]/Desktop/outside.txt
4error	10:14:22.401 kernel	Sandbox: LabSession(412) deny(1) file-read-metadata /Users/[REDACTED]/Desktop/outside.txt

Same call with a path inside Documents/ produces no deny line and open returns a fd. That is the whole difference.

A second lab case: I temporarily drop get-task-allow from the entitlements plist, re-sign ad-hoc, and attach:

1$ codesign -s - --entitlements lab-no-debug.entitlements LabSession
2$ xcrun lldb -n LabSession
3error: attach failed: cannot attach to process due to System Integrity / codesign

That failure is the missing get-task-allow key, not an invitation to disable AMFI. I restore the debug entitlement on the lab target and move on.

A third case, keychain: SecItemCopyMatching without the matching keychain-access-groups entry returns errSecMissingEntitlement (-34018) in the lab. I log the OSStatus, not the item contents.

Sanitized reproduction

UI button “read outside” feeds readOutside: a 48-character Desktop path. Expected: deny log, method returns NO, no crash. I also planted a 16-byte stack copy of the UTF-8 path so a long path is a crash, not a novel sandbox escape.

1// LabSession.m — intentional lab bug, not a bypass
2- (BOOL)readOutside:(NSString *)absPath {
3    char buf[16];
4    const char *u = absPath.UTF8String;
5    memcpy(buf, u, strlen(u) + 1);     // no bound; lab only
6    int fd = open(u, O_RDONLY);
7    return fd >= 0;
8}
1* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=2)
2    frame #0: 0x00000001890afc2c libsystem_platform.dylib`_platform_memmove + 204
3    frame #1: 0x0000000104a81f40 LabSession`-[LabSession readOutside:] + 0x28

ASAN:

1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
2WRITE of size 49 at ... thread T0
3    #0 memcpy
4    #1 -[LabSession readOutside:] LabSession.m:77

Repro is: 48-byte path, 16-byte buffer, memcpy size 49, plus the deny(1) file-read-data line for the same path. Length of the path is what I keep. The sandbox denial is the analysis result; the ASAN hit is so the write-up has a crash, not a host-file dump.

objc_msgSend dispatch
Figure 3. readLabFile: / readOutside: still go through objc_msgSend. Log class + selector, not path contents.

Closing

Entitlements are a plist inside LC_CODE_SIGNATURE. get-task-allow is why the debugger attaches; application-identifier (team id redacted) is why the container is unique. Missing keys surface as deny(1) / errSecMissingEntitlement / attach failure, not as silent success. sandbox-exec on the host is a reduced model of that deny. cryptid=1 still ends the session before any of this. No AMFI switch, no jailbreak, no FairPlay.

Commands appendix

1otool -l LabSession | egrep 'cryptid|LC_CODE_SIGNATURE'
2codesign -d --entitlements :- LabSession
3class-dump LabSession
4xcrun simctl get_app_container booted com.lab.session data
5xcrun simctl spawn booted log stream --predicate 'eventMessage CONTAINS "Sandbox"'
6sandbox-exec -p '(version 1)(deny default)(allow file-read-metadata)' /usr/bin/stat /etc/passwd
7xcrun lldb -n LabSession