| name | offensive-macos-tooling-ghidra-headless |
| description | Use when driving Ghidra headless from Cursor for macOS reverse engineering: opening Mach-O binaries, listing functions, decompiling by address, running custom Ghidra scripts, or doing breadth sweeps across many targets. Fires on "ghidra-mcp", "decompile with Ghidra", "run a Ghidra script", "list functions in this binary", and systemic-class scan setup. |
| folder | offensive-macos-tooling-ghidra-headless |
| source | skillz-wave2 |
| trigger_phrases | ["ghidra-mcp","decompile with Ghidra","run a Ghidra script","Ghidra headless","list functions in this binary"] |
Ghidra Headless — Tooling Skill
Channel boundary: REPO_MODE=analysis. Root-cause analysis, lab reproduction,
defensive mapping, and tooling guidance only. No operational exploit authoring
against live third-party targets.
When to use
- You need code-level static analysis of a Mach-O binary from Cursor: open/import, list functions, decompile, inspect strings, xrefs, types, or run a script.
- You are doing breadth work where one script must run over many daemons and emit stable TSV rows for triage.
- A hunt skill says "run the Ghidra script" or "decompile the candidate function"; this skill defines the exact MCP shape.
Lab Topology — Where To Run This
| Step | Surface | How |
|---|
| Open/import binary | primary lab host | ghidra-mcp over SSH via /Users/<remote-user>/bin/ghidra-mcp-launch |
| List/decompile/analyze | Cursor | MCP tools such as program.open, function.list, function.by_name, decomp.function |
| Run hunt script | Cursor -> primary lab host | ghidra.script with scripts under /Users/<remote-user>/ghidra-scripts/ |
| Cross-check metadata | primary lab host | macre-vm-mcp tools for entitlements, codesign, launchd, logs |
| Human visual depth work | lab-host GUI | Hopper may still be used manually, but it is not in the agent MCP loop |
Full topology: Skills/offensive-macos-station-topology/SKILL.md.
Theory
Ghidra is now the station's primary agent-facing disassembler because it can be driven as a headless analysis engine. Cursor does not need a GUI click path: it starts a stdio MCP server over SSH, opens a program into a persistent Ghidra project, and keeps a session_id for follow-up calls.
The station uses mrphrazer/ghidra-headless-mcp pinned at commit b9c491a6383dbc68c581e7fed16341ac47e7faba. It exposes a real pyghidra backend, stdio transport, and about 212 tools. The richer bethington/ghidra-mcp plugin remains useful context, but this station's live path is the SSH-backed headless server.
Ghidra's first open of a binary is the expensive step. It imports the file, runs analyzers, discovers functions and strings, then returns a session_id. Reuse that session for every later function.*, decomp.*, listing.*, symbol.*, and ghidra.script call. Do not reopen the same binary for every question.
Core MCP Tools
| Tool | Use |
|---|
health.ping | Prove the MCP process is reachable |
program.open | Import/open a file and return session_id |
function.list | Page through functions; use query to narrow |
function.by_name | Resolve a named function or ObjC selector-like symbol |
decomp.function | Return recovered C for a function start address |
listing.code_units.list | Inspect disassembly/data around an address |
strings.list or search.* tools | Find anchors when symbols are poor |
ghidra.script | Run station scripts such as scan_wrong_door.py |
Confirm exact tool schemas with tools/list because the MCP server is pinned source, not a hand-written wrapper.
Workflow A — Decompile One Function
- Health check: call
health.ping.
- Open the binary:
- Tool:
program.open
- Arguments:
path from CORPUS.md Lab Host Path Mapping, project_location="/Users/<remote-user>/ghidra-projects", project_name="<program>-analysis", read_only=true, update_analysis=true
- Output artifact:
session_id
- Find the target function:
- If you know the name:
function.by_name(session_id, name, exact=false)
- If you are exploring:
function.list(session_id, query="<substring>", limit=50)
- Decompile:
- Tool:
decomp.function
- Arguments:
session_id, function_start, timeout_secs=60
- Output artifact: recovered C plus Ghidra's function metadata
- Record stable anchors in the findings repo: binary path, Ghidra project name, session target, function start, relevant strings/xrefs, and why this function matters.
Workflow B — Breadth Sweep With A Script
-
Sync scripts to the primary lab host:
scripts/install-ghidra-host.sh --install
-
For each target binary, call program.open once and retain the session_id.
-
Run the relevant script:
- Wrong-door:
/Users/<remote-user>/ghidra-scripts/scan_wrong_door.py
- Defaults bypass:
/Users/<remote-user>/ghidra-scripts/scan_defaults_bypass.py
- Catalyst porting gap:
/Users/<remote-user>/ghidra-scripts/scan_catalyst_porting_gap.py
- Code-sign flags:
/Users/<remote-user>/ghidra-scripts/scan_flags_zero.py
- XPC listeners:
/Users/<remote-user>/ghidra-scripts/dump_xpc_listeners.py
-
Save stdout TSV under the active findings repo, usually findings/analysis/<date>-<class>-sweep.tsv.
-
Rank candidates by evidence density, then use dynamic tools (macre-vm-mcp, LLDB, DTrace, ObjC harnesses) for proof or closure.
Current Bug-Class Anchors
- Wrong-door bugs: entitlement or authorization checks landing on the wrong reachable interface. The Ghidra workflow finds listener/delegate/entitlement anchors before dynamic XPC probes.
- Media and parser attack surfaces where decompilation plus string/xref sweeps separate reachable code from dead exports.
- Logic bugs where platform or configuration branches flip a security decision. The Catalyst/defaults hunt scripts are shaped for this class.
Mutability Semantics — read_only and update_analysis
Verified empirically against pinned b9c491a (probe in SHAKEDOWN #8, 2026-05-13):
read_only=true is session-level, not DB-level. The backend stores it on the session record and _require_writable_session raises session <id> is read-only before any mutation tool (function.rename, decomp.global_rename, etc.) reaches the program. So read_only=true actively blocks mutations from the MCP — it is not a misleading no-op.
update_analysis=true is independent of read_only. Analysis runs inside its own Ghidra transaction with endTransaction(tx_id, True), which persists analysis output (function table, thunk signatures, applied data archives) into the project DB on disk regardless of read_only.
- After a
program.open(read_only=true, update_analysis=true) followed by program.close, reopening the same project + program with read_only=true, update_analysis=false returns the analysis-applied function table — no re-analysis, no extra program.save. The DB carries it.
program.save is not gated by read_only (it returns saved:true from a read-only session). Cosmetic inconsistency; it doesn't change the persistence story for the documented Workflow A.
Practical implication: the documented read_only=true, update_analysis=true combo on first open is exactly right for "analyze once, browse cheaply many times." If a session also needs to rename or retype, pass read_only=false on that one session — the read_only block is enforced on mutation, not on open.
Pitfalls
- First analysis can be slow. Reuse the
session_id; do not reopen per function.
- Function names may be synthetic (
FUN_...). Use strings, xrefs, imports, and call graph edges to find semantic anchors.
- Swift and ObjC names may be partial or mangled. Pair Ghidra results with
nm, strings, class-dump, or runtime probes.
- Script tools execute powerful code inside the analysis process. Keep scripts read-only unless the task explicitly requires mutation.
bethington/ghidra-mcp advertises more GUI/plugin features, but its headless HTTP server is not the station's active path until its startup crash is resolved.
- Crashed-session zombies hold project locks. If a prior Claude Code session exited while its
ghidra-headless-mcp subprocess was holding a .lock on a Ghidra project, the next session's program.open / project.program.open_existing will fail with LockException. Run scripts/lab-health.sh from the workstation to identify orphan lockfiles + stale sidecar PIDs. Do not kill ghidra-headless-mcp PIDs by hand from ps output — both live and zombie processes look identical there. The sidecar JSON at ~/.ghidra-headless-mcp/sessions/<pid>.json is the disambiguator.
When A Session Crashes
scripts/lab-health.sh
scripts/lab-health.sh --remove-stale
The script reports five sections: host facts, live MCP sessions (PID + open project + claimed lockfile), stale sidecars, orphan project lockfiles, and a ps cross-check. Exit code is 1 if any orphan locks or stale sidecars exist.
The pid-tagging-and-shutdown.patch carried in PATCHES.md is what writes the sidecar JSON and installs SIGTERM/SIGINT handlers that release locks on shutdown. Without that patch, lab-health.sh only sees orphan lockfiles via section 4 — not the live sidecar map.
Micro-Exercise
Goal: prove the station can decompile and run a hunt script.
-
Run:
scripts/install-ghidra-host.sh --smoke
-
Expected result:
- Java 21, Python 3.12, Ghidra 12.0.4, and
ghidra-headless-mcp versions print.
- The smoke opens
/bin/ls, lists functions, decompiles the first function, and runs scan_wrong_door.py.
- The script stdout includes the TSV header
daemon listeners ent_refs should_accept_impls audit_token_uses evidence.
Success means Cursor can use ghidra-mcp after MCP config reload.
See Also
ghidra-scripts/README.md
scripts/install-ghidra-host.sh
Skills/offensive-macos-tooling-cli-static/SKILL.md
Skills/offensive-macos-tooling-lldb/SKILL.md
Skills/offensive-macos-station-topology/SKILL.md
- Ghidra project: https://ghidra-sre.org/