Tips and tricks for macOS lldb

If there's one thing that I've internalized from years of work, it's that if you do something twice, write it down. I reference stuff I wrote 6 years ago daily. I'm now hitting a lot of the same questions and patterns for answering in my lldb investigations, so here's my living public wiki. Expect it to be heavy on code pointers and light on explanation.

What functions does a class have?

Load it in Hopper and search the string.

If you know the name of the class:

po [@"" __methodDescriptionForClass:[CKConversationList class]]

If you have a pointer

po [@"" __methodDescriptionForClass:[0x7aa2894a0 class]]

If neither works:

expr -l objc -- 
unsigned int n = 0;
void **methods = (void **)class_copyMethodList((Class)[[IMDChatRegistry sharedInstance] class], &n);
NSMutableString *s = [NSMutableString string];
for (unsigned int i = 0; i < n; i++) {
    const char *name = (const char *)sel_getName((SEL)method_getName(methods[i]));
    ((void (*)(id, SEL, NSString *, ...))objc_msgSend)(s, @selector(appendFormat:), @"%s\n", name);
}
(void)free(methods);
s

Where is a class defined?

image lookup -rn "IMDChatRegistry"

Find the class pointer and look it up.

expr (void*)objc_lookUpClass("IMDChatRegistry")
# (void *) $5 = 0x00000001f2108ce0
image lookup -a 0x00000001f2108ce0
#      Address: IMDaemonCore[0x00000001ee708ce0] (IMDaemonCore.__DATA_DIRTY.__objc_data + 480)
#      Summary: (void *)0x00790001f2109190

Where is a function defined?

image lookup -n "-[IMDaemonChatRequestHandler _loadChat:completionHandler:]"
p/x ((uint64_t)(void *)class_getMethodImplementation((Class)NSClassFromString(@"IMDaemonChatRequestHandler"), @selector(_loadChat:completionHandler:))) & 0x0000007fffffffff
0x000000010421240c
image lookup -a 0x000000010421240c
#      Address: imagent[0x000000010001240c] (imagent.__TEXT.__text + 66004)
#      Summary: imagent`___lldb_unnamed_symbol769

Handle wait for offsets

Sometimes you want to break on something, the symbol tables are removed, but you need to break on it early. First, run normally, and get an address

p/x (void *)class_getMethodImplementation((Class)objc_getClass("IMChatRegistry"), (SEL)sel_registerName("_registerChatDictionary:forChat:isIncoming:newGUID:shouldPostNotification:"))
# 0x8f200001cedd0d80
(lldb) image lookup -a 0x8f200001cedd0d80
# Address: IMCore[0x00000001cb3d0d80] (IMCore.__TEXT.__text + 967872)
# Summary: IMCore`___lldb_unnamed_symbol7909

Then

# Trigger your waitfor
lldb --wait-for -n Messages
# 
settings set target.process.stop-on-sharedlibrary-events true
br set -s IMCore -a 0x1cb3d0d80
settings set target.process.stop-on-sharedlibrary-events false

Break on regex

breakpoint set --func-regex "-\\[AMS.*\\]"

See XPC changes

-[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:]

# Print the connection details (ps -p 66515 confirms that this is imagent)
po $x0
<NSXPCConnection: 0x768129860> connection to service with pid 66515 named com.apple.imagent.desktop.auth

# Print the selector
p (char *)$x6
loadChatWithGUID:reply:

# Print the argument count
p/d $x4
2

# Print the arguments (we know there are 2)
po ((id *)$x3)[0]
iMessage;+;chat449880512908674466
po ((id *)$x3)[1]
 <__NSMallocBlock__: 0x76ad14570>
  signature: "v16@?0@"NSArray"8"
  invoke   : 0xee498001ced44920 (/System/iOSSupport/System/Library/PrivateFrameworks/IMCore.framework/Versions/A/IMCore`IMCoreSimulatedEnvironmentEnabled)

Longer strings

settings set target.max-string-summary-length 100000

Modify breakpoints

breakpoint modify -c '(BOOL)[(id)[(id)$x0 guid] containsString:@"+15049019053"]' 1

Make Hopper work in jumping

First get the slid address with image dump sections IMDaemonCore, taking the "Load Address" for .__TEXT (in this case 0x00000001ba246000), and in Hopper do Modify > Change File Base Address... to 0x00000001b6846000:

image dump sections IMDaemonCore
# Load Address 0x00000001ba246000

How do I see the code?

disassemble --frame --count 50

How do I see what writes/reads to a given variable?

We're looking for what interacts with IMChatRegistry's _cachedChatsInThreadNameMap. After connecting with lldb -n Messages we can run this to see that it's an offset of 0x98.

po ivar_getOffset((void *)class_getInstanceVariable((Class)objc_getClass("IMChatRegistry"), "_cachedChatsInThreadNameMap"))
# 0x98

A little back and forth with Claude created this script, which could be used to find the loaded named callpaths in IMChatRegistry that interacted with it:

script
import lldb
import re

PAC_MASK = 0x0000007FFFFFFFFF

def get_ivar_offset(target, class_name, ivar_name):
    options = lldb.SBExpressionOptions()
    options.SetLanguage(lldb.eLanguageTypeObjC)
    expr = f'(long)ivar_getOffset((void *)class_getInstanceVariable((Class)objc_getClass("{class_name}"), "{ivar_name}"))'
    val = target.EvaluateExpression(expr, options)
    return val.GetValueAsSigned() if val.IsValid() and val.GetError().Success() else None

def get_class_imps(target, class_name, metaclass=False):
    options = lldb.SBExpressionOptions()
    options.SetLanguage(lldb.eLanguageTypeObjC)
    cls_expr = (f'(Class)object_getClass((id)objc_getClass("{class_name}"))'
                if metaclass else f'(Class)objc_getClass("{class_name}")')
    n = target.EvaluateExpression(
        f'(unsigned int)({{ unsigned int n = 0; (void)class_copyMethodList({cls_expr}, &n); n; }})',
        options).GetValueAsUnsigned()
    if n == 0:
        return []
    arr_ptr = target.EvaluateExpression(
        f'(void **)class_copyMethodList({cls_expr}, (unsigned int *)0)',
        options).GetValueAsUnsigned()
    out = []
    for i in range(n):
        m = target.EvaluateExpression(f'(void *)((void **){arr_ptr})[{i}]', options).GetValueAsUnsigned()
        if not m:
            continue
        imp = target.EvaluateExpression(f'(void *)method_getImplementation((void *){m})', options).GetValueAsUnsigned()
        sel = target.EvaluateExpression(f'(char *)sel_getName((void *)method_getName((void *){m}))', options).GetSummary() or ""
        out.append((imp & PAC_MASK, sel.strip('"')))
    target.EvaluateExpression(f'(void)free((void *){arr_ptr})', options)
    return out

def build_method_ranges(target, class_name):
    ranges = []
    for imps, kind in [(get_class_imps(target, class_name, False), '-'),
                       (get_class_imps(target, class_name, True), '+')]:
        for imp_addr, sel in imps:
            saddr = lldb.SBAddress(imp_addr, target)
            sym = saddr.GetSymbol()
            if sym.IsValid():
                start = sym.GetStartAddress().GetLoadAddress(target)
                end = sym.GetEndAddress().GetLoadAddress(target)
            else:
                fn = saddr.GetFunction()
                if not fn.IsValid():
                    continue
                start = fn.GetStartAddress().GetLoadAddress(target)
                end = fn.GetEndAddress().GetLoadAddress(target)
            ranges.append((start, end, sel, kind))
    return ranges

def find_symbol_addr(target, name):
    for ctx in target.FindSymbols(name):
        sym = ctx.GetSymbol()
        if sym.IsValid():
            return sym.GetStartAddress().GetLoadAddress(target)
    return None

def decode_bl_target(addr, word):
    if (word & 0xFC000000) != 0x94000000:
        return None
    imm26 = word & 0x03FFFFFF
    if imm26 & 0x02000000:
        imm26 |= ~0x03FFFFFF
    return addr + (imm26 << 2)

def scan_method(process, start, end, offset, msgsend_addr):
    """Return list of (addr, mnemonic, xn, xt) for STR and msgSend-bound LDR at #offset."""
    imm12 = offset // 8
    str_pat = 0xF9000000 | (imm12 << 10)
    ldr_pat = 0xF9400000 | (imm12 << 10)
    mask = 0xFFFFFC00
    LOOKAHEAD = 6
    err = lldb.SBError()
    data = process.ReadMemory(start, end - start, err)
    if not err.Success():
        return []
    found = []
    seen_ldr = set()
    for i in range(0, len(data) & ~3, 4):
        w = int.from_bytes(data[i:i+4], 'little')
        op = w & mask
        xn = (w >> 5) & 0x1F
        xt = w & 0x1F
        if xn == 31:
            continue
        if op == str_pat:
            found.append((start + i, "str", xn, xt))
        elif op == ldr_pat and (start + i) not in seen_ldr:
            for k in range(1, LOOKAHEAD + 1):
                j = i + k * 4
                if j + 4 > len(data):
                    break
                tgt = decode_bl_target(start + j, int.from_bytes(data[j:j+4], 'little'))
                if tgt == msgsend_addr:
                    found.append((start + i, "ldr", xn, xt))
                    seen_ldr.add(start + i)
                    break
    return found

# --- main ---
target = lldb.debugger.GetSelectedTarget()
process = target.GetProcess()
CLASS = "IMChatRegistry"
IVAR = "_cachedChatsInThreadNameMap"

offset = get_ivar_offset(target, CLASS, IVAR)
ranges = build_method_ranges(target, CLASS)
msgsend = find_symbol_addr(target, "_objc_msgSend")

results = []
for start, end, sel, kind in ranges:
    for addr, mnem, xn, xt in scan_method(process, start, end, offset, msgsend):
        results.append((addr, kind, sel, mnem, xn, xt))

print(f"{CLASS}.{IVAR} @ #{offset:#x}  —  {len(results)} site(s):\n")
for addr, kind, sel, mnem, xn, xt in sorted(results):
    rt = f"x{xt}"
    print(f"  {addr:#x}  {kind}[{CLASS} {sel}]    {mnem} {rt}, [x{xn}, #{offset:#x}]")

prints out

0x1cedd0270  -[IMChatRegistry initAsListener:]    ldr x8, [x19, #0x98]
0x1cedd0274  -[IMChatRegistry initAsListener:]    str x0, [x19, #0x98]
0x1cedd17d4  -[IMChatRegistry _registerChatDictionary:forChat:isIncoming:newGUID:shouldPostNotification:]    ldr x0, [x8, #0x98]
0x1cedd17e8  -[IMChatRegistry _registerChatDictionary:forChat:isIncoming:newGUID:shouldPostNotification:]    ldr x0, [x8, #0x98]
0x1cedd236c  -[IMChatRegistry _clearMapsUsingChat:guids:]    ldr x0, [x21, #0x98]
0x1cedd30d8  -[IMChatRegistry _handleChatParticipantsDidChange:]    ldr x0, [x21, #0x98]
0x1cedd30e8  -[IMChatRegistry _handleChatParticipantsDidChange:]    ldr x0, [x21, #0x98]
0x1cedd59dc  -[IMChatRegistry _resetChatRegistry]    ldr x0, [x19, #0x98]
0x1cedd5b80  -[IMChatRegistry cachedChatsInThreadNameMap]    ldr x0, [x0, #0x98]