iOS userland reversing is mostly: find the selector, find the IMP, read the ARM64. objc_msgSend is the choke point. This lab uses a self-signed LabSession binary I compile with Xcode for the simulator / a dev device. cryptid=1 App Store slices are not decrypted here.

Mach-O load commands
Figure 1. Load commands I read before any disassembly. cryptid=1 stops the lab.
objc_msgSend dispatch
Figure 2. self + SEL → cache or method list → IMP.

Confirm the image is actually reverseable

 1$ file LabSession
 2LabSession: Mach-O 64-bit executable arm64
 3
 4$ otool -l LabSession | egrep 'cmd |cryptid|segname|product'
 5      cmd LC_SEGMENT_64
 6  segname __TEXT
 7      cmd LC_SEGMENT_64
 8  segname __DATA_CONST
 9      cmd LC_ENCRYPTION_INFO_64
10  cryptid 0                 ; lab requirement
11      cmd LC_CODE_SIGNATURE
12
13$ codesign -d --entitlements :- LabSession 2>/dev/null | head
14<?xml version="1.0" encoding="UTF-8"?>
15<plist>
16  <key>get-task-allow</key>
17  <true/>                   ; debugable lab build
18  <key>application-identifier</key>
19  <string>XXXXXX.com.lab.session</string>   ; team id redacted
20</plist>

If cryptid is 1, I stop and switch to a build I own. I do not document FairPlay unwrap.

Selectors from the Mach-O, not from hope

 1$ class-dump LabSession | sed -n '/LabSession/,+24p'
 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)invalidate;
10@end

startWithToken: is the method I care about. class-dump reads __objc_methname / class dumps; stripped bins still have selector strings unless they are obfuscated.

1$ otool -v -s __TEXT __objc_methname LabSession | grep start
2Contents of (__TEXT,__objc_methname) section
30000000100003a10  startWithToken:

Where objc_msgSend is called

1; Hopper / otool -tV  (addresses with slide 0 for the file)
20000000100001c80  ldr    x0, [x19, #0x0]      ; LabSession *
30000000100001c84  adrp   x1, 1
40000000100001c88  add    x1, x1, #0x0a10      ; SEL startWithToken:
50000000100001c8c  ldr    x2, [sp, #0x18]      ; NSString * token
60000000100001c90  bl     0x1000045c0          ; objc_msgSend stub

x0 = self, x1 = SEL, x2 = first real argument. That is the ARM64 ObjC ABI, every time.

1$ nm LabSession | grep msgSend
2                 U _objc_msgSend

The stub lives in the dyld shared cache on device; in the simulator it is still an undefined symbol bound at load.

lldb: break, print class and selector, jump to IMP

1(lldb) process launch --stop-at-entry
2(lldb) breakpoint set -n objc_msgSend
3(lldb) breakpoint modify 1 -c '(BOOL)(void*)$x1 != 0'
4(lldb) # too hot. Filter by selector address we know:
5(lldb) breakpoint set -n objc_msgSend \
6        -C 'bool ok = (bool)[(char*)$x1 contains "startWithToken"]; return ok;'
7# lldb cond syntax varies; I use a Python callback in practice:
8
9(lldb) command script import lldb_sel.py

lldb_sel.py (lab):

 1# lldb_sel.py — print class + selector, skip everything else
 2import lldb
 3
 4def sel_stop(frame, bp_loc, extra):
 5    x0 = frame.FindRegister('x0').GetValueAsUnsigned()
 6    x1 = frame.FindRegister('x1').GetValueAsUnsigned()
 7    proc = frame.GetThread().GetProcess()
 8    err = lldb.SBError()
 9    sel = proc.ReadCStringFromMemory(x1, 128, err)
10    if sel != 'startWithToken:':
11        return False  # continue
12    print('[msgSend] sel=%s self=%#x' % (sel, x0))
13    return True       # stop
14
15def __lldb_init_module(debugger, internal_dict):
16    debugger.HandleCommand(
17        'breakpoint set -n objc_msgSend -s libobjc.A.dylib')
18    debugger.HandleCommand(
19        'breakpoint command add -F lldb_sel.sel_stop 1')

When it stops:

 1[msgSend] sel=startWithToken: self=0x0000000281a0c0c0
 2(lldb) po $x0
 3<LabSession: 0x281a0c0c0>
 4(lldb) po $x2
 5<redacted: length=36, prefix=lab_>     ; I use my own description method
 6(lldb) # do not po tokens in a real app
 7(lldb) disassemble -n '-[LabSession startWithToken:]'
 8LabSession`-[LabSession startWithToken:]:
 9    0x100001d20:  pacibsp
10    0x100001d24:  stp    x20, x19, [sp, #-0x20]!
11    0x100001d28:  stp    x29, x30, [sp, #0x10]
12    0x100001d2c:  add    x29, sp, #0x10
13    0x100001d30:  mov    x19, x0
14    0x100001d34:  mov    x20, x2          ; token
15    0x100001d38:  adrp   x8, 2
16    0x100001d3c:  ldr    x8, [x8, #0x1c8] ; ivar _token
17    0x100001d40:  str    x20, [x19, x8]

IMP recovered. The method stores the argument into _token. That is the analysis result.

AArch64 frame
Figure 3. Frame I expect at the IMP: x29/x30 pair, then callee-saved.

Sanitized reproduction

I call the lab UI, paste a 36-character stand-in token lab_ + 'A'*32.

1(lldb) po [self->_token length]
236
3(lldb) memory read -c 8 $x20
40x0000000283bb4a00: 6c 61 62 5f 41 41 41 41    lab_AAAA
5# remaining 28 bytes not copied into the note

If I need to show a crash, I have a second lab method that memcpys the UTF8 of the token into a 16-byte stack buffer. Trigger:

1// LabSession.m — intentional lab bug
2- (void)insecureCopy:(NSString *)token {
3    char buf[16];
4    const char *u = token.UTF8String;
5    memcpy(buf, u, strlen(u) + 1);  // no bound
6    _scratch = buf[0];
7}
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: 0x0000000100001e10 LabSession`-[LabSession insecureCopy:] + 0x38

ASAN on the same file:

1==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
2WRITE of size 37 at ... thread T0
3    #0 memcpy
4    #1 -[LabSession insecureCopy:] LabSession.m:41
5Shadow bytes around the buggy address:
6  00 00 00 00[f1]f1 f1 f1 00 00[f3]f3

That is a complete repro for the lab bug: source line, memcpy size 37, 16-byte buffer, ASAN shadow. It is not an iOS jailbreak, not a codesign bypass, and not a kernel bug.

Frida variant (same IMP, truncated log)

 1const m = Module.getExportByName('libobjc.A.dylib', 'objc_msgSend');
 2Interceptor.attach(m, {
 3  onEnter(args) {
 4    const sel = (new ObjC.Object(args[1])).toString();
 5    if (sel !== 'startWithToken:') return;
 6    const cls = new ObjC.Object(args[0]).$className;
 7    const n = new ObjC.Object(args[2]).length();
 8    console.log(cls, sel, 'arg2.len=' + n);
 9  }
10});
1LabSession startWithToken: arg2.len=36

I log length, not contents, unless the sample is my own and the value is a dummy.

Closing

Selector string → objc_msgSend site → IMP → ARM64. cryptid, entitlements, and whether I own the build decide if I even start. Argument dumps get truncated. The lab memcpy crash exists so the write-up has a reproduction that is a crash, not a payload.

Commands appendix

1otool -l LabSession | egrep 'cryptid|segname|LC_CODE'
2class-dump LabSession
3nm LabSession | grep msgSend
4xcrun lldb ./LabSession