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.

Mach-O load commands
Figure 1. Header, segments, dylibs, code signature, encryption. cryptid=1 stops the lab.

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.

AArch64 frame
Figure 2. Frame at copyIdentifier: x29/x30 pair, callee-saved, 16-byte local under a 64-byte memcpy.

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