rea-reverse

Ghidra Headless MCP: Tune REA for Stripped and Unusual Binaries

Written for REA 6.4.0Updated 11 min read
On this page

The REA + Ghidra walkthrough used a small binary with symbols, and Ghidra’s defaults handled it. Real targets are less cooperative. Function names are stripped. The header names the wrong language. Code is only reachable through a pointer table. The first query times out in your agent, or the JVM runs out of heap. This guide covers the settings REA, the open-source project by morluto, gives you for those cases, and when to use each one.

It assumes rea doctor --provider ghidra --json already passes and you have run the basic loop. You will learn:

  • how a REA Ghidra session starts, and which setting affects which stage;
  • a workflow for stripped binaries, including names that persist across sessions;
  • language/compiler overrides and analysis seeds;
  • which architectures the 6.4.0 provider accepts, including the bounded MIPS lane;
  • startup deadlines, heap settings, and snapshot reuse.

Everything here is checked against the published npm 6.4.0 package (rea-agents). REA’s repository main already documents settings that 6.4.0 does not have. Those are marked below so you don’t set a variable your installed version ignores.

Prerequisites

  • REA 6.4.0 with Ghidra 12.1.x and a 64-bit full JDK 21+, configured as in the intro guide.
  • Linux or macOS. Windows x64 works only as an experimental, read-only P0 (see the Windows EXE/DLL guide). Renaming and the no-return repair described below are not available there.
  • A C compiler. For the MIPS section you also need a MIPS cross toolchain such as mips-linux-gnu-gcc.
  • Targets you are allowed to analyze. This guide uses programs you compile yourself.

How a Ghidra session starts

Each setting below acts on one stage of the session lifecycle:

  1. Profile resolution. Before anything starts, REA resolves an analysis profile for the target: format, architecture, language choice, seed digest and more. The profile is recorded in every piece of Evidence and keys every snapshot.
  2. Import. REA launches AnalyzeHeadless with -readOnly and -deleteProject against a private temporary project. On Linux and macOS it starts the JVM directly, using Ghidra’s own LaunchSupport configuration.
  3. Pre-analysis scripts. These apply seeds and similar preparation.
  4. Default auto-analysis. REA serves nothing until this finishes. Partial results are never exposed.
  5. Bridge and readiness. REA’s packaged Java bridge connects, and queries start.

In an agent, open_binary only selects the target. The first Ghidra-backed query triggers stages 2 to 5, which is why the first question is slow and later ones are fast.

These are the knobs 6.4.0 actually reads, with the file in the published dist/ where each one is defined:

Setting Stage Defined in 6.4.0 dist/
GHIDRA_INSTALL_DIR, JAVA_HOME all config/environment.js
REA_ANALYSIS_PROVIDER provider choice config/environment.js
REA_GHIDRA_LANGUAGE_ID, REA_GHIDRA_COMPILER_SPEC_ID 1–2 config/environment.js, config/ghidraLanguageOverride.js
REA_GHIDRA_SEED_FILE 1, 3 config/environment.js, ghidra/GhidraAnalysisSeeds.js
REA_GHIDRA_STARTUP_TIMEOUT_MS (default 330000) 2–5 config/ghidraStartupTimeout.js
GHIDRA_HEADLESS_MAXMEM, then GHIDRA_MAXMEM (default 2G) 2 ghidra/GhidraJavaLaunch.js

All of these must be in the environment of the process that runs REA. For an agent, that means the env block of the REA MCP server entry, not your interactive shell. Run rea setup again, or edit the entry, and restart the client after you change any of them.

Step 1: Build a stripped target

Reuse vault.c from the intro guide, and build two variants:

cc -O0 -g -o vault-debug vault.c
cc -O2 -s -o vault-stripped vault.c
BIN="$PWD/vault-stripped"

The debug build is your answer key. With -O2 -s, mix_byte is usually inlined and the symbol table is gone, so Ghidra names functions after their addresses (for example FUN_00101189).

Step 2: Navigate without names

On a stripped binary, start from data, not names. Strings survive stripping, and references lead you back to code:

rea search "$BIN" "Wrong passphrase" --provider ghidra --json
rea xrefs "$BIN" 0xSTRING_ADDRESS --provider ghidra --json
rea function "$BIN" 0xFUNCTION_ADDRESS --provider ghidra --json

Replace the placeholders with the addresses each step returns. Always pass addresses with the 0x prefix: on Ghidra, that prefix forces address interpretation, and a bare string is treated as a symbol name first. Use the addresses exactly as REA reports them. For position-independent ELF, Ghidra’s image base can differ from the offsets that objdump prints.

Once you have the right function, the 6.4.0 fine-grained commands answer narrower questions without decompiling everything:

# One instruction with decoded operand facts
rea inspect-native-instruction "$BIN" 0xINSN_ADDRESS --provider ghidra --json

# What can this call site actually reach?
rea resolve-native-call-targets "$BIN" 0xCALL_ADDRESS --provider ghidra --json

# A recovered type, or the typed data at an address
rea inspect-native-data-type "$BIN" --address 0xDATA_ADDRESS --provider ghidra --json

# Bounded static def-use across resolved calls
rea trace-native-values "$BIN" 0xFUNCTION_ADDRESS --provider ghidra \
  --max-depth 2 --max-functions 8 --json

trace-native-values is the command most worth tuning. The defaults are depth 3, 16 decompiled functions, 256 call sites, 5,000 nodes and 10,000 edges, with --offset/--limit paging over the node list. On a large stripped binary, start with a small depth and raise it only when the result reports unresolved dependencies you care about.

Truncated functions. If a dossier carries a limitation such as “Ghidra Listing reports a terminal call at …”, Ghidra decided that a callee never returns, so the code after that call is excluded from the function body. On Linux and macOS, REA runs a fix-up script after analysis that clears no-return flags from functions that contain a real return. The limitation tells you where Ghidra’s flow model still ends the function, and it states whether that rests on Ghidra’s flag or on an unknown callee. Treat code after that point as unanalyzed, not absent.

Large dossiers. In an agent, ask for the dossier once, then page through one part at a time with inspect_analysis_view instead of re-running the analysis. Its native facets include pseudocode, assembly, basic_blocks, callers, callees, unresolved_calls, referenced_strings and the value_flow_* views. The source is the retained evidence_id from the same session.

Step 3: Rename, and make the names stick

Inside one MCP session on Linux or macOS, an agent can name what it learns:

You decompiled 0xFUNCTION_ADDRESS and concluded it validates the passphrase.
Use set_addresses_names to name it check_passphrase, then report the
per-address success values. Tell me which names you were unsure about.

set_addresses_names takes a map such as {"0x101189": "check_passphrase"} and applies each address in its own transaction, reporting success per address. set_address_name handles a single address. Two caveats from the 6.4.0 tool contracts:

  • The edits live in the ephemeral Ghidra database. They never touch your file, and they disappear when the session closes.
  • Metadata edits invalidate immutable analysis snapshots, so names you set do not reach a snapshot replay.

To keep names across sessions and CLI runs, put them in a seed file (next step). Function and label seeds carry names, and REA applies them to every new import before auto-analysis, so a reviewed names.tsv per build becomes the durable record of your findings.

Step 4: Seed code and names before auto-analysis

REA_GHIDRA_SEED_FILE points to an absolute, UTF-8, tab-separated file. Its format is defined in dist/ghidra/GhidraAnalysisSeeds.js (rea-ghidra-seeds-v1):

# address<TAB>kind[<TAB>name]   (illustrative addresses)
0x101189	function	check_passphrase
0x101260	function	dispatch_handler_3
0x1012a0	code
0x104010	label	expected_key

The rules, as enforced by the 6.4.0 parser:

  • kind is function, code or label. A label needs a name, and a code seed takes no name.
  • Addresses are 0x hex in Ghidra’s default address space. Names cannot contain whitespace. Duplicate address/kind pairs are rejected.
  • The file may be up to 32 MiB and 1,000,000 entries. Blank lines and # comments are ignored.
  • A pre-analysis script applies functions first (highest address first), then code, then labels. A function seed on an existing function renames it.
  • Function and code seeds need initialized bytes. Label seeds can name uninitialized storage such as .bss.

Use function and code seeds for code that auto-analysis cannot reach: handlers called through computed jumps, or entry points stored only as pointers in data. A seed fails if it lands inside an instruction Ghidra already decoded, or on defined data.

Seeds are caller assertions, not discoveries. REA commits the seed file’s SHA-256 and entry counts to the analysis profile. If the session applies a different set, it fails instead of serving results. Every result carries limitations stating that seeded functions and names came from you, plus outcome counts (created, already present, failed, unmapped). Read those counts after the first query. A nonzero function_failed count means some seeded addresses were wrong.

Step 5: Override language and compiler spec

Ghidra picks a language from the executable header. When the header is misleading, or you need a non-default compiler convention, set:

export REA_GHIDRA_LANGUAGE_ID="x86:LE:32:default"
export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp"   # optional

What 6.4.0 enforces:

  • The language ID must be the four-part Ghidra form processor:endian:size:variant. The compiler spec must be a simple ID such as default or borlandcpp.
  • Setting REA_GHIDRA_COMPILER_SPEC_ID without REA_GHIDRA_LANGUAGE_ID is a configuration error.
  • REA checks both IDs against your installation’s language definitions before import, and records them in the profile as a configured selection.
  • DOS MZ/COM imports already use a fixed real-mode language, and reject an override.

The override applies to every target the server opens, so set it in a dedicated shell or a separate MCP server entry, not globally.

Step 6: Know the architecture boundary

On Linux and macOS, the 6.4.0 Ghidra provider accepts executables whose architecture REA resolves as x86, x86-64, ARM, ARM64 or MIPS. Two common surprises:

  • Universal (fat) Mach-O files are refused. Ghidra cannot enforce the slice you meant. Thin the file first, for example lipo -thin arm64 -output app-arm64 App, then analyze the slice.
  • MIPS is a bounded lane, not “all MIPS”. In 6.4.0 (dist/ghidra/GhidraMipsProfile.js), the provider admits only ELF32 ET_EXEC images declaring o32 and MIPS32r2. They must have an ABI flags record that agrees with the header, and declare a double-precision or FPXX floating-point ABI. It refuses MIPS64/n64, n32, MIPS16, microMIPS, ASE extensions, machine-specific variants, EF_MIPS_FP64, EF_MIPS_NAN2008, soft-float and missing ABI records, each with a specific reason. A language override does not bypass this check.

Check a candidate before handing it to an agent:

readelf -h target | grep -E 'Class|Type|Machine|Flags'
readelf -A target    # shows the MIPS ABI flags record if present

For a known-good MIPS target, compile one yourself: mips-linux-gnu-gcc -mips32r2 -mabi=32 -no-pie -O1 -o hello-mips hello.c. Binaries carved out of firmware (see Binwalk and Unblob) are often PIE (DYN) or shared libraries, which 6.4.0 refuses. Do not guess. Run REA and read the refusal reason. One thing 6.4.0 does handle well: a carved ELF whose section header table runs past the end of the captured file. REA examines every complete section header it has, and records the missing ones as a limitation instead of failing. This applies only to the section header table: the program header table and its MIPS ABI flags record must still be intact, or the file is refused.

Main-only, not in 6.4.0. Repository main documents MIPS ET_REL objects, MIPS16 shared images, soft-float prototypes, raw-binary imports, RISC-V, PSP and a REA_GHIDRA_DISABLED_ANALYZERS variable. None of these exist in the 6.4.0 package; setting that variable does nothing.

Step 7: Fix first-query timeouts

Two deadlines are involved, and they belong to different owners:

  • REA’s Ghidra startup deadline covers import, auto-analysis, bridge connection and readiness. The default is 330,000 ms. REA_GHIDRA_STARTUP_TIMEOUT_MS accepts a positive decimal integer up to 2,147,483,647. An empty or invalid value is a configuration error, not a silent default.
  • The client’s tool-call deadline is owned by your MCP client, and it can be shorter. Raising REA’s deadline does not extend the client’s.

For a large binary, raise both:

export REA_GHIDRA_STARTUP_TIMEOUT_MS=900000

Then raise the per-tool timeout in your client’s settings, if it has one. The CLI has no client deadline, so a cold CLI run measures how long auto-analysis really takes on your machine:

time rea analyze "$BIN" --provider ghidra --json > /dev/null

If the client gave up mid-startup, don’t just ask again in the same session. Use the recovery flow from REA’s MCP contracts: call close_binary and check its result, call open_binary again with provider_id: "ghidra", then retry with a longer client timeout.

Step 8: Size the JVM

On Linux and macOS, REA builds the JVM’s -Xmx from the first non-empty value of GHIDRA_HEADLESS_MAXMEM, then GHIDRA_MAXMEM, then 2G:

export GHIDRA_HEADLESS_MAXMEM=8G    # large target on a big host
export GHIDRA_HEADLESS_MAXMEM=512M  # small target on a constrained host

Facts from the 6.4.0 launcher worth knowing:

  • REA clears _JAVA_OPTIONS, JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS and GHIDRA_JAVA_OPTIONS, and owns GHIDRA_HEADLESS_JAVA_OPTIONS. Flags you inject through them are dropped. Heap is the only supported memory knob.
  • The JVM also gets -XX:ParallelGCThreads=2 and -XX:CICompilerCount=2. These limit those thread pools, not total CPU. REA does not pass Ghidra’s -max-cpu. To cap CPU, use host tools (taskset, cgroups) on the REA process.
  • -Xmx limits the Java heap, not the RSS of the whole process family.

Verify on the real JVM, not in your shell: on Linux, find the ghidra.Ghidra Java process after the first query starts and read -Xmx from /proc/<pid>/cmdline and VmRSS from /proc/<pid>/status.

Step 9: Reuse snapshots deliberately

A snapshot caches exact query results; it is not a saved Ghidra project. The usual mistake is changing the profile and expecting the old snapshot to keep answering.

  • CLI. analyze, inspect, decompile, function, instructions, search, xrefs, trace and inspect-native-api accept --snapshot /absolute/path.json. The fine-grained commands from step 2, read-bytes and annotate-native-function do not.
  • MCP. open_binary accepts snapshot_path to load one, and close_binary accepts snapshot_path to write one atomically before the session ends.
  • Replay is profile-bound. REA replays a cached answer only when the target digest, operation, parameters, provider version and analysis profile all match. Language override, compiler spec, seed digest and MIPS ABI facts are all part of the profile. Heap size and timeouts are not.

The practical rule: one snapshot file per profile. Name it after the setting, for example vault-stripped.seeds-v3.json. When you edit names.tsv, the seed digest changes, so start a new file. Old answers were computed under different assertions, and REA will not mix them.

export REA_GHIDRA_SEED_FILE="$PWD/names.tsv"
SNAP="$PWD/analysis/vault-stripped.seeds-v1.json"
rea analyze "$BIN" --provider ghidra --snapshot "$SNAP" --json
rea decompile "$BIN" --procedure check_passphrase --provider ghidra --snapshot "$SNAP" --json

Repeating the same command with the same profile returns the cached result without starting Ghidra. For comparing builds, see Evidence, snapshots and comparisons.

Troubleshooting

Configuration error naming REA_GHIDRA_COMPILER_SPEC_ID. You set a compiler spec without a language ID. Set both, or neither.

Seed outcome shows unmapped entries. Those addresses are outside initialized memory in Ghidra’s address space. Re-take the addresses from REA output, not from another tool’s file offsets.

architecture_unsupported on a MIPS file. Read the reason string. It names the exact flag or ABI record that falls outside the 6.4.0 lane.

Java fails to start after setting a heap value. The value must be a JVM size such as 512M or 4G. Anything else stops Java at startup.

Responsible use

Use these techniques on binaries you built, open-source software, CTF challenges or REA’s official showcase targets. For anything else, make sure you are authorized first, and read REA’s DISCLAIMER.md.

Next steps