Before I disassemble anything I read the load commands. They tell me whether the file is even mine to reverse: CPU, segments, which dylibs bind, whether a code signature blob is present, and the only field that is a hard stop — cryptid. This lab is a self-signed LabSession I build in Xcode for the simulator. App Store FairPlay slices are out of scope.
Confirm the image, then the header
1$ file LabSession
2LabSession: Mach-O 64-bit executable arm64
3
4$ otool -h LabSession
5Mach header
6 magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
7 0xfeedfacf 16777228 0 0x00 2 18 2128 0x00200085
0xfeedfacf is MH_MAGIC_64. cputype 16777228 is CPU_TYPE_ARM64. filetype 2 is MH_EXECUTE. flags has MH_PIE (0x00200000) plus the usual MH_NOUNDEFS | MH_DYLDLINK | MH_TWOLEVEL. ncmds=18 is the number of load commands I am about to walk; sizeofcmds=2128 is their on-disk span right after the 32-byte mach_header_64.
If file had said arm64e I would still continue on a lab build I own. If it had said a fat file I would lipo -thin arm64 first. I do not thin an App Store universal and hope.
LC_SEGMENT_64 __TEXT and __DATA
1$ otool -l LabSession
2Mach header
3 magic 0xfeedfacf
4 cputype CPU_TYPE_ARM64
5 cpusubtype CPU_SUBTYPE_ARM64_ALL
6 filetype MH_EXECUTE
7 ncmds 18
8 sizeofcmds 2128
9 flags 0x00200085
10
11Load command 0
12 cmd LC_SEGMENT_64
13 cmdsize 72
14 segname __PAGEZERO
15 vmaddr 0x0000000000000000
16 vmsize 0x0000000100000000
17 fileoff 0
18 filesize 0
19 maxprot 0x00000000
20 initprot 0x00000000
21 nsects 0
22
23Load command 1
24 cmd LC_SEGMENT_64
25 cmdsize 632
26 segname __TEXT
27 vmaddr 0x0000000100000000
28 vmsize 0x0000000000004000
29 fileoff 0
30 filesize 16384
31 maxprot 0x00000005 ; r-x
32 initprot 0x00000005
33 nsects 7
34Section
35 sectname __text
36 segname __TEXT
37 addr 0x0000000100001c00
38 size 0x00000000000008a0
39 offset 7168
40 align 2^2 (4)
41 type S_REGULAR
42attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
43Section
44 sectname __objc_methname
45 segname __TEXT
46 addr 0x0000000100003a10
47 size 0x000000000000012c
48 offset 14864
49 type S_CSTRING_LITERALS
50
51Load command 2
52 cmd LC_SEGMENT_64
53 cmdsize 472
54 segname __DATA
55 vmaddr 0x0000000100004000
56 vmsize 0x0000000000004000
57 fileoff 16384
58 filesize 16384
59 maxprot 0x00000003 ; rw-
60 initprot 0x00000003
61 nsects 5
62Section
63 sectname __got
64 segname __DATA
65 addr 0x0000000100004000
66Section
67 sectname __objc_classlist
68 segname __DATA
69 addr 0x0000000100004120
__PAGEZERO is the 4 GiB unmapped hole that makes a NULL dereference die instead of mapping as data. __TEXT is r-x, file-backed from offset 0 (the header itself lives here). __DATA is rw-. On a current Xcode toolchain I also see __DATA_CONST (relro-ish constants) as its own LC_SEGMENT_64; this 2020 lab binary still folds classlists into __DATA. I do not care which layout I get as long as initprot on __TEXT is not writable.
Selectors live in __TEXT,__objc_methname. That is why class-dump still works after strip:
1$ class-dump LabSession | sed -n '/LabSession/,+16p'
2@interface LabSession : NSObject
3{
4 NSString *_token; // 0x08
5 NSURLSession *_http; // 0x10
6}
7- (id)initWithEnvironment:(id)env;
8- (void)startWithToken:(id)token;
9- (void)copyIdentifier:(id)name;
10- (void)invalidate;
11@end
LC_LOAD_DYLIB, LC_CODE_SIGNATURE, LC_ENCRYPTION_INFO_64
1Load command 9
2 cmd LC_LOAD_DYLIB
3 cmdsize 88
4 name /usr/lib/libobjc.A.dylib (offset 24)
5 time stamp 2 Wed Dec 31 16:00:02 1969
6 current version 228.0.0
7compatibility version 1.0.0
8
9Load command 10
10 cmd LC_LOAD_DYLIB
11 cmdsize 96
12 name /System/Library/Frameworks/Foundation.framework/Foundation
13
14Load command 16
15 cmd LC_ENCRYPTION_INFO_64
16 cmdsize 24
17 cryptoff 16384
18 cryptsize 0
19 cryptid 0 ; lab requirement
20 pad 0
21
22Load command 17
23 cmd LC_CODE_SIGNATURE
24 cmdsize 16
25 dataoff 32768
26 datasize 9280
cryptid 0 means the __TEXT slice is not FairPlay-encrypted. If that field is 1, I stop. I do not document FairPlay unwrap, I do not run third-party decrypt helpers, and I switch to a build I signed myself.
LC_CODE_SIGNATURE is a blob at the end of __LINKEDIT. Presence of the command is not the same as a trusted signature. For the lab:
1$ codesign -d -vv LabSession 2>&1 | egrep 'Identifier|Authority|flags|Executable'
2Executable=/[REDACTED]/Build/Products/Debug-iphonesimulator/LabSession.app/LabSession
3Identifier=com.lab.session
4Format=app bundle with Mach-O thin (arm64)
5CodeDirectory v=20400 flags=0x2(adhoc)
6Authority=N/A ; ad-hoc / self-signed lab
flags=adhoc is expected for a simulator debug build. I am not bypassing AMFI; I am reading a file I signed.
size -x and a few nm symbols
1$ size -x -m LabSession
2Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
3Segment __TEXT: 0x4000 (vmaddr 0x100000000 fileoff 0)
4 Section __text: 0x8a0 (addr 0x100001c00 offset 0x1c00)
5 Section __stubs: 0x78 (addr 0x1000024a0 offset 0x24a0)
6 Section __objc_methname: 0x12c (addr 0x100003a10 offset 0x3a10)
7 total 0x2c80
8Segment __DATA: 0x4000 (vmaddr 0x100004000 fileoff 0x4000)
9 Section __got: 0x40
10 Section __objc_classlist: 0x10
11 total 0x980
12Segment __LINKEDIT: 0x8000 (vmaddr 0x100008000 fileoff 0x8000)
13total 0x10000c000
size -x is the hex view I want when I am about to add file offsets to a hex editor. vmsize of __PAGEZERO dominates the “total”; that number is not the file length.
1$ nm LabSession | egrep ' T | t | U '
20000000100001c00 T _main
30000000100001c80 t -[LabSession startWithToken:]
40000000100001d20 t -[LabSession copyIdentifier:]
50000000100001e10 t -[LabSession insecureCopy:]
6 U _memcpy
7 U _objc_msgSend
8 U _NSLog
_main and the three method IMPs are local to this image. _objc_msgSend and _memcpy are undefined — dyld will bind them. On device those IMPs live in the dyld shared cache; in the simulator they still show as U.
ARM64 at copyIdentifier:
1; otool -tV LabSession (file addresses, slide 0)
20000000100001d20 pacibsp
30000000100001d24 stp x29, x30, [sp, #-0x30]!
40000000100001d28 mov x29, sp
50000000100001d2c stp x20, x19, [sp, #0x10]
60000000100001d30 sub sp, sp, #0x10 ; 16-byte local
70000000100001d34 mov x19, x0 ; self
80000000100001d38 mov x20, x2 ; NSString * name
90000000100001d3c mov x0, x20
100000000100001d40 bl 0x1000024a0 ; -[NSString UTF8String] stub
110000000100001d44 mov x1, x0 ; const char *utf
120000000100001d48 add x0, x29, #0x18 ; &buf[16]
130000000100001d4c mov x2, #0x40 ; 64, not 16
140000000100001d50 bl 0x1000024c4 ; _memcpy stub
x0 = dest, x1 = src, x2 = 0x40. The local is 16 bytes. That is the lab bug. Frame layout is the usual AArch64 pair: saved fp/lr, callee-saved, then the local.
lldb: slide, sections, then the IMP
1(lldb) process launch --stop-at-entry
2(lldb) image list LabSession
3[ 0] A1B2C3D4-E5F6-7890-ABCD-EF1234567890 0x0000000104a80000 LabSession
4(lldb) image dump sections LabSession
5 0x0000000104a80000-0x0000000104a84000 r-x __TEXT
6 0x0000000104a81c00-0x0000000104a824a0 r-x __TEXT.__text
7 0x0000000104a84000-0x0000000104a88000 rw- __DATA
8(lldb) breakpoint set -n '-[LabSession copyIdentifier:]'
9(lldb) c
10(lldb) disassemble -f
11LabSession`-[LabSession copyIdentifier:]:
12 0x104a81d20: pacibsp
13 0x104a81d24: stp x29, x30, [sp, #-0x30]!
14(lldb) po [$x2 length]
1536
16(lldb) # do not po the identifier string; length only
ASLR slide is 0x104a80000 - 0x100000000 = 0x4a80000. Every file address from otool adds that. I log length, not the 36-character stand-in.
Sanitized reproduction
UI pastes lab_ + 'A'*32 into the identifier field, which calls copyIdentifier:.
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: 0x0000000104a81d50 LabSession`-[LabSession copyIdentifier:] + 0x30
4 frame #2: 0x0000000104a81c90 LabSession`-[LabSession startWithToken:] + 0x10
ASAN on the same source:
1==388==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
2WRITE of size 64 at ... thread T0
3 #0 memcpy
4 #1 -[LabSession copyIdentifier:] LabSession.m:58
5Shadow bytes around the buggy address:
6 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3
Source of the lab bug, not a payload:
1// LabSession.m — intentional lab bug
2- (void)copyIdentifier:(NSString *)name {
3 char buf[16];
4 const char *u = name.UTF8String;
5 memcpy(buf, u, 64); // bound is a lie
6 _scratch = buf[0];
7}
Repro is: 36-byte UTF-8 identifier, 16-byte stack slot, memcpy size 64, ASAN shadow, pc in copyIdentifier:+0x30. It is not a jailbreak, not an AMFI bypass, and not FairPlay.
Closing
otool -l is the whole map: header, r-x __TEXT, rw- __DATA, dylibs, signature blob, cryptid. cryptid=1 ends the session. size -x and nm pin the sections and the handful of symbols I actually need. Then ARM64, then a crash I planted so the write-up has a reproduction that is a crash, not a decrypt.
Commands appendix
1file LabSession
2otool -h LabSession
3otool -l LabSession | egrep 'cmd |segname|sectname|cryptid|LC_LOAD_DYLIB|LC_CODE|LC_UUID'
4otool -tV LabSession | sed -n '/copyIdentifier/,+24p'
5size -x -m LabSession
6nm LabSession | egrep 'main|copyIdentifier|msgSend'
7class-dump LabSession
8codesign -d -vv LabSession
9xcrun lldb ./LabSession