Ghidra MCP: Headless Decompiling from an AI Agent with REA
On this page
To let an AI agent use Ghidra without the GUI, point an MCP server at an extracted Ghidra install and let it run the headless analyzer for you. REA, a third-party open-source tool, does this as a provider: set GHIDRA_INSTALL_DIR, check it with rea doctor --provider ghidra, then ask for decompile, xrefs or trace from the CLI or over MCP. REA imports the binary into a throwaway Ghidra project, waits for auto-analysis and returns the answer with an Evidence record. It never downloads or installs Ghidra.
What was and was not run for this page. We could not run Ghidra for this revision, so no Ghidra output is shown. The rea doctor result, the sample build and every address below are real (REA 6.7.0, macOS 27.0.1 on Apple silicon, Apple clang 21.0.0); the addresses come from otool and nm, so you can check Ghidra’s answers against them. The rea commands are from the 6.7.0 CLI and REA’s Ghidra provider docs.
Requirements
From REA’s installation guide, all checked by rea doctor:
- Linux or macOS (x64 or arm64). Windows x64 is experimental and read-only, for native x86/x86-64 PE files on local NTFS volumes only (Windows notes).
- Ghidra 12.1.x, already extracted (the bridge is verified with 12.1.4).
- A 64-bit full JDK 21 or newer, including
javac. - Ghidra’s native decompiler for your host under
Ghidra/Features/Decompiler/os/<platform>/. Official archives can lack it for macOS and Linux arm64; see the fix at the end. - Node.js 22.19+, 24.11+ or 26+.
Point REA at Ghidra and check it
export GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra_12.1.4_PUBLIC
export JAVA_HOME=/absolute/path/to/jdk-21 # optional if java/javac are on PATH
rea doctor --provider ghidra --json
Use the scoped --provider ghidra: a plain rea doctor audits every engine and agent registration, and reports unhealthy for unrelated reasons. On our machine, with Ghidra not installed, the scoped check returned "healthy": false with exactly one failing check:
node ok healthy 22.22.3
ghidra false missing_analysis_engine GHIDRA_INSTALL_DIR is not set
The same JSON also carries a general checks list of 27 entries (Hopper, IDA, JADX, Kuna and 19 agent registrations on this machine); the two scope_checks above are the ones that matter. With Ghidra present, the doctor validates the Ghidra version, support/analyzeHeadless, Java version and bitness, javac and the native decompiler.
A GUI agent such as Cursor does not inherit variables you exported in a terminal. rea setup writes GHIDRA_INSTALL_DIR/JAVA_HOME into each agent’s REA registration after showing you the change; restart the agent afterwards. If Hopper or IDA is also configured, add --provider ghidra to each command or set REA_ANALYSIS_PROVIDER=ghidra, otherwise you can get an ambiguous provider error listing details.candidate_ids.
A sample with known answers
vault.c hides an 8-byte passphrase behind a byte-mixing function:
/* vault.c - a tiny practice target for REA + Ghidra */
#include <stdio.h>
#include <string.h>
#define KEY_LEN 8
/* "rea-demo" after mix_byte() */
static const unsigned char kExpected[KEY_LEN] = {
0x28, 0x42, 0x41, 0x80, 0x4A, 0x4E, 0x49, 0x4A
};
static unsigned char mix_byte(unsigned char c, int i) {
return (unsigned char)((c ^ 0x5A) + i * 3);
}
int check_passphrase(const char *input) {
if (strlen(input) != KEY_LEN)
return 0;
for (int i = 0; i < KEY_LEN; i++) {
if (mix_byte((unsigned char)input[i], i) != kExpected[i])
return 0;
}
return 1;
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <passphrase>\n", argv[0]);
return 2;
}
if (check_passphrase(argv[1])) {
puts("Vault opened");
return 0;
}
puts("Wrong passphrase");
return 1;
}
$ cc -O0 -g -o vault vault.c
$ ./vault rea-demo; echo $?
Vault opened
0
$ ./vault wrong; echo $?
Wrong passphrase
1
The ground truth for this -O0 Mach-O build, from nm and otool:
$ nm vault
00000001000004b0 T _check_passphrase
0000000100000660 s _kExpected
0000000100000590 T _main
000000010000055c t _mix_byte
U _puts
U _strlen
(4 lines trimmed)
$ otool -v -s __TEXT __cstring vault
0000000100000668 usage: %s <passphrase>\n
0000000100000680 Vault opened
000000010000068d Wrong passphrase
$ otool -tV vault | grep -B1 'Wrong passphrase'
0000000100000614 adrp x0, 0 ; 0x100000000
0000000100000618 add x0, x0, #0x68d ; literal pool for: "Wrong passphrase"
So a correct analysis finds Wrong passphrase at 0x10000068d, referenced from main at 0x100000614/0x100000618 just after the bl _check_passphrase at 0x1000005f4, and the eight expected bytes at 0x100000660 in __TEXT,__const. On Mach-O, C symbols carry a leading underscore; use names exactly as the tool returns them.
The commands
Each rea CLI call is its own process: import, auto-analysis, one answer, close. Over MCP an agent opens the binary once (open_binary) and reuses the analyzed program, so the first question is the slow one.
BIN="$PWD/vault"
rea analyze "$BIN" --provider ghidra --json # overview (MCP: binary_overview)
rea search "$BIN" "passphrase" --provider ghidra --json
rea search "$BIN" check --kind procedures --provider ghidra --json
rea decompile "$BIN" _check_passphrase --provider ghidra --json
rea function "$BIN" _check_passphrase --provider ghidra --json
rea instructions "$BIN" _check_passphrase --provider ghidra --json
rea xrefs "$BIN" 0x10000068d --provider ghidra --json
rea trace "$BIN" "Wrong passphrase" --provider ghidra --json
rea trace-native-values "$BIN" _check_passphrase --provider ghidra --json
What each one is for, per the provider docs:
searchdefaults to literal, case-insensitive string search;--kind proceduressearches function names and--mode regextakes a Java regex. Only what Ghidra defined as string data is searched, so packed or undefined literals (Rust strings are a known case) can be missing: an empty result does not prove the text is absent.decompilereturns pseudocode for one procedure;functionadds assembly, callers, callees, references, basic blocks and bounded p-code value flow;instructionslists raw instructions without the decompiler. A0xprefix selects an address, otherwise an exact symbol name wins.xrefskeeps edge types (call, jump, data read, write, computed …). Computed calls with no known target stay unknown rather than guessed.trace(MCPtrace_feature) matches the query literally, follows xrefs and resolves each to its containing procedure, and lists what it could not resolve.trace-native-valuesis a bounded static data-flow trace; alias effects and persistent state stay unknown.- Arguments that start with a dash need the
=form, such as--procedure=-nameor--query=-text.
For an agent, one prompt covers the loop, and the last sentence is the important one, because kExpected lives in data, not code:
Use REA with the Ghidra provider on /absolute/path/to/vault. Find what prints
"Wrong passphrase", decompile the check that decides it, and work out which input
passes. Read the data bytes it compares against (read_bytes) instead of guessing,
and cite the addresses for each step.
Inverting mix_byte on those bytes gives rea-demo, which you can check with ./vault rea-demo. Agent renames and comments (annotate_native_function) live only in the throwaway Ghidra database; the executable never changes.
-O2 makes this sample easier, not harder
We expected an optimised build to hide more. It did the opposite. With cc -O2, nm no longer lists _mix_byte or _kExpected, because the compiler inverted the whole check at compile time and compares each byte against the plaintext:
$ otool -tV vault-o2 | sed -n '/_check_passphrase:/,/ret/p'
0000000100000470 bl 0x1000005f0 ; symbol stub for: _strlen
0000000100000474 cmp x0, #0x8
000000010000047c ldrb w8, [x19]
0000000100000480 cmp w8, #0x72
0000000100000494 ldrb w8, [x19, #0x2]
0000000100000498 cmp w8, #0x61
(b.ne branches and the other six byte compares trimmed: 0x65 0x2d 0x64 0x65 0x6d 0x6f)
0x72 0x65 0x61 0x2d 0x64 0x65 0x6d 0x6f is rea-demo in ASCII. Any disassembler shows it; a decompiler adds nothing here. Constant-input checks like this one are a poor test of a decompiler; give it a target where the expected value is computed at run time.
Snapshots for scripted runs
Because each CLI call re-imports the binary, --snapshot /absolute/path/vault.json caches exact results: repeat the same query with the same bytes, parameters and provider settings and REA returns the saved answer without starting Ghidra. It is not a saved Ghidra database, so a new question still starts the provider. Binary patch diffing uses this to compare two builds.
Troubleshooting from REA’s docs
native_decompiler missing on macOS. Build it with Xcode Command Line Tools, make and bison. On Apple silicon:
cd "$GHIDRA_INSTALL_DIR/Ghidra/Features/Decompiler/src/decompile/cpp"
make ghidra_opt ARCH_TYPE="-arch arm64" OSDIR=mac_arm_64
decompiler_dir="$GHIDRA_INSTALL_DIR/Ghidra/Features/Decompiler/os/mac_arm_64"
mkdir -p "$decompiler_dir"
test ! -e "$decompiler_dir/decompile" && test ! -L "$decompiler_dir/decompile" &&
install -m 755 ghidra_opt "$decompiler_dir/decompile"
On Intel Macs use plain make ghidra_opt and mac_x86_64.
The agent times out on the first question. REA gives Ghidra 330,000 ms for import, auto-analysis and bridge start, but the MCP client may cancel the tool call sooner. Raise REA_GHIDRA_STARTUP_TIMEOUT_MS in the REA server entry and the client’s own tool timeout; one does not extend the other. time rea analyze "$BIN" --provider ghidra --json > /dev/null shows the real cold-start time with no client deadline. After a client gave up mid-start, call close_binary, then open_binary with provider_id: "ghidra", before retrying (details).
Out of memory. The JVM heap comes from GHIDRA_HEADLESS_MAXMEM (default 2G; 512M is documented as working on small targets). It must be in the REA server’s environment, and it is the only memory knob: REA clears _JAVA_OPTIONS, JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS and GHIDRA_JAVA_OPTIONS.
Wrong language, or code reached only through computed jumps. Set REA_GHIDRA_LANGUAGE_ID (for example x86:LE:32:default). For missed code, REA_GHIDRA_SEED_FILE takes an absolute, tab-separated file of address<TAB>kind[<TAB>name] lines (kind is function, code or label); REA records its SHA-256 and reports a function_failed count when a seeded address was wrong.
artifact_changed. The binary was rebuilt while a session was open; reopen it.
To compare REA with other Ghidra and IDA bridges, see REA vs GhidraMCP and IDA Pro MCP.