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.
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.
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.
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