rea-reverse

Ghidra MCP: Headless Decompiling from an AI Agent with REA

Written for REA 6.7.0Published by rea-reverseUpdated 5 min read
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.

Free Native Binary Analysis with REA and Ghidra, Step by StepSilent walkthrough with English captions.

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:

  • search defaults to literal, case-insensitive string search; --kind procedures searches function names and --mode regex takes 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.
  • decompile returns pseudocode for one procedure; function adds assembly, callers, callees, references, basic blocks and bounded p-code value flow; instructions lists raw instructions without the decompiler. A 0x prefix selects an address, otherwise an exact symbol name wins.
  • xrefs keeps edge types (call, jump, data read, write, computed …). Computed calls with no known target stay unknown rather than guessed.
  • trace (MCP trace_feature) matches the query literally, follows xrefs and resolves each to its containing procedure, and lists what it could not resolve. trace-native-values is 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=-name or --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.