Xcode MCP Turns AI into a Runtime Disassembly Partner for Stubborn System-Stack Crashes
iOS clients hit one of the most headache-inducing crash types: the stack is full of system libraries, with no existing source code to consult. Static disassembly tools mostly guess rather than confirm, and they lose runtime information such as key clues. For some stubborn problems, runtime binary analysis is unavoidable.
Such analysis requires environment setup, GUI operations, business reproduction, and so on. AI cannot fully take over; a more reasonable approach is collaboration between engineer and AI.
IDA can debug iOS, but its operation is complex, the experience is poor, and its adaptation is outdated. lldb-mcp has no GUI and suits automation better. The new Xcode's mcpbridge provides an LLDB invocation channel, making seamless collaboration appear feasible.
A Spine-Chilling System Stack
Here is a crash report example:
EXC_BAD_ACCESS(SIGSEGV)
Trapped mach exception code 0x1, subcode 0x1d
0 libdispatch.dylib dispatch_source_set_timer + 36
1 CFNetwork URLConnectionLoader::loadWithWhatToDo(NSURLRequest*, _CFCachedURLResponse const*, long, URLConnectionLoader::WhatToDo) + 959
2 CFNetwork URLConnectionLoader::loadWithWhatToDo(NSURLRequest*, _CFCachedURLResponse const*, long, URLConnectionLoader::WhatToDo) + 959
3 CFNetwork invocation function for block in URLConnectionLoader::continueWithCacheLookupResult(NSURLRequest*, _CFCachedURLResponse const*, NSURLRequestCachePolicy) + 99
4 CFNetwork invocation function for block in URLConnectionLoader::withExistingProtocolAsync(void (URLProtocol*) block_pointer) const + 39
5 CFNetwork invocation function for block in QCoreSchedulingSet::performAsync(void () block_pointer) const + 51
6 libdispatch.dylib _dispatch_call_block_and_release + 31
7 libdispatch.dylib _dispatch_client_callout + 15
8 libdispatch.dylib _dispatch_lane_serial_drain + 739
9 libdispatch.dylib _dispatch_lane_invoke + 439
10 libdispatch.dylib _dispatch_workloop_invoke + 1611
11 libdispatch.dylib _dispatch_root_queue_drain_deferred_wlh + 291
12 libdispatch.dylib _dispatch_workloop_worker_thread + 691
13 libsystem_pthread.dylib _pthread_wqthread + 326
Thread 65 crashed with ARM Thread State (64-bit):
x0:0x0000000121be6f80 x1:0x0000019d410181e8 x2:0xffffffffffffffff x3:0x000000003b9aca00
x4:000000000000000000 x5:0x0000000000000020 x6:000000000000000000 x7:000000000000000000
...
fp:0x000000016dfa2690 lr:0x00000001b5ad9968 sp:0x000000016dfa2650
pc:0x00000001d9455fe0 cpsr:0x0000000020000000
All system libraries, not a single application frame. The only clues are the register state and the subcode.
How We Did It in the Ancient Era
The primary goal: confirm the crash object's type and its origin.
In the ancient era, we might try:
- Static disassembly: Without a dSYM, take the binary UUID from the crash report to find the corresponding system library version, drag it into Hopper / Ghidra, jump to the offset, read instructions or pseudocode. Missing runtime information, many scenarios require heavy guessing without direct confirmation.
- Source code hunting: A few base libraries are on Apple's open-source site, but versions often don't match; more core components (like the C++ classes inside CFNetwork) are closed-source, nowhere public, so you have to grind through assembly.
- Runtime assembly analysis: Start LLDB, attach, set breakpoints, trigger reproduction, then it's all human:
- Read registers to infer parameter semantics: is x0 a pointer or a constant, an Objective-C object or a C++ object?
- Read memory to infer object layout: what member is at +0x58, how many bytes each data type occupies.
- Read assembly branches to guess flow, write registers/memory to change execution paths.
- For OC objects, dig through isa to find class and method lists; for block objects, dig out reference tables to analyze memory counts.
- Loop of guess-and-verify: every step is a guess. Library version correct? Object type? Offset meaning? Failure mechanism (initialization incomplete vs. freed-and-zeroed)? If any link is wrong, the entire reasoning chain collapses and restarts.
Depending on complexity, this takes from tens of minutes to days. Much of it is complex mechanical labor — exactly what AI should take over.
How to Use Xcode MCP
Integration is simple: Xcode > Settings > Intelligence, enable "Allow external agents to use Xcode tools", then register Xcode's built-in mcpbridge in the agent client:
{
"mcpServers": {
"xcode": {
"command": "xcrun",
"args": ["mcpbridge"]
}
}
}
Once connected, the InvokeDebuggerCommand is the LLDB operation channel. The AI can set breakpoints, read registers, read memory, and change execution paths in the active debug session — operations sync in real time to the Xcode interface, so a human can observe and take over at any moment. The division of labor forms naturally: the human triggers reproduction, narrows hypotheses, and decides the conclusion; the AI does breakpoint evidence gathering, assembly reading, and batch verification.
But letting AI run wild hits pitfalls: continue is asynchronous — returning resuming doesn't mean it has paused; you must poll process status until it stops. LLDB commands can be chained with newlines for batch execution, saving many round trips. These discipline rules distill into a simple skill.
The skill doesn't write general knowledge like instruction meanings or system class memory layouts. It writes only:
- Channel discipline: polling, invocation shape, batch commands, channel characteristics — the model doesn't know these; miss one and the pipeline breaks.
- Scenario experience: slide offset conversion for unsymbolicated libraries, arguments starting at x2 after
objc_msgSend— "scenario × action" experience. The model has general knowledge but may not combine it.
Collaborative Analysis Between Engineer and AI
With Xcode running, combined with the agent's perspective (the agent calls Xcode's LLDB process via MCP to execute commands and returns receipts):
The agent ran for about half an hour (during a model congestion period), during which some GUI interaction was needed. A brief analysis flow:
> The only clues are the crash report: `subcode 0x1d`, `dispatch_source_set_timer + 36`, register state, no application frames in the stack.
1. **Authorization and session probing** | `XcodeOpenWorkspace` triggers authorization; pick the one with an active debug session from two workspace IDs; `process status` confirms PID 63623 is running | executed 11 commands, wrote 0 B | read 1 empty file, 0 B memory
2. **Locating the trigger entry** | Locate the demo among 27 `.xcodeproj`s; read `ViewController.m`, `ScenarioRunner.m` to confirm the "Case 2" button sends a network request | executed 4 commands, wrote 0 B | read 2 source files about 90 lines, 0 B memory
3. **Module and symbol location** | `image list` + `image lookup -n/-rn`: no standalone C symbol `dispatch_source_set_timer` locally; the implementation is `_dispatch_source_set_runloop_timer_4CF` | executed 6 commands (2 failed), wrote 0 B | read about 250 symbols, 0 B memory
4. **Disassemble to locate the faulting instruction** | `disassemble -n ... -c 16` yields `+32 mov x19,x0` → `+36 ldr x25,[x0,#0x58]` → `+40 ldrb w8,[x25,#0x1d]`; the `0x1d` is the immediate of this instruction | executed 1 command, wrote 0 B | read 32 disassembled instructions, 0 B memory
5. **Trace origin: who creates the object** | Disassemble `QCoreSchedulingSet::createDispatchTimer`, see its tail call `dispatch_source_create` creating a TIMER source | executed 2 commands, wrote 0 B | read 24 disassembled instructions, 0 B memory
6. **Trace: who calls set_timer** | Disassemble `URLConnectionLoader::scheduleTimeoutTimer`, see at `+92` it takes the source from loader, `mov x2,#-1`, assembles `0x3b9aca00`, matching x2/x3 bit-for-bit in the report | executed 2 commands, wrote 0 B | read 40 disassembled instructions, 0 B memory
7. **Confirm the import stub** | Read `__auth_stub`'s `adrp/add/ldr/braa`, confirm CFNetwork's set_timer call ultimately jumps into 4CF | executed 3 commands (1 failed), wrote 0 B | read 10 disassembled instructions, 0 B memory
8. **Breakpoint round 1: 4CF function entry** | Hits UIKit/BaseBoard keyboard timer; read the stack to judge as shared API noise; incidentally `po` + memory read first confirms `OS_dispatch_source` and `[x0+0x58]=ds_refs`, then disable | executed 5 commands, wrote 0 B | read 8 frames stack, 9 registers, 2 memory locations 64 B
9. **Breakpoint round 2: set_timer import stub** | Still hits BaseBoard; judge it as a shared cache global stub, not CFNetwork exclusive; disable | executed 4 commands, wrote 0 B | read 8 frames stack, 0 B memory
10. **Breakpoint round 3: CFNetwork's only call site** | Break at `scheduleTimeoutTimer + 152`; after releasing, two `NSURLSession-work` threads hit simultaneously | executed 6 commands, wrote 0 B | read 8 frames stack, 0 B memory
11. **Normal-state evidence collection** | `bt`/`register read`/`po $x0`/`memory read` confirm the object is a timer source, `[x0+0x58]` is valid, `ds_refs+0x1d = 0x02` | executed 9 commands, wrote 0 B | read 21 registers, 2 object descriptions, 2 memory locations 48 B
12. **Injection reproduction** | Don't change x0 (preserving the report's "x0 valid" feature), only clear `[x0+0x58]`, verify and continue → `EXC_BAD_ACCESS (address=0x1d)` | executed 5 commands, wrote 1 location 8 B | read 1 location 8 B
13. **Crash scene recording and cleanup** | Read `bt 8`, 12 registers, 6 instructions near PC, compare point-by-point with the report, then delete 3 breakpoints | executed 4 commands, wrote 0 B | read 6 disassembled instructions, 12 registers, 8 frames stack
14. **Stack difference review** | `thread list` + switch threads `bt 12/20` to complete 11 frames; disassemble `continueWithCacheLookupResult` block to prove that the reported chain exists locally | executed 8 commands (1 failed), wrote 0 B | read 26 disassembled instructions, 21 frames stack
## Conclusion
The object: a timer source of OS_dispatch_source — CFNetwork's connection timeout timer. The crash is while reading its internal flag byte at ds_refs + 0x1d.
Origin: created inside CFNetwork, held by the loader. The unavailability isn't a bad parameter — it's that the object's internal [x0+0x58] was cleared (framework-internal UAF / lifetime race or memory stomp). Clearing those 8 bytes locally reproduces the same subcode 0x1d, confirming the reproduction.
For this single task, human involvement was low because the overall context was compact and the business wasn't fragmented. But to root-cause deeply, the engineer still needs to combine business and scenario for more analysis. With this conclusion, you can continue the next step of investigation: how the loader manages the source's lifetime, whether it's a bug in the underlying layer or upper-layer API interference. The follow-up is complex and not expanded here.
Throughout, the agent issued about 70 debug commands, read about 140 disassembled instructions and 250 symbols, wrote 8 bytes into the process, and made many "experienced" decisions — mechanical steps that would have taken a senior engineer a long time.
The skill's flow and discipline kept the analysis on track: locate → trigger → read the scene → semanticize; all four steps hit. 70 commands with zero premature execution, zero state corruption.
Summary
AI completed the segment of dynamic binary analysis — what the object is, where it comes from, how to reproduce it. Further business attribution and fix decisions still require the engineer to take this conclusion back into the business to continue. This toolkit isn't limited to crash analysis: confirming black-box behavior of third-party SDKs, runtime verification of system APIs, evidence collection of intermittent issues — all use the same workflow.
In such scenarios, engineers may no longer need to memorize large amounts of assembly and fixed OS knowledge, but rather understand principles and methodologies. Engineers make core decisions; AI serves as a solution contributor and executor of repetitive labor.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
The part on aiding runtime binary analysis is worth reading again. Bookmarking it for now.