rea-reverse

二进制补丁对比:逐函数比较两个版本(zlib CVE-2018-25032 实例)

基于 REA 6.7.0发布者 rea-reverse更新于 约 7 分钟读完
本页目录

对比两个构建版本,别去 diff 整个二进制。先用源码历史或安全公告找出修复改动过的函数,在两个构建里用同一个工具分别反编译,再按维度逐项比较。拿 zlib 1.2.11 和 1.2.12 来做,CVE-2018-25032 的修复一眼就能看出来:compress_block 不再读 d_buf/l_buf,改成每个符号从 sym_buf 读 3 个字节。

下文所有命令都在 2026-11-02 实际跑过,输出只做了删减,没有改动。用的是 REA 6.7.0(第三方开源工具),反编译器是 Ghidra 12.1.4。两个库都是用 zlib 的公开源码编译的。

我们的环境

  • REA 6.7.0、Ghidra 12.1.4、JDK 25。REA 的 Ghidra provider 需要完整的 JDK 21+;在 macOS 上还得自己编译反编译器。具体步骤和 rea doctor 检查见 REA + Ghidra 免费原生二进制分析。
  • 一个 MCP 客户端或脚本,用来做对比这一步。6.7.0 里 compare_functions、compare_artifacts、find_changed_behavior 都没有对应的 CLI 命令。
  • git、jq 和 C 编译器。

第 1 步:用完全相同的参数编译两个版本

构建参数不一致,本身就会造出差异。所以两个版本要用同一个编译器、同一组参数、同样的目录结构,而且不要 strip。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

我们是在 Apple Silicon Mac 上用 zig cc -target x86_64-linux-gnu 交叉编译出 x86-64 Linux ELF。这一路踩到两个坑:

  • zlib 1.2.11 在新版 macOS SDK 上编不过。 zutil.h 在 Mac 目标上把 fdopen 定义成 NULL,结果 SDK 的 stdio.h 报了 3 个错误。改成交叉编译到 Linux 就绕开了。
  • zlib 1.2.12 的 configure 会忽略你传入的 CC(1.2.13 修复)。它会悄悄走通用分支,导致共享库对象少了 -fPIC -DHAVE_HIDDEN,而 1.2.11 是有的。这正是会污染 diff 的那种参数漂移。继续之前,先确认两个 Makefile 一致:
$ 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

(我们的解决办法是在 PATH 里放一个叫 x86_64-pc-linux-gnu-gcc 的包装脚本,再设 CHOST=x86_64-pc-linux-gnu,这样两个版本的 configure 都不用传 CC。)参与对比的两个库:

7442921bd385d7fa47c7db966d304f05b8d02687cb95a9e8ae769aa24a5a650b  libz.so.1.2.11
d5e390de79c2e705f5f4b91744c208936dcb4a41fd9380cd0eef197d80b46638  libz.so.1.2.12

两个都是 ELF 64-bit LSB shared object, x86-64 ... with debug_info, not stripped。符号一定要保留:REA 按名字配对函数,FUN_00102a40 这类地址名会被当成不稳定的名字。拿 strip 过的构建来比,大部分维度都会是 unknown。

第 2 步:用源码 diff 选候选函数

整个版本的 diff 基本都是噪声:

$ 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.
...

CVE 修复是提交 5c44459,它把相互重叠的 d_buf/l_buf 合并成了一个 sym_buf。在 deflate.c 里它改了 deflateInit2_、deflatePrime、deflateCopy 和 deflate_fast/slow/rle/huff 这几个压缩策略函数,在 trees.c 里改了 init_block、_tr_tally 和 compress_block。我们挑了 deflateInit2_(分配缓冲区的地方)和 compress_block(读缓冲区的地方)。

看符号大小可以先粗略过一遍,但大小一样不能证明函数没变:

$ 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 在两个版本里都正好是 0x446 字节,可修复的核心恰恰就在它里面。

第 3 步:在两个构建里分别反编译

rea function 输出一条完整的 analyze_function Evidence,加上 --snapshot 就会缓存下来:

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

冷启动每次 16–18 秒,主要花在 Ghidra 导入和自动分析上。用同一个快照再跑一遍同样的查询只要 2 秒,返回的 evidence_id 完全相同。

deflateInit2_ 按名字查会失败:

Ghidra procedure name is ambiguous: deflateInit2_; select an exact entry address from [0x109700, 0x117b70]

第二个地址是一个 6 字节的 thunk("thunk": true, "thunk_target": "0x109700"),一个只负责跳到真正函数体的 PLT 式跳板。改成传函数体的入口地址就行:1.2.11 是 0x109700,1.2.12 是 0x10a2f0,也就是 Ghidra 默认镜像基址 0x100000 加上 nm 给出的偏移。得到的 Evidence 里函数名仍然是 deflateInit2_,按名字配对不受影响。

第 4 步:逐对比较

compare_functions 的 left 和 right 要传两条完整的 Evidence 记录,不是 ID。我们通过 MCP 用了两种方式,结论一致:

  1. 直接把第 3 步的 JSON 文件传进去;
  2. 全部放在同一个会话里:带 provider_id: "ghidra" 和第 3 步的 snapshot_path 调 open_binary,接着 analyze_function、close_binary,另一个版本照做一遍,最后调 compare_functions。因为有快照,每次 analyze_function 只用了 27–39 毫秒,没有启动 Ghidra。

交给 agent 做的话,把固定的函数对列表给它,明确要求不许换成别的函数名,并让它列出每个 changed 和 unknown 维度以及对应的 limitations。

第 5 步:读结果

我们这次的结果:

维度 compress_block deflateInit2_
identity unchanged unchanged
pseudocode changed(+110 / −108 行,90 个 hunk) changed(+5 / −6 行,8 个 hunk)
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 都是按 symbol 的 matched。注意:即使有维度是 unknown,整体状态照样是 changed。changed 不代表每个维度都有定论,unknown 的那几行要单独看。

修复体现在伪代码里。 下面是 REA 的 Ghidra Evidence 里 1.2.11 的 compress_block 主循环:

  if (s->last_lit == 0) {
  ...
    do {
      uVar4 = s->d_buf[uVar8];
      bVar1 = s->l_buf[uVar8];
  ...
      uVar8 = uVar8 + 1;
    } while (uVar8 < s->last_lit);

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);

距离变成 2 个字节、字面量/长度 1 个字节,全部从同一个缓冲区按每符号 3 字节读取,和提交说明一致。deflateInit2_ 里缓冲区的设置也恰好在提交说的位置变了:

-        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;

其他几行怎么看:

  • 行数会夸大改动。 compress_block 的 90 个 hunk 大多是 Ghidra 给临时变量重新编号造成的(uVar8 变成 uVar9,iVar9 变成 iVar10)。REA 的 limitation 写得很清楚:文本比较不做地址和生成名的归一化。要读 hunk 的内容,别数 hunk 的个数。
  • assembly 附带说明“relocation normalization is unavailable”,地址一挪就算变化,所以判断时更多参考 cfg 和 pseudocode。
  • comments 之所以变,是因为 Ghidra 会自动写 Unresolved local var: uint lx@[???] 这类注释,这次变成了 ... sx@[???],反映的是循环下标改了名,并不是人工注释。
  • deflateInit2_ 的 calls 是 unknown:“Ghidra’s callee inventory omits targets of 5 call reference(s)”。这 5 处就是 (*strm->zalloc)(...) 的内存分配,属于间接调用,静态分析找不到目标。调用集合不完整时,REA 不会判定两边相同。
  • references 两个函数都是 unknown:“Unnamed reference endpoints have no stable identity across different artifacts”。几乎所有跨版本对比都会遇到这一条。

如果候选函数的所有维度全是 unchanged,也先别下“没打补丁”的结论:修复可能在被内联的辅助函数里,或者在你没列出的函数里,也可能构建根本没带上改动。

第 6 步:对比两个构建装了哪些文件

inspect-artifact 不接受目录(报错是“open_binary accepts files and macOS app bundles. This target is a directory”),所以先把每个安装目录打成 zip:

(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 要的是每个结果里嵌在 normalized_result.substeps[0].evidence 的清单 Evidence,不是外层那条记录。我们得到 status: unknown,其中 unchanged 1 项、added 1 项、removed 1 项、changed 12 项、unknown 2 项:

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
...

两个符号链接是 unknown,不是 changed;带版本号的 .so 因为文件名变了,显示成一删一增。换成更大的产品,这一步往往能在看函数之前先发现新打包进来的辅助程序或改过的配置文件。

第 7 步:汇总和归档

find_changed_behavior 接受了 compare_artifacts 的结果,返回 behavior_status: unknown,其中 14 个静态候选、2 个未解决,limitations 里写着“No process comparison Evidence was supplied”和“Static differences are behavior candidates, not runtime observations”。写报告时也应该保留这个说法。

把 compare_functions 的结果传进去,我们这几次都失败了,不管是 CLI 生成的 Evidence 还是会话内的记录:evidence_integrity_mismatch,“Investigation input has a dangling Evidence link”,因为那时这些对比引用的函数 Evidence 已经不在证据库里了。所以函数层面的结论要自己写,引用 compare_functions 的 evidence_id 以及它关联的两个 analyze_function ID。

归档时,在 close_binary 之前用绝对路径调用 export_evidence_bundle。CLI 快照的证据包在顶层的 evidence_bundle 键下:

$ 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}

不加 --overwrite 再导出一次,会报“Evidence file already exists”。别用 rea compare 比两个版本的证据包来找补丁:它比的是证据包里有哪些记录,两个哈希不同的构建没有共同的记录。我们得到的是 status: unknown,新增 0、删除 0、变化 0、未解决 4。证据包对比适合用来确认同一个构建的两次运行结果一致。

什么时候改用 BinDiff、Diaphora 或 ghidriff

整程序 diff 工具会按结构把两个二进制里的所有函数配起来,再给改动排序。REA 只比较你指定的函数对,按名字精确匹配,无法确定的一律标成 unknown。没有源码、也不知道从哪看起时,先用整程序 diff 工具。已经有候选列表(来自源码历史、安全公告或 diff 工具的输出),并且需要每条结论都有证据时,再用 compare_functions 逐对确认。REA 的证据模型和其他 Ghidra MCP 有什么不同,见 REA 与 GhidraMCP、IDA Pro MCP 对比。

排错

报“procedure name is ambiguous”。 有 thunk 或 PLT 跳板和函数同名,改传函数体的入口地址(见第 3 步)。

compare_functions 拒绝输入。 它只接受完整的 analyze_function Evidence,传 decompile 的结果或者光一个 ID 字符串都会失败。

所有维度都是 unknown。 先看 function_match。两边都是地址名,说明构建被 strip 了。带符号重新编译,或者改比导出函数。

快照没被复用。 字节、操作、参数、provider 或分析配置有一项和写快照那次不同,或者会话里做过改名之类的元数据修改。关闭目标再重新打开。