rea-reverse

用 REA 分析 Go 二进制:读出 Go 版本、依赖模块和 VCS 提交

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

先说结论: 开启模块模式构建的 Go 程序,文件里都带着一份 build info:编译器版本、主模块、每个依赖的版本和 go.sum 哈希、replace 替换,以及构建参数(GOOS、GOARCH、-trimpath、vcs.revision、vcs.modified)。REA 6.7.0(morluto 的开源项目)用 rea inspect-go-binary <path> 读这份记录,对应的 MCP 工具是 inspect_go_binary。不需要装 Go,也不会运行目标程序。-ldflags="-s -w" 去掉的是符号表,build info 还在。

适合这几类场景:给要发布的程序做 SBOM;某个 Go CVE 出来后,要查手上一堆工具哪些受影响;或者核对一个 release 是不是从它声称的那个 commit 构建的。我们把一个小程序按几种方式编译,再加上两个用 Homebrew 装的开源工具,逐个跑 REA,每个结果都和 go version -m 对了一遍。下面的输出都是实测的,太长的地方做了删减并标明。

只分析自己构建的程序、开源软件的发布包,或者你有权评估的文件。本文的 REA 命令只读文件,但最后一步的漏洞扫描会联网查询 vuln.go.dev。用在其他目标之前,先读 REA 的 DISCLAIMER.md。

测试环境

Apple 芯片的 macOS 27.0.1,Homebrew 装的 Go 1.27.1,Node.js 22.22.3,[email protected] 从 npm 装进一个空目录。动手前先 npm pack [email protected] 把发布包拆开看过:命令注册在 dist/cli/goCommands.js,MCP 契约在 dist/contracts/go/goToolContracts.js。下文全部以 npm 上的 6.7.0 为准,不依赖 GitHub main 分支。

mkdir rea-sandbox && cd rea-sandbox
npm init -y >/dev/null
npm install [email protected]
npx rea --version
# 6.7.0
npx rea inspect-go-binary --help
rea inspect-go-binary — Inspect embedded Go compiler, modules and build settings without target execution

Usage: rea inspect-go-binary <path>

Arguments:
  path  Local ELF, PE or thin Mach-O image
(全局选项略)

帮助里没有 --json。要 JSON 输出用全局选项 --format json,要只看某几个字段用 --filter-output,后面都会用到。

准备一个带依赖的样本

样本程序只做一件事:用 golang.org/x/text 把一行字转成标题格式。这样 build info 里至少有一个真实依赖:

package main

import (
	"fmt"

	"golang.org/x/text/cases"
	"golang.org/x/text/language"
)

func main() {
	fmt.Println(cases.Title(language.English).String("hello from go"))
}
go mod init example.com/hello
go mod tidy          # pulled golang.org/x/text v0.43.0
git init && git add . && git commit -m "hello sample"
mkdir out && go build -o out/hello .

先用 Go 工具链自带的读取方式拿一份基准:

go version -m out/hello
out/hello: go1.27.1
	path	example.com/hello
	mod	example.com/hello	v0.0.0-20261020050849-e614ba482e10
	dep	golang.org/x/text	v0.43.0	h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
	build	-buildmode=exe
	build	-compiler=gc
	build	CGO_ENABLED=1
	(CGO_CFLAGS, CGO_CPPFLAGS, CGO_CXXFLAGS, CGO_LDFLAGS: all empty, trimmed)
	build	GOARCH=arm64
	build	GOOS=darwin
	build	GOARM64=v8.0
	build	vcs=git
	build	vcs.revision=e614ba482e10b8e3e4a86a082620c4e1df3e08f1
	build	vcs.time=2026-10-20T05:08:49Z
	build	vcs.modified=false

第一个坑:代码没改,版本却带了 +dirty

第一轮构建的结果并不是上面这样。只有第一次 build 是 vcs.modified=false,之后每个产物都变成:

	mod	example.com/hello	v0.0.0-20261020050501-d7f09abf3eb6+dirty
	build	vcs.modified=true

源码一个字没动。git status --porcelain 给出了原因:?? out/。输出目录放在仓库里又没被跟踪,第一个二进制落进去之后,Go 就认为工作区有改动了。把 out/ 写进 .gitignore 并提交后恢复正常,所以上面的哈希是第二个提交 e614ba4,不是 d7f09ab。如果 SBOM 要用到这些字段,记住一点:+dirty 也可能只是一个没忽略的构建产物造成的,不一定是有未提交的代码。

用 REA 读同一份记录

npx rea inspect-go-binary out/hello

默认输出是 TOON 格式,长得像精简版 YAML。只保留关键部分:

evidence_id: ev_215a4c355b654ef5a753e839389146d7e534c66d0e04e95940656e3a66cc8866
subject:
  name: hello
  digest:
    sha256: e75cba19b1898b016c6ef6f6e9dfc42cdab719245d30c83b0dd73a0d0cb99cc9
  format: mach-o
  architecture: arm64
(provider, parameters trimmed)
normalized_result:
  artifact:
    bytes: 2857570
  format: macho
  architecture: arm64
  bits: 64
  byte_order: little
  build_info:
    header_offset: 1703936
    encoding: inline
    go_version: go1.27.1
    (go_version_bytes_base64, module_text, module_bytes_base64 trimmed)
    version_location:
      offset: 1703969
      bytes: 8
    module_location:
      offset: 1703979
      bytes: 519
    module:
      path: example.com/hello
      main:
        path: example.com/hello
        version: v0.0.0-20261020050849-e614ba482e10
        sum: ""
        replacement: null
      dependencies[1]{path,version,sum,replacement}:
        golang.org/x/text,v0.43.0,"h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=",null
      settings[14]{key,value}:
        "-buildmode",exe
        (12 rows trimmed, identical to go version -m)
        vcs.modified,"false"
      unparsed_lines: []
      complete: true
confidence: observed
authority: shipped-artifact

解析出来的字段和 go version -m 一行不差:Go 版本、伪版本号、14 条构建参数,连顺序都一样。这次运行墙钟 0.48 秒,大头是 Node.js 启动;go version -m 只要 0.02 秒。所以选 REA 不是图快。

多出来的是这几样东西:artifact.sha256 把结果绑定到一个确定的文件;header_offset 和两个 *_location 给出每个值在文件里的位置。我们没有直接相信,拿 otool 和 xxd 核对了一下:

otool -l out/hello | grep -A10 __go_buildinfo | grep -E 'sectname|offset'
#   sectname __go_buildinfo
#     offset 1703936
xxd -s 1703936 -l 48 out/hello
001a0000: ff20 476f 2062 7569 6c64 696e 663a 0802  . Go buildinf:..
001a0010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
001a0020: 0867 6f31 2e32 372e 3187 0430 77af 0c92  .go1.27.1..0w...

1703936(0x1a0000)正好是 __go_buildinfo 节的起点,也是 \xff Go buildinf: 魔数的位置。标志字节 02 表示字符串内联存放,对应 REA 的 encoding: inline。go1.27.1 从 0x1a0021 = 1703969 开始,和 version_location 一致。519 字节的模块区间是 487 字节的模块文本加上前后各 16 字节的标记;module_bytes_base64 解码后开头是 30 77 af 0c 92 74 08 02 …,和 xxd 看到的一样。原始字节保留下来,意味着 REA 解析不了的行也不会丢,会放进 unparsed_lines。

去符号和交叉编译之后

接着编几个实际会发布的版本:

go build -trimpath -ldflags="-s -w" -o out/hello-stripped .
GOOS=linux   GOARCH=amd64 go build -o out/hello-linux-amd64 .
GOOS=linux   GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o out/hello-linux-stripped .
GOOS=windows GOARCH=amd64 go build -o out/hello.exe .
GOOS=darwin  GOARCH=amd64 go build -o out/hello-darwin-amd64 .
npx rea inspect-go-binary out/hello-linux-stripped --format json \
  --filter-output normalized_result.format,normalized_result.architecture,normalized_result.build_info.header_offset,normalized_result.build_info.go_version
构建 大小(字节) REA format / arch header_offset go tool nm
hello(darwin/arm64) 2,857,570 macho / arm64 1703936 2896 行
hello-stripped 1,905,346 macho / arm64 1703936 58 行,全是 U 导入
hello-linux-amd64 2,806,372 elf / amd64 1708032 3000 行
hello-linux-stripped 1,835,168 elf / amd64 1695744 no symbol section
hello.exe 2,958,336 pe / amd64 1803264 未检查
hello-darwin-amd64 2,970,192 macho / amd64 1785856 未检查

六个文件都读出了 go1.27.1,complete: true。对每个文件跑 grep -obUa ' Go buildinf:',命中位置都恰好比 REA 的偏移大 1(前一个字节是 0xff)。-s -w 把符号表删干净了,Linux 版上 go tool nm 已经无话可说,build info 却原样保留。

构建参数会随编译选项变化,其中两处容易看错。交叉编译的产物记录的是 CGO_ENABLED=0,也没有 CGO_*FLAGS 几行,因为交叉编译时如果没配 C 工具链,Go 默认关掉 cgo。加了 -trimpath 的产物多一条 -trimpath=true,少了四条空的 CGO_*FLAGS,所以 hello-stripped 是 11 条,hello 是 14 条。而且明明传了 -s -w,里面却没有 -ldflags 这一行。去掉 -trimpath 再编一次,它就回来了:

go build -ldflags="-s -w" -o out/hello-sw .
go version -m out/hello-sw | grep ldflags
#	build	-ldflags="-s -w"

也就是说,带 -trimpath 的构建里看不到 -ldflags,不代表没用链接参数。

三处一开始没跑通的地方

通用二进制。 用 lipo -create 把 arm64 和 x86_64 两个版本合在一起,REA 给的是报错而不是报告:

code: unsupported_target
message: "Unsupported target for inspect_go_binary at …/out/hello-universal: Universal/fat Mach-O containers require selecting a thin slice"
retryable: false

同一个文件 go version -m out/hello-universal 照读不误,输出 go1.27.1。用 REA 的话得先拆出单个切片。拆出来的切片和原来的 x86_64 构建逐字节相同(SHA-256 都是 5bc2022e…):

lipo -thin x86_64 -output out/slice-x86_64 out/hello-universal
npx rea inspect-go-binary out/slice-x86_64

/bin/ls 也撞上了同样的错误:macOS 27 上它是三个切片的通用文件(x86_64、arm64e、arm64e.x1)。切片相关的细节可以看 这个 Mac 应用是不是通用二进制?。

--filter-output 把错误吞了。 第一次对 /bin/ls 跑的时候带了过滤参数,整个输出只有:

{}

退出码 1。过滤也作用在错误信息上,而错误里根本没有我们要的那些键。以后看到带过滤的命令返回 {},先去掉过滤重跑一次,看真正的报错。

zsh 和切片语法。 帮助里写的过滤语法是 a[0,3]。在 zsh 里不加引号写 settings[7,9],还没轮到 REA 就报 zsh: no matches found。整段加上单引号:--filter-output 'normalized_result.build_info.module.settings[7,9]'。

非 Go 文件拆成单切片后结果很干净。对 /bin/ls 的 arm64e 切片,REA 返回 build_info: null,退出码 0,limitations 里多了第四条:“No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.” 所以 null 的意思是“这个文件里没找到”,不是“这不是 Go 程序”。

换成真实工具:gh 和 cloudflared

再拿两个 Homebrew 装的开源 Go 工具试:GitHub CLI 2.96.0(MIT,38,299,474 字节)和 cloudflared 2026.10.0(Apache-2.0,26,056,034 字节)。每个都存一份 REA 的 JSON 和 go version -m 的输出,用一小段 Python 比较两边的 (path, version) 集合。

npx rea inspect-go-binary /opt/homebrew/Cellar/gh/2.96.0/bin/gh --format json > gh.json
go version -m /opt/homebrew/Cellar/gh/2.96.0/bin/gh > gh.txt
gh 2.96.0 cloudflared 2026.10.0
Go 版本 go1.26.4 go1.27.1
依赖数(REA / go version -m) 165 / 165 66 / 66
替换数(REA / => 行) 0 / 0 3 / 3
只在一边出现的条目 无 无
REA 耗时 0.52 秒 0.43 秒
JSON 报告大小 63,063 字节 27,646 字节

cloudflared 的三处替换在 REA 里是嵌套对象。被替换的原依赖 sum: null,因为它那行 dep 本来就没有哈希,哈希记在替换目标上:

{"path": "github.com/quic-go/quic-go", "version": "v0.59.1", "sum": null,
 "replacement": {"path": "github.com/chungthuang/quic-go",
                 "version": "v0.45.1-0.20260529212404-a9fddf436fc4",
                 "sum": "h1:ZaFGQi6lUEnMyl0DvRy2mEp9u7FP+FrUBr7q+c4U68o="}}

这是个 fork。排查 CVE 时要问的是“实际编进去的是哪一份”:针对上游版本范围写的 quic-go 公告,不能直接拿 v0.59.1 去套,二进制里的代码是 fork 的那个伪版本。

两个 Homebrew 构建的主模块版本都是 (devel),也没有任何 vcs.* 参数,说明都不是从版本库检出目录里打的戳。每个 keg 的 .brew/ 目录里有对应的 formula,能看出发布版本号是怎么塞进去的:gh 的 formula 通过 GH_VERSION 传给 Makefile,cloudflared 的在 ldflags 里写了 -X main.Version=…。开了 -trimpath 就不记录 -ldflags,所以单看 build info 没法知道这是 gh 2.96.0,我们是靠 Cellar 路径确认的。这两个文件实际上也都去过符号:gh 的 formula 写着 ldflags = %w[-s -w],go tool nm 在 gh 里找不到任何已定义符号,在 cloudflared 里只有 33 个。这一点下一节会用到。

把模块清单交给漏洞扫描器

REA 只负责盘点。至于“这个二进制有没有已知漏洞”,Go 团队的 govulncheck(v1.9.0,数据库更新于 2026-10-20)有二进制模式:

govulncheck -mode=binary /opt/homebrew/Cellar/gh/2.96.0/bin/gh
# Your code is affected by 35 vulnerabilities from 5 modules and the Go standard library.

逐条看结果,涉及的第三方模块其实是六个:golang.org/x/[email protected]、google.golang.org/[email protected]、golang.org/x/[email protected]、golang.org/x/[email protected]、golang.org/x/[email protected] 和 github.com/klauspost/[email protected]。govulncheck 的汇总数和它自己的明细对不上,原因我们没有追下去。这六个模块在 REA 的依赖清单里都能找到,版本完全一致,盘点和扫描在“文件里有什么”这件事上是吻合的。

至于 gh 是不是真的调用了这些有漏洞的代码,就不好说了。我们自己的构建能说明原因:

构建 govulncheck -mode=binary
hello、hello-trim、hello-linux-amd64 受影响 0 个(依赖模块里有 12 个,但代码没调用)
hello-sw、hello-stripped、hello-linux-stripped 受 Go 标准库 12 个漏洞影响

两组之间唯一的区别是 -s -w。在去了符号的构建上,govulncheck 把 http2.ClientConn.Close 这类 net/http 符号列为已找到,可是在没去符号的构建上,go tool nm 显示根本没有链接任何 net/http 符号。没有符号表,它看不出哪些函数真的被链接进来,报的就比实际多。所以对去过符号的二进制(上面 Homebrew 的 gh 也算),把结果当成模块级清单来看:这些模块、这个 Go 版本有公告。我们 hello 构建的 12 个漏洞都在 go1.27.2 修复了(crypto/tls、html/template、net/http、net/http/internal/http2、net/textproto),所以办法就是换新工具链重新编译,而 REA 的报告能告诉你哪些二进制需要重编。

在 agent 里通过 MCP 调用

npx rea mcp 会启动 REA 的 stdio MCP 服务。接入 Claude Code、Cursor 或 Codex 的步骤见 安装 REA 并接入 MCP。为了不经过 agent 直接核对契约,我们写了一个很短的 Node 客户端,依次发 initialize、tools/list 和一次 tools/call。服务列出了 207 个工具,inspect_go_binary 的输入 schema 只有一个属性 path,说明是 “Absolute filesystem path to the selected executable or library”。

CLI 会把 out/hello 按当前目录解析成绝对路径;同样的相对路径走 MCP 会被拒:

{"code":"invalid_request","category":"invalid_input",
 "details":{"operation":"inspect_go_binary",
  "issues":[{"path":["path"],"reason":"invalid_format",
             "message":"Expected an absolute filesystem path on this host."}]}}

换成绝对路径后,服务端日志里工具本身耗时 durationMs: 6.83,返回的 evidence_id(ev_215a4c35…)和对同一文件跑 CLI 得到的完全相同,两种调用方式拿到的是同一个标识。结果里还带着 _meta.provenance.untrusted_content: true:模块文本是构建这个二进制的人写进去的,agent 应该把它当数据,而不是当指令。

REA 和其他工具对不上的地方

检查项 REA 6.7.0 参照工具 说明
fat Mach-O unsupported_target go version -m 能读 先 lipo -thin
/bin/ls 的 arm64e 切片 architecture: arm64 otool -hv:ARM64,subtype E REA 丢了子类型
格式名 subject.format: mach-o,normalized_result.format: macho — 同一份报告两种写法
带 --filter-output 时出错 输出 {},退出码 1 — 错误信息被藏起来了
模块、版本、构建参数 8 个文件全部一致 go version -m 没发现差异
头部偏移 6 个构建全部吻合 otool -l、grep -obUa 差 1 只是因为开头的 0xff

这些都没影响模块数据,而 SBOM 关心的正是模块数据。在 Mac 上最容易碰到的是 fat 二进制那一条。

局限和下一步

我们只见到了 encoding: inline,也就是 Go 1.18 起使用的布局。REA 说它也能读更早的指针布局,但我们没有编出那么老的二进制来验证。limitations 里写的 256 MiB 文件上限等资源限制也没测,最大的样本是 38 MB 的 gh。另外这里只有构建元数据:inspect-go-binary 不恢复函数名、类型或代码。里面每个字段都是构建时写入的,也可以被改,它描述的是这个文件,证明不了文件的来历。

需要元数据以外的东西: