rea-reverse

Binary Patch Diffing with an AI Agent: Compare Two Builds in REA

Written for REA 6.4.0Updated 9 min read
On this page

Short version: REA 6.4.0 does not have a “diff these two binaries and list every changed function” button. What it has is function-level comparison over MCP: you (or your agent) pick a function pair, run analyze_function on each build, and pass both Evidence records to compare_functions. You get eight dimensions (identity, pseudocode, assembly, comments, calls, references, strings/names and CFG), each unchanged, changed or unknown. compare_artifacts does the same at the file-inventory level, and find_changed_behavior rolls comparison results into one report that keeps static candidates apart from runtime observations. Use the project’s source diff to choose which functions to compare, use REA to confirm what actually changed in the shipped code, and treat every unknown as an open question, never as “same”.

This guide is for defenders and maintainers who want to know what a security release changed in compiled code: you are assessing whether a patched build really contains a fix, triaging an update for a dependency you ship, or checking that a backport landed. It is the function-level follow-up to REA Evidence, snapshots and version comparison. That guide covers snapshots, bundle validation and rea compare. This one only briefly recaps them.

Our example target is zlib 1.2.11 versus 1.2.12, both built from source. zlib 1.2.12 includes the fix for CVE-2018-25032, a memory-corruption bug in deflate that the zlib project disclosed publicly. The goal here is defensive: locating and describing the fix. Nothing in this guide is about triggering the bug.

Prerequisites

  1. REA 6.4.0 connected to an MCP client such as Claude Code, Cursor or Codex. See Getting started with REA and MCP.
  2. Ghidra configured as REA’s native provider: REA + Ghidra for native binaries. Use the same provider for both builds. If the providers differ, REA refuses to equate their pseudocode and assembly text (more on that below).
  3. A C toolchain, git and jq.
  4. Enough patience for Ghidra’s first analysis of each library. Snapshots make later runs fast. If the first import times out or runs out of memory on a larger library, see Ghidra headless MCP: tune REA for the startup timeout, heap and close/reopen recovery.

Everything here is static analysis of software you built yourself. If you go on to run either build under a harness, do it on systems you are authorized to use and read DISCLAIMER.md first.

Step 1: Build both versions the same way

Patch diffing measures differences between two builds, and your build settings can create differences of their own. Build both versions with the same compiler, the same flags and the same prefix layout, and don’t strip them:

git clone https://github.com/madler/zlib.git /work/zlib-src
for v in 1.2.11 1.2.12; do
  git -C /work/zlib-src worktree add /work/zlib-$v v$v
  (cd /work/zlib-$v && CFLAGS="-O2 -g" ./configure --prefix=/work/zlib-$v/install && make && make install)
done

Why each choice matters:

  • Same flags. A different -O level changes inlining and block layout, and shows up as CFG and assembly changes that have nothing to do with the patch.
  • Symbols kept. REA matches functions by name. Its comparison code treats address-derived names such as FUN_00102a40, sub_401000 or fcn.00401000 as unstable. When either side carries one, the identity and calls dimensions return unknown instead of guessing. Symbol-bearing builds give you real names, including static functions like compress_block, unless the compiler inlined them away.
  • Install trees. install/lib holds the shared library and install/include the headers, so you have a directory per version that you can inventory in Step 6.

Step 2: Let the source diff choose your candidates

zlib is open source, so you don’t need to search the whole binary for changes. Start from the history:

git -C /work/zlib-src diff --stat v1.2.11 v1.2.12 -- '*.c' '*.h'
git -C /work/zlib-src log --oneline v1.2.11..v1.2.12 -- deflate.c deflate.h trees.c

You’ll see that 1.2.12 is not a one-line patch release. It bundles years of other changes along with the CVE fix. That is typical, and it’s the main reason a whole-binary diff is noisy: you want the functions the fix touches, not everything that moved. The CVE-2018-25032 fix is zlib commit 5c44459, “Fix a bug that can crash deflate on some input when using Z_FIXED”. It replaces the overlaid d_buf/l_buf arrays with a separate sym_buf. In deflate.c its diff touches deflateInit2_, deflatePrime, deflateCopy and the deflate_fast, deflate_slow, deflate_rle and deflate_huff strategies. In trees.c it touches init_block, _tr_tally and compress_block (its _tr_flush_block hunk only edits a debug trace). deflateInit2_ and compress_block are good first candidates. Several of the others are local (static) functions, and non-debug builds use the _tr_tally_lit/_tr_tally_dist macros from deflate.h instead of calling _tr_tally, so expect some of them to be inlined. Read the commit diff yourself instead of relying on this list.

Write the candidate list down. The agent will work through it in Step 4, and a fixed list keeps it from wandering.

Step 3: Cache per-function Evidence with the CLI (optional)

You can let the agent do all the analysis inside its MCP session. For anything you’ll repeat, build snapshots from the CLI first. rea function is the CLI form of analyze_function, and it accepts --snapshot:

mkdir -p /work/diff
for v in 1.2.11 1.2.12; do
  for fn in deflateInit2_ compress_block; do
    rea function /work/zlib-$v/install/lib/libz.so.$v "$fn" --provider ghidra \
      --snapshot /work/diff/zlib-$v.snapshot.json --json > /work/diff/$fn.$v.json
  done
done

Each output file is a complete analyze_function Evidence record, bound to the library’s SHA-256. Use one snapshot per version. The Evidence guide explains why, and it also shows how to pull the snapshot’s bundle out with jq '.evidence_bundle' for archiving.

A quick sanity check before you go further:

jq '{op: .operation, name: .normalized_result.procedure.name, limits: .normalized_result.limitations}' \
  /work/diff/compress_block.1.2.12.json

If name comes back as a FUN_… address name, or the function isn’t found, it was probably inlined in this build. Note that and move on. Don’t compare it with a different function just because the address looks close.

Step 4: Compare function pairs with the agent

compare_functions, compare_artifacts and find_changed_behavior are MCP-only in 6.4.0. They are listed in the package’s MCP_TOOLS_WITHOUT_DEDICATED_CLI, so there is no rea compare-functions command. Give your agent a prompt like this:

Using REA over MCP, compare these functions between the two zlib builds:
deflateInit2_, compress_block.

For each function:
1. open_binary /work/zlib-1.2.11/install/lib/libz.so.1.2.11 with provider ghidra
   (snapshot_path /work/diff/zlib-1.2.11.snapshot.json), run analyze_function,
   keep the Evidence.
2. Do the same for /work/zlib-1.2.12/install/lib/libz.so.1.2.12.
3. Pass both complete Evidence records to compare_functions as left and right.
4. Report status, function_match, and every dimension whose status is changed or
   unknown, with its limitations and the two evidence_links.
Do not merge unknown into unchanged. Do not compare a function against a
differently named one unless I ask.

Notes on the parts that trip people up:

  • Inputs are whole Evidence records, not IDs. compare_functions takes left and right as full analyze_function Evidence. The MCP contract says to pass the returned Evidence straight through. If a function’s Evidence exceeds the MCP response budget, REA keeps it in the session and gives the agent an inspect_analysis_view reference to page through it. The comparison still needs the complete record.
  • open_binary with snapshot_path imports your CLI snapshot, so an exact cache hit skips Ghidra. A hit requires the same bytes, operation, parameters, provider and analysis profile.
  • Matching is exact. When both names are equal and not address-derived, function_match is matched by symbol. Different names give mismatched. Equal address-derived names give ambiguous, and an ambiguous match makes the overall status unknown.

Step 5: Read the result dimension by dimension

The overall status is unknown if any dimension is unknown (or the match is ambiguous), changed if any dimension changed, and unchanged only when all eight agree. That’s a strict rule. Most of the useful information is in the per-dimension rows:

Dimension What is compared Common reasons for unknown
identity name, signature, local variable descriptions an address-derived name on either side
pseudocode provider pseudocode text, as a bounded line diff different providers, or the diff hit its time/size limit
assembly provider assembly text different providers. Even with one provider, relocation normalization is unavailable, so address shifts can show as changes
comments comments at function-relative offsets a comment address that can’t be normalized
calls caller and callee names an address-derived caller or callee name
references incoming/outgoing reference endpoints and kinds provider did not expose reference kinds
strings_names referenced strings and names by offset source addresses that can’t be normalized
cfg basic-block sizes and successor topology, address-normalized block addresses that can’t be normalized

For text dimensions, text_delta gives added_lines, removed_lines and hunks. When the two sides used different providers, a changed dimension carries conclusion_kind: "contradiction" rather than derived_relationship, and the result’s limitations say provider representations were not equated. That’s another reason to stick with one provider.

A 6.4.0 limitation to know about. In the 6.4.0 package, the calls dimension compares the caller and callee name lists as reported. It does not check whether those lists are complete. A function dossier can include unresolved_calls (call sites with no resolved target) and dossier limitations, but in 6.4.0 a calls: unchanged row can still sit on top of incomplete coverage. This is issue #2222, “compare_functions treats incomplete call coverage as unchanged”. It shows that two empty caller/callee lists produce calls: unchanged even when one side’s call graph is unknown, and that call sites in unresolved_calls are ignored. The issue was closed on 2026-10-12, the day after 6.4.0 was released, by a fix on main that treats missing, partial, unknown or unresolved call coverage as insufficient for call-set equality. That fix is not in 6.4.0. Until you upgrade, have the agent check normalized_result.unresolved_calls and limitations on both inputs before it trusts an unchanged calls row.

A sensible way to interpret a fix:

  1. pseudocode and cfg changed in the functions your source diff named: that’s consistent with the patch landing in the binary.
  2. pseudocode changed but cfg is unchanged: often a constant or a size computation changed. Read the pseudocode hunks.
  3. Candidate functions all unchanged on every dimension: the fix may sit in an inlined helper or a function you didn’t list, or the build didn’t pick up the change. Don’t conclude “not patched” from one quiet function.

Step 6: Inventory-level diff with compare_artifacts

Before or after the function work, compare what each build actually installed. The CLI can inventory a directory:

rea inspect-artifact /work/zlib-1.2.11/install --json > /work/diff/inv-1.2.11.json
rea inspect-artifact /work/zlib-1.2.12/install --json > /work/diff/inv-1.2.12.json
jq '.normalized_result.substeps[0].evidence.operation' /work/diff/inv-1.2.12.json

compare_artifacts takes the inventory Evidence nested in each inspect_artifact result (at normalized_result.substeps[0].evidence), not the outer record. Over MCP the agent can produce these itself with open_binary and inspect_artifact on each tree, then call compare_artifacts. Each change is classified added, removed, changed, unknown or contradiction by logical path, with dimensions such as content, size and metadata, and it cites both inputs. Missing coverage produces unknown or truncated, never equivalence. For zlib you’ll see the versioned .so file names change, which is expected. On larger products this step is where you find a newly bundled helper binary or a changed config file before you dive into functions.

Step 7: Aggregate and write the summary

Pass the comparison Evidence (the compare_functions and compare_artifacts results) to find_changed_behavior as comparisons. It produces findings with a scope (static_candidate for these), a classification (derived_relationship, contradiction, unresolved_branch) and the source comparison ID. With no process comparison in the input, behavior_status stays unknown and the limitations say so: static differences are behavior candidates, not runtime observations. That wording belongs in your report too.

Then ask the agent for a summary with one rule: every sentence cites an evidence_id. For example:

Write a change summary for zlib 1.2.11 -> 1.2.12 limited to the CVE-2018-25032
fix. For each claim, cite the compare_functions evidence_id and the two
analyze_function evidence_ids it links. List every unknown dimension separately
under "Open questions" with its limitation text. Do not describe how to trigger
the bug.

Finish by calling export_evidence_bundle with an absolute path before close_binary, and archive it next to the summary. The canonicalize-and-compare steps from the Evidence guide apply unchanged. If you have open questions you want to track across review rounds, record_unknown stores them in the same bundle.

How this compares with BinDiff, Diaphora and ghidriff

Dedicated diffing tools match all functions across two binaries using structural heuristics, and they rank the changes. REA 6.4.0 doesn’t do automatic whole-program matching. It compares the pairs you choose, by exact name, and refuses to fill gaps with guesses. In practice:

  • Use a whole-program differ when you have no source and no idea where to look.
  • Use REA when you have a candidate list (from source history, an advisory or a differ’s output) and you need conclusions that cite their evidence, with uncertainty kept explicit.
  • The two combine well: take a differ’s top-ranked pairs, then confirm each with compare_functions.

Troubleshooting

compare_functions rejects the input. It accepts only analyze_function Evidence with an artifact subject. Passing a decompile result or an ID string fails.

Every dimension is unknown. Check function_match first. Address-derived names on both sides make identity and calls unknown. Rebuild with symbols, or compare exported functions.

assembly is always changed. Expected when code moved: there is no relocation normalization. Weight cfg and pseudocode more heavily.

Snapshot not reused in MCP. The provider or analysis profile differs from the CLI run, or the session made metadata edits. Close and reopen the target.

The agent compares the wrong pair. Give it the explicit list from Step 2 and tell it not to substitute names.

Next steps