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