Drive the macdbg LLDB wrapper as a headless debugger from Claude — launch/attach a macOS binary, set breakpoints, step, read/write memory and registers, and toggle anti-anti-debug bypasses (PT_DENY_ATTACH, Mach exception ports, fork/exec interception), all via JSON commands over agent.sh instead of the interactive Textual TUI. Use this whenever the user wants Claude itself to debug, trace, or reverse-engineer a macOS binary in this repo (or any target binary/pid) rather than just editing macdbg's own source.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Drive the macdbg LLDB wrapper as a headless debugger from Claude — launch/attach a macOS binary, set breakpoints, step, read/write memory and registers, and toggle anti-anti-debug bypasses (PT_DENY_ATTACH, Mach exception ports, fork/exec interception), all via JSON commands over agent.sh instead of the interactive Textual TUI. Use this whenever the user wants Claude itself to debug, trace, or reverse-engineer a macOS binary in this repo (or any target binary/pid) rather than just editing macdbg's own source.
macdbg headless agent
macdbg's interactive TUI (./macdbg.sh <binary>) is unusable from an agent
because it owns the terminal. ./agent.sh is the second, headless entry
point: same LLDB-backed Debugger core (macdbg/core/debugger.py), driven
by a JSON protocol over a Unix socket instead of the TUI. Only works on
macOS with Xcode command line tools (lldb) installed. Run every command
from the macdbg repo root.
Mental model
agent.sh start launches a background daemon process that owns one live
LLDB session (one target, one or more threads). All later commands
(agent.sh cmd <session> <command>) talk to that daemon over a local Unix
socket and return JSON. The daemon keeps running — with breakpoints,
register state, patched memory, etc. all intact — between your tool calls,
until you explicitly agent.sh stop <session>. Always stop a session
when you're done with it — it holds a live child process and an LLDB
debugger object open until then.
Commands that resume execution (continue, step_*, wait,
decide_fork, decide_exec) block until the process reaches the next
genuine stop, exits, or hits an interactive fork/exec decision. Anti-debug
and tracer breakpoint hits are handled and auto-continued transparently —
you only see "real" stops (your own breakpoints, step completions,
signals, exceptions).
--session, --attach, and --boot-timeout are global options and must
come before the program path — anything after the program path is passed
to the target itself. Giving both a program path and --attach is
rejected.
Session names are plain identifiers only (no /, no .., max 40 chars —
Unix socket path limit). Omit --session for an auto-generated one.
The response includes a boot object with the initial console output and
either an event: "stop" (paused at the entry breakpoint, ready to go) or
event: "exited" (rare, means the target exited during startup).
Every response is one JSON object with "ok": true|false. Resuming
commands additionally include event (stop / exited / running /
pending_decision / terminated), a console field with any buffered
stdout/stderr collected since the last drain, and a stop or exit
object describing what happened.
--timeout on a resume command bounds how long to wait for the next
stop — omit it to block indefinitely; if it fires you get event: "running" and the process is still going (call wait to keep waiting, or
interrupt from a separate agent.sh cmd to force a stop).
--timeout on a plain command instead bounds how long the client
waits for a response at all. Defaults to 60s — enough for the slow ones
(memory_search with scope: "all", scan_live_strings on a large
process, extract_strings on a big binary). --timeout 0 means "fail
almost immediately," not "wait forever" — omit it entirely for the 60s
default.
Every numeric field accepts either a JSON integer or a string —
{"addr": "0x100003f88"} and {"addr": 4294983560} resolve to the same
address. Hex/octal/binary prefixes (0x, 0o, 0b) all work. Prefer the
hex form for addresses; hand-converting to decimal is where transcription
errors happen.
Command reference
Lifecycle:
status — process state, pc, pending decision, tracer state.
restart — kill and relaunch the same target (not available when attached).
interrupt — force-stop a running process.
save — persist breakpoints to ~/.macdbg/<binary>-<sha>/state.json
(also happens automatically on stop).
Execution control (all take optional {"timeout": N}):
continue, step_in (single instruction), step_over, step_out,
step_in_source, step_over_source, wait (keep waiting without
issuing a new resume — use after a running timeout).
Breakpoints:
breakpoint_toggle {"addr": N} — add if absent, remove if present.
Address-only; for symbol-name breakpoints see the "raw idioms" section
below.
breakpoint_list {"hide_internal": true} — internal tracer/defense
breakpoints are hidden by default.
breakpoint_enable {"bp_id": N, "enabled": true|false}
breakpoint_condition {"bp_id": N, "condition": "x0 == 5"}
breakpoint_commands {"bp_id": N, "commands": ["print $x0"]}
breakpoint_delete {"bp_id": N}
These all refuse to touch a breakpoint that belongs to a defense or the
tracer — otherwise you could silently disarm a defense while
breakpoint_list kept reporting it as armed. Use
defense_disable/tracer_disable to turn those off properly.
Introspection:
registers — annotated register dump for the currently selected frame.
Each entry is {"name", "value", "annotation"} where value is a
hex-formatted string like "0x000000010001d078" (not a raw integer).
annotation dereferences pointer-shaped values into a string, symbol
name, or preview — usually the fastest way to read a
just-decoded/decrypted buffer (e.g. after step_out of a decoder,
check whether the return-value register's annotation already shows
the plaintext). Auto-selects thread 0 if no thread is currently
selected (LLDB clears that in some post-interrupt paths), so you don't
need to call select_thread first after every interrupt.
disasm {"addr": N, "count": 64} — both optional; defaults to a window
around the current pc. Clamped to the enclosing function/symbol (or
the __text section if nothing else covers addr) — never shows
adjacent functions even for large count. If you want a raw window
that ignores function boundaries (e.g. to inspect the byte
immediately after a function), use raw {"command": "disassemble -s ADDR -c N"} instead.
Memory:
read_memory {"addr": N, "size": N} — returns hex (always) and
ascii (only when the bytes look printable).
write_memory {"addr": N, "hex": "9090"} — patches persist in the live
process; do not persist across restart (the binary is re-mapped).
write_register {"name": "x0", "value": N}.
set_pc {"addr": N} — redirect execution by pointing the program counter
at addr (x64dbg's "Set New Origin Here"); does not run.
memory_search {"needle_hex": "…", "scope": "target"|"all", "max_hits": 32, "budget_bytes": N} — or "needle_ascii" instead of needle_hex.
scope: "all" can take tens of seconds; bound with budget_bytes
and/or a generous --timeout.
Strings:
extract_strings {"min_len": 5} — from the executable's static string
sections; works before launch. Returns {"strings": [{"addr": N, "text": "…"}, …]}.
scan_live_strings {"min_len": 8, "budget_bytes": N} — scans live
heap/stack memory; needs a running process. Same response shape.
Default budget_bytes is 512 MiB.
Anti-anti-debug defenses (name is one of anti_ptrace, anti_sysctl,
anti_csops, anti_timing, anti_parent, anti_sigtrap,
anti_mach_ports, direct_syscall, fork_identity, exec_sandbox):
anti_sysctl clears P_TRACED from sysctl(KERN_PROC) results and
anti_csops clears CS_DEBUGGED from csops(CS_OPS_STATUS) results --
the two flag-based checks that otherwise see the debugger even with
anti_ptrace on. Enable them at the entry stop, before your first
continue, so the check can't run first.
anti_timing feeds mach_absolute_time() a fake monotonic clock. A
sample that times a sensitive call catches the millisecond latency the
scrubs above add (a scrubbed flag plus a slow call still reads as
"debugger"); this hides that delta, and the per-instruction cost of
single-stepping. Pair it with anti_sysctl/anti_csops. It intercepts
everymach_absolute_time call, so it slows timing-heavy targets --
leave it off unless a timing check needs it.
anti_timing covers mach_absolute_time, mach_continuous_time, and
clock_gettime_nsec_np. It cannot cover a direct mrs cntvct_el0
register read or wall-clock gettimeofday.
anti_parent scrubs a debugger p_comm (debugserver/lldb/gdb) to
launchd in sysctl(KERN_PROC) results, defeating a parent-name check
whether it used getppid or read e_ppid from its own kinfo_proc. It
does not hook getppid, so legitimate callers get the real value. Shares
the sysctl breakpoint with anti_sysctl.
anti_sigtrap forwards a self-planted brk #0 (EXC_BREAKPOINT that is
not one of your breakpoints) to the target's own SIGTRAP handler, the way
the kernel would with no debugger. Defeats self-trap checks that install a
SIGTRAP handler and execute brk #0 to see whether the handler runs. It
reads the handler via an in-process sigaction call, so it needs a live
process; off by default. Limits: it only handles brk/SIGTRAP (not SIGILL
udf or Mach-exception-port self-handlers), and delivers the signal
without a real siginfo/ucontext, so a handler that inspects those
won't see valid values.
ptrace / sysctl-P_TRACED / csops-CS_DEBUGGED issued through the libc
syscall() wrapper (e.g. syscall(SYS_ptrace, PT_DENY_ATTACH, …)) are
neutralised automatically whenever the corresponding symbol defense is on
-- no separate toggle. Not covered: the raw syscall from an inline svc
in the sample's own code (only ptrace is svc-scanned), or the self-attach
ptrace trick.
All defenses now apply whether you continue past the checked call or
single-step through it; stepping no longer bypasses a defense.
Fork/exec interactive decisions — by default these auto-resolve
(identity/sandbox mode blocks/fakes the call and keeps going); set
interactive mode first to inspect:
Only captures calls made after it's enabled — nothing retroactive.
Enable it at the initial entry stop (before your first continue) if
you want the full call history from process start.
trace_hits {"since": 0} — poll accumulated hits newer than hit number
since. Each hit is {"n": int, "category": "FILE"|"NET"|"PROC", "call": "human-readable"}.
Escape hatch:
raw {"command": "…"} — run any literal lldb command through the
command interpreter; returns output/error_output. This is powerful
and unrestricted — it can delete internal defense/tracer breakpoints
the structured commands would refuse to touch. Do not use it to touch
a breakpoint id you got from breakpoint_list {"hide_internal": false}. See "raw idioms" below for what to reach for.
Raw idioms you'll actually use
Most reversing work on a Cocoa binary funnels through raw for the
things LLDB does well and macdbg doesn't wrap. The workflows I hit
constantly:
Address → function name. By far the most common one — every time you
see an address in a register or a xref and want to know what it is:
raw {"command": "image lookup -a 0x100004a20"}
Symbol → address (exact name):
raw {"command": "image lookup -n \"-[NSApplication run]\""}
Symbol → address (regex, useful when you don't know the exact selector):
raw {"command": "image lookup -r -n \"delegate\""}
Break by symbol name (the structured breakpoint_toggle is
address-only):
raw {"command": "breakpoint set -n \"-[MyController doSomething:]\""}
The response's output includes the breakpoint id and the resolved
address — read them back if you want to manage the breakpoint later.
Break on every call to an Objective-C selector. On arm64 the compiler
emits per-selector objc_msgSend$foo stubs; setting a breakpoint on that
stub fires every time anyone calls [obj foo]. Discover them with
nm -a <binary> | grep 'objc_msgSend\$'.
raw {"command": "breakpoint set -n \"objc_msgSend$description\""}
Call an Objective-C method at runtime (invaluable for
reverse-engineering — poke at any Cocoa object without patching, or drive
a decode routine with test input):
raw {"command": "expression -l objc -O -- [NSApp delegate]"}
raw {"command": "expression -l objc -O -- [(id)0x12345 description]"}
-l objc selects the ObjC parser; -O prints via -description instead
of dumping the raw struct. Watch out for two footguns: LLDB prints a
BOOL return as <nil> when it's 0 (wrap in [NSNumber numberWithBool:…] for a clean value), and reading private ivars with
(Type *)ptr->_ivar fails on stripped binaries (does not have a member named errors) — fall back to KVC with [obj valueForKey:@"foo"] or, if
KVC's own accessor bailout throws, read the ivar offset from
_OBJC_IVAR_$_Class._ivar (via nm) and add it to the object pointer
manually.
Word-formatted memory dump (nicer than read_memory's raw hex when
you're looking at instructions or pointer tables):
raw {"command": "memory read -f x -c 4 0x100003f88"}
-f x = hex words, -c 4 = four words. For string content, use
-f s; for byte-oriented dumps, read_memory is fine.
Disassemble past the auto-clamp — disasm refuses addresses outside
the enclosing function, which is usually right but occasionally not:
raw {"command": "disassemble -s 0x100003f88 -c 8"}
Look up what module and section an address belongs to (useful when
you don't yet know if an address is in __text, a stub, or a data
section):
raw {"command": "image lookup --verbose --address 0x1000a4210"}
Session management
agent.sh status <session> / agent.sh interrupt <session> — shorthands
for the same JSON commands.
agent.sh list — every known session, alive or not.
agent.sh logs <session> [-n N] — the daemon's own stderr/stdout, for
diagnosing a session that failed to start or crashed.
agent.sh stop <session> — saves breakpoint state, detaches or kills
the target, tears down the daemon. Always do this when finished —
don't just abandon a session.
The daemon's accept loop is single-threaded. During a resume command
(continue/step_*/wait/decide_*), the daemon polls the socket
between stops, so status/interrupt/quit sent from a separateagent.sh cmd invocation get answered promptly. Any other command sent
during that window gets {"error": "session busy"} — retry when the
in-flight command returns.
During a slow plain command (a scope: "all" memory search, a big
scan_live_strings, a long-running raw), the accept loop is fully
blocked until it returns — even status and interrupt will queue and
may hit the client's 60s default timeout. Bound such calls with
budget_bytes and/or a larger --timeout.
You can't interrupt a continue down the same blocked call — the
interrupt must come from a fresh connection. If a target might genuinely
loop forever, launch the resume with a timeout and send interrupt
from a second agent.sh cmd if it doesn't come back.
One daemon per target: don't juggle multiple targets in a single session,
start a new named session per binary.