Patch Diffing: Compare Two Binaries Function by Function (zlib CVE-2018-25032)
On this page
To patch-diff two builds, don’t diff the whole binary. Use the source history or the advisory to pick the functions the fix touched, decompile each one in both builds with the same tool, and compare them dimension by dimension. On zlib 1.2.11 vs 1.2.12 this shows the CVE-2018-25032 fix plainly: compress_block stops reading d_buf/l_buf and reads three bytes per symbol from sym_buf instead.
Everything below was run for real on 2026-11-02 with REA 6.7.0, a third-party open-source tool, with Ghidra 12.1.4 as its decompiler. The outputs are trimmed but not edited. Both libraries were built from zlib’s public source, so there’s nothing proprietary in here.
Setup we used
- REA 6.7.0 with Ghidra 12.1.4 and JDK 25. REA’s Ghidra provider needs a full JDK 21+ and, on macOS, a decompiler binary you build yourself. See REA + Ghidra for native binaries for that, and for
rea doctor. - An MCP client or script for the comparison step.
compare_functions,compare_artifactsandfind_changed_behaviorhave no CLI command in 6.7.0. git,jqand a C compiler.
Step 1: Build both versions with identical flags
Your build settings can create differences of their own, so build both versions with the same compiler, the same flags and the same layout, and don’t strip them. On Linux:
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
We built on an Apple Silicon Mac and cross-compiled x86-64 Linux ELF libraries with zig cc -target x86_64-linux-gnu. That route hit two traps worth knowing about:
- zlib 1.2.11 doesn’t compile against the current macOS SDK.
zutil.hdefinesfdopenasNULLon Mac targets, and the SDK’sstdio.hthen fails with three errors. Cross-compiling to Linux avoids it. - zlib 1.2.12’s
configureignores aCCyou pass in (fixed in 1.2.13). It quietly falls back to its generic branch, and the shared objects lose-fPIC -DHAVE_HIDDENwhile 1.2.11 keeps them. That’s exactly the kind of flag drift that pollutes a diff. Check that both Makefiles agree before you go further:
$ grep -E '^(CC|SFLAGS)=' zlib-1.2.11/Makefile zlib-1.2.12/Makefile
zlib-1.2.11/Makefile:CC=x86_64-pc-linux-gnu-gcc
zlib-1.2.11/Makefile:SFLAGS=-O2 -g -fPIC -D_LARGEFILE64_SOURCE=1 -DHAVE_HIDDEN
zlib-1.2.12/Makefile:CC=x86_64-pc-linux-gnu-gcc
zlib-1.2.12/Makefile:SFLAGS=-O2 -g -fPIC -D_LARGEFILE64_SOURCE=1 -DHAVE_HIDDEN
(Our fix was a wrapper script named x86_64-pc-linux-gnu-gcc on PATH, plus CHOST=x86_64-pc-linux-gnu, so that neither configure needed CC.) The two libraries we compared:
7442921bd385d7fa47c7db966d304f05b8d02687cb95a9e8ae769aa24a5a650b libz.so.1.2.11
d5e390de79c2e705f5f4b91744c208936dcb4a41fd9380cd0eef197d80b46638 libz.so.1.2.12
Both are ELF 64-bit LSB shared object, x86-64 ... with debug_info, not stripped. Keep the symbols. REA pairs functions by name and treats address-derived names such as FUN_00102a40 as unstable, so on a stripped build most dimensions come back unknown.
Step 2: Let the source diff choose the candidates
A release diff is mostly noise:
$ git diff --stat v1.2.11 v1.2.12 -- '*.c' '*.h'
...
37 files changed, 11948 insertions(+), 2889 deletions(-)
$ git log --oneline v1.2.11..v1.2.12 -- deflate.c deflate.h trees.c
21767c6 zlib 1.2.12
8678871 Replace black/white with allow/block. (theresa-m)
4346a16 Assure that the number of bits for deflatePrime() is valid.
5c44459 Fix a bug that can crash deflate on some input when using Z_FIXED.
c376a41 Fix deflateEnd() to not report an error at start of raw deflate.
...
The CVE fix is commit 5c44459. It replaces the overlaid d_buf/l_buf arrays with one sym_buf. In deflate.c it touches deflateInit2_, deflatePrime, deflateCopy and the deflate_fast/slow/rle/huff strategies. In trees.c it touches init_block, _tr_tally and compress_block. We picked deflateInit2_ (where the buffers are set up) and compress_block (where they’re read).
Symbol sizes are a quick first look, but they can’t prove a function is unchanged:
$ nm -S libz.so.1.2.11 | grep -E ' (compress_block|deflateInit2_|deflate_slow)$'
0000000000014a50 0000000000000446 t compress_block
0000000000009700 00000000000002d1 T deflateInit2_
000000000000cec0 00000000000006b1 t deflate_slow
$ nm -S libz.so.1.2.12 | grep -E ' (compress_block|deflateInit2_|deflate_slow)$'
00000000000157d0 0000000000000446 t compress_block
000000000000a2f0 00000000000002cb T deflateInit2_
000000000000dbb0 0000000000000735 t deflate_slow
compress_block is exactly 0x446 bytes in both builds, yet it carries the core of the fix.
Step 3: Decompile each function in both builds
rea function returns one complete analyze_function Evidence record, and --snapshot caches it:
for v in 1.2.11 1.2.12; do
rea function /work/zlib-$v/install/lib/libz.so.$v compress_block --provider ghidra \
--snapshot /work/diff/zlib-$v.snapshot.json --json > /work/diff/compress_block.$v.json
done
Each cold run took 16–18 s, mostly Ghidra import and auto-analysis. Repeating the same query with the same snapshot took 2 s and returned the identical evidence_id.
deflateInit2_ failed by name:
Ghidra procedure name is ambiguous: deflateInit2_; select an exact entry address from [0x109700, 0x117b70]
The second entry is a 6-byte thunk ("thunk": true, "thunk_target": "0x109700"), a PLT-style stub that only jumps to the real body. Pass the body’s address instead: 0x109700 in 1.2.11 and 0x10a2f0 in 1.2.12. That’s Ghidra’s default image base, 0x100000, plus the nm offset. The resulting Evidence still names the function deflateInit2_, so name matching still works.
Step 4: Compare the pairs
compare_functions takes the two complete Evidence records as left and right, not their IDs. We called it over MCP in two ways, and they gave the same verdicts:
- by passing the JSON files from Step 3 directly;
- in one session:
open_binarywithprovider_id: "ghidra"and the Step 3snapshot_path,analyze_function,close_binary, the same for the other build, thencompare_functions. With the snapshots in place, eachanalyze_functionreturned in 27–39 ms without starting Ghidra.
If an agent does this for you, give it the fixed list of pairs and tell it not to substitute other names. Ask it to report every changed and unknown dimension along with its limitations.
Step 5: Read the result
The verdicts from our run:
| Dimension | compress_block |
deflateInit2_ |
|---|---|---|
| identity | unchanged | unchanged |
| pseudocode | changed (+110 / −108 lines, 90 hunks) | changed (+5 / −6 lines, 8 hunks) |
| assembly | changed | changed |
| comments | changed | changed |
| calls | unchanged | unknown |
| references | unknown | unknown |
| strings_names | changed | changed |
| cfg | changed | changed |
| status | changed (2 / 5 / 1) | changed (1 / 5 / 2) |
function_match was matched by symbol for both. Note that the overall status is changed even though some dimensions are unknown. A changed verdict doesn’t mean every dimension is settled, so read the unknown rows separately.
The pseudocode is where the fix shows. Here is compress_block’s loop in 1.2.11, from REA’s Ghidra Evidence:
if (s->last_lit == 0) {
...
do {
uVar4 = s->d_buf[uVar8];
bVar1 = s->l_buf[uVar8];
...
uVar8 = uVar8 + 1;
} while (uVar8 < s->last_lit);
And the same loop in 1.2.12:
if (s->sym_next == 0) {
...
do {
puVar7 = s->sym_buf;
uVar12 = (uint)CONCAT11(puVar7[uVar9 + 1],puVar7[uVar9]);
bVar1 = puVar7[uVar9 + 2];
...
uVar9 = uVar9 + 3;
} while (uVar9 < s->sym_next);
The distance is now two bytes and the literal/length one byte, all read from a single buffer three bytes per symbol, which is what the commit describes. In deflateInit2_ the buffer setup changes in exactly the place the commit says:
- piVar6->d_buf = (ushf *)(pBVar7 + (uVar1 & 0xfffffffe));
- piVar6->l_buf = pBVar7 + (ulong)uVar1 * 3;
+ piVar6->sym_buf = pBVar7 + uVar1;
+ piVar6->sym_end = uVar1 * 3 - 3;
How to read the other rows:
- Line counts overstate the change. Most of
compress_block’s 90 hunks come from Ghidra renumbering its temporaries (uVar8becomesuVar9,iVar9becomesiVar10). REA’s limitation says it plainly: exact text is compared without address or generated-name normalization. Read the hunks and don’t count them. - assembly carries the note “relocation normalization is unavailable”. Every moved address counts as a change, so weight
cfgandpseudocodemore heavily. - comments changed because Ghidra writes comments such as
Unresolved local var: uint lx@[???], which became... sx@[???]. That’s the renamed loop index, not a hand-written comment. - calls: unknown in
deflateInit2_: “Ghidra’s callee inventory omits targets of 5 call reference(s)”. Those five are the(*strm->zalloc)(...)allocations, which are indirect calls with no static target. REA won’t call two incomplete call sets equal. - references: unknown in both: “Unnamed reference endpoints have no stable identity across different artifacts”. Expect this on almost any cross-version diff.
If every candidate is unchanged on every dimension, don’t conclude “not patched” yet. The fix may sit in an inlined helper or in a function you didn’t list, or the build may not have picked it up.
Step 6: Compare what each build installed
inspect-artifact rejects a directory (“open_binary accepts files and macOS app bundles. This target is a directory”), so zip each install tree first:
(cd /work/zlib-1.2.11 && zip -qry -X /work/diff/zlib-1.2.11-install.zip install)
rea inspect-artifact /work/diff/zlib-1.2.11-install.zip --json > /work/diff/inv-1.2.11.json
compare_artifacts takes the inventory Evidence nested at normalized_result.substeps[0].evidence of each result, not the outer record. Ours returned status: unknown with 1 unchanged, 1 added, 1 removed, 12 changed and 2 unknown:
changed install/include/zlib.h content,size,relations
changed install/lib/libz.a content,size,relations
unknown install/lib/libz.so availability
unknown install/lib/libz.so.1 availability
removed install/lib/libz.so.1.2.11 content
added install/lib/libz.so.1.2.12 content
changed install/lib/pkgconfig/zlib.pc content,relations
...
The two symlinks are unknown rather than changed, and the versioned .so shows up as removed plus added because the file name changed. On a larger product, this is the step that finds a newly bundled helper or an edited config file before you look at any functions.
Step 7: Aggregate and archive
find_changed_behavior accepted the compare_artifacts result. It returned behavior_status: unknown, 14 static candidates and 2 unresolved, with the limitations “No process comparison Evidence was supplied” and “Static differences are behavior candidates, not runtime observations”. Keep that wording in your write-up.
Passing it the compare_functions results failed in our runs, both from CLI Evidence and from the in-session records: evidence_integrity_mismatch, “Investigation input has a dangling Evidence link”. The function Evidence those comparisons point to wasn’t in the store at that point. So write the function-level conclusions yourself, citing the compare_functions evidence_id and the two analyze_function IDs it links.
To archive, call export_evidence_bundle with an absolute path before close_binary. A CLI snapshot carries its bundle under the top-level evidence_bundle key:
$ jq '.evidence_bundle' zlib-1.2.12.snapshot.json > zlib-1.2.12.bundle.json
$ rea evidence-import zlib-1.2.12.bundle.json --json
{"imported":2,"unknowns_added":0,"total":2}
$ rea evidence-export zlib-1.2.12.bundle.json zlib-1.2.12.canonical.json --json
{"path":".../zlib-1.2.12.canonical.json","bytes":916572,"records":2}
Running the export again fails with “Evidence file already exists” unless you add --overwrite. Don’t use rea compare on the two versions’ bundles to find the patch. It compares bundle membership, and two builds with different hashes share no records. Ours gave status: unknown, 0 added, 0 removed, 0 changed and 4 unresolved. Bundle comparison is for checking that two runs on the same build agree.
When to use BinDiff, Diaphora or ghidriff instead
Whole-program differs match all functions across two binaries by structure and rank the changes. REA compares only the pairs you choose, by exact name, and labels what it can’t establish as unknown. If you have no source and no idea where to look, start with a whole-program differ. If you have a candidate list from source history, an advisory or a differ’s output, and you need each conclusion tied to evidence, confirm the pairs with compare_functions. For a comparison of REA’s evidence model with other Ghidra MCP servers, see REA vs GhidraMCP vs IDA Pro MCP.
Troubleshooting
“procedure name is ambiguous”. A thunk or PLT stub shares the name. Pass the body’s entry address (Step 3).
compare_functions rejects the input. It accepts only complete analyze_function Evidence. A decompile result or a bare ID string fails.
Every dimension is unknown. Check function_match. Address-derived names on both sides mean a stripped build. Rebuild with symbols, or compare exported functions.
The snapshot isn’t reused. The bytes, operation, parameters, provider or analysis profile differ from the run that wrote it, or the session made metadata edits such as a rename. Close and reopen the target.