MacDBG: Building Scriptable Debuggers on macOS with Mach Kernel APIs
Hook
While most developers reach for LLDB's Python bindings for scriptable debugging, macOS has a more powerful—if obscure—path: direct Mach kernel manipulation through task ports and exception handling.
Context
Debugging on macOS has always been a tale of two worlds. On one side, you have polished interactive debuggers like LLDB and GDB that developers use daily. On the other, you have the raw Mach kernel APIs—task ports, exception handlers, thread state manipulation—that power those debuggers but remain largely inaccessible to anyone not willing to wade through kernel documentation and write reams of C code.
For security researchers, reverse engineers, and developers building custom analysis tools, this gap is frustrating. LLDB's Python scripting helps, but you're still constrained by LLDB's abstractions and workflows. What if you need to build a specialized memory tracer, automate vulnerability research across dozens of processes, or create a custom instrumentation framework? MacDBG emerged to fill this niche: a minimal framework that exposes macOS's debugging primitives through clean Python APIs while maintaining the performance of native C code. It's not trying to replace your daily debugger—it's trying to give you the building blocks to create whatever debugging tool your specific problem demands.
Technical Insight
MacDBG's architecture is a textbook example of layering abstractions correctly. At its core is libmcdb.dylib, a lightweight C library that wraps the Mach kernel's process control APIs. This handles the performance-critical operations: acquiring task ports (which represent kernel-level access to a process), installing exception handlers to intercept crashes and breakpoints, and reading/writing thread state registers. On top of this sits a Python wrapper that provides the developer-friendly interface.
The Mach task port is key to understanding how MacDBG differs from ptrace-based debuggers. When you attach to a process on macOS, you're not just getting permission to peek at memory—you're acquiring a capability-based handle that gives you extensive control over that process's execution. Here's what the attachment process looks like in MacDBG:
from libmcdb import Debugger
# Attach to a running process
dbg = Debugger()
dbg.attach(pid=12345)
# Set a breakpoint at a specific address
dbg.set_breakpoint(0x100001a40)
# Continue execution until breakpoint hits
dbg.run()
# When breakpoint triggers, examine thread state
registers = dbg.get_registers()
print(f"RIP: {hex(registers['rip'])}")
print(f"RAX: {hex(registers['rax'])}")
# Read memory at current instruction pointer
instructions = dbg.read_memory(registers['rip'], 16)
Under the hood, that attach() call is invoking task_for_pid(), a privileged Mach API that returns a task port. The set_breakpoint() method reads the byte at the target address, replaces it with an INT3 instruction (0xCC), and stores the original byte for later restoration. When that breakpoint fires, the kernel delivers a Mach exception message to MacDBG's exception handler thread, which then surfaces it to your Python code as a simple event.
What makes this architecture powerful is the separation of concerns. The C layer handles only the kernel interaction—it knows nothing about disassembly, symbol resolution, or debugging workflows. That's all in Python, where MacDBG provides optional Capstone integration for instruction decoding and helper methods for common tasks like stack walking. Want to trace all malloc calls in Firefox? MacDBG ships with an example that demonstrates exactly this:
# Simplified version of MacDBG's malloc.py example
dbg = Debugger()
dbg.attach(pid=firefox_pid)
# Find malloc in dyld shared cache
malloc_addr = dbg.find_symbol("_malloc")
dbg.set_breakpoint(malloc_addr)
allocation_sizes = []
while True:
dbg.run() # Continue until next malloc call
regs = dbg.get_registers()
# On x86_64, first arg is in RDI
size = regs['rdi']
allocation_sizes.append(size)
if len(allocation_sizes) >= 1000:
break
print(f"Average allocation: {sum(allocation_sizes) / len(allocation_sizes)} bytes")
The watchpoint implementation showcases another architectural decision. Rather than relying on hardware debug registers (which are limited to four on x86_64), MacDBG implements software watchpoints by setting page protections and catching memory access exceptions. It's slower but far more flexible—you can watch arbitrary memory regions without hitting hardware limits. When a watched page is accessed, the exception handler determines which specific address triggered the fault and whether it was a read or write operation.
This design philosophy—exposing low-level primitives through high-level APIs—means MacDBG excels at automation. You can spawn hundreds of processes, attach debuggers to each, set complex conditional breakpoints based on memory patterns, and collect data for analysis. The framework doesn't impose a specific workflow; it gives you the tools and lets you build what you need.
Gotcha
MacDBG's biggest limitation is right there in the README: it's alpha software, and the maintainer is explicit about upcoming breaking changes in the beta release. Function names might change, APIs might be reorganized, and there's no stability guarantee. If you're building a long-term project, you'll need to vendor the code and be prepared to update it yourself.
The examples are also a double-edged sword. The malloc.py script is great for understanding capabilities, but it's hardcoded for specific Firefox builds and memory layouts. You can't just point it at Chrome or another application without understanding both the target's memory structure and MacDBG's internals well enough to adapt the code. This isn't a criticism of the project—it's explicitly a framework, not a turnkey solution—but it means the learning curve is steep. You need solid knowledge of assembly, macOS internals, and the specific programs you're debugging. Documentation beyond the examples is minimal, so expect to read the C source code when things don't behave as expected.
System Integrity Protection (SIP) also limits MacDBG's utility on modern macOS. Attaching to system processes or any binary with hardened runtime protections requires either disabling SIP entirely or codesigning your debugger with the right entitlements. This isn't unique to MacDBG—all debuggers face this restriction—but it's worth noting that many interesting reverse engineering targets are off-limits without compromising your system's security posture.
Verdict
Use if: You're doing security research, building custom analysis tools, or automating complex debugging workflows on macOS where you need direct control over process execution and can handle alpha-quality code. MacDBG shines when you need to programmatically instrument dozens of processes, implement custom memory tracers, or build specialized debugging tools where LLDB's abstractions get in the way. It's ideal for one-off research projects or rapid prototyping of debugging concepts where you can afford to adapt to breaking changes. Skip if: You need production stability, comprehensive documentation, cross-platform support, or a traditional interactive debugging experience. If you're debugging application logic rather than reverse engineering or security research, LLDB with Python scripting gives you 90% of MacDBG's programmability with far better stability and community support. Also skip if you're uncomfortable reading C source code to understand behavior—MacDBG assumes you can dive into the implementation when the examples aren't enough.