rea-reverse

查看 Go 二进制的 Go 版本和依赖模块(go version -m)

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

直接跑 go version -m <二进制>。开启模块模式构建的 Go 程序都带着一份 build info:编译器版本、主模块、每个依赖的版本和 go.sum 哈希、replace 替换,以及构建参数(GOOS、GOARCH、-trimpath、vcs.revision、vcs.modified)。-ldflags="-s -w" 和交叉编译都去不掉它,go version -m 在任何平台上都能读 ELF、PE 和 Mach-O。

下面的命令都在 macOS 27.0.1(Apple silicon)、Go 1.27.1 上实际跑过,样本是为本文编译的小程序,外加两个用 Homebrew 装的开源工具。太长的行做了删减并标明。手边没有 Go 工具链的话,浏览器里的 ELF、PE、Mach-O 查看器会逐行输出同样的内容。

准备一个带依赖的样本

样本用 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 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

mod 是主模块,伪版本由提交时间和哈希拼成;dep 是实际链接进来的模块,不是 go.mod 里的全部内容。

代码没改,版本却带了 +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。如果 SBOM 要用这些字段,记住:+dirty 也可能只是一个没忽略的构建产物造成的。

去符号和交叉编译之后

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 .
构建 大小(字节) build info 偏移 go tool nm
hello(darwin/arm64) 2,857,570 1703936 2896 行
hello-stripped 1,905,346 1703936 58 行,全是 U 导入
hello-linux-amd64 2,806,372 1708032 3000 行
hello-linux-stripped 1,835,168 1695744 no symbol section
hello.exe 2,958,336 1803264 未检查
hello-darwin-amd64 2,970,192 1785856 未检查

六个文件都还能读出 go1.27.1 和完整的模块清单。-s -w 把符号表删干净了,Linux 去符号版上 go tool nm 已经无话可说,build info 却原样保留。这份记录以魔数 \xff Go buildinf: 开头;在 Mach-O 里它单独占一个 __go_buildinfo 段(otool -l out/hello | grep -A10 __go_buildinfo 给出偏移 1703936),对上表每个文件跑 grep -obUa ' Go buildinf:',命中位置都恰好比偏移大 1。

构建参数里有两处容易看错。交叉编译的产物记录的是 CGO_ENABLED=0,也没有 CGO_*FLAGS 几行,因为交叉编译时没配 C 工具链,Go 默认关掉 cgo。加了 -trimpath 的产物多一条 -trimpath=true,少了四条空的 CGO_*FLAGS(14 条变 11 条),而且明明传了 -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 合成的通用(fat)Mach-O 也能直接读:go version -m out/hello-universal 输出 go1.27.1。

真实工具:gh 和 cloudflared

GitHub CLI 2.96.0(MIT,38,299,474 字节)和 cloudflared 2026.10.0(Apache-2.0,26,056,034 字节),都来自 Homebrew:

go version -m /opt/homebrew/Cellar/gh/2.96.0/bin/gh
gh 2.96.0 cloudflared 2026.10.0
Go 版本 go1.26.4 go1.27.1
dep 行数 165 66
=> 替换 0 3

替换会显示成一条没有哈希的 dep,后面跟一条 =>,带着替换目标的版本和哈希。cloudflared 里 github.com/quic-go/quic-go v0.59.1 被替换成了 github.com/chungthuang/quic-go v0.45.1-0.20260529212404-a9fddf436fc4。这是个 fork。排查 CVE 时要问的是“实际编进去的是哪一份”:针对上游版本范围写的 quic-go 公告,不能直接拿 v0.59.1 去套。

两个 Homebrew 构建的主模块都是 (devel),也没有任何 vcs.* 参数,说明都不是从检出目录里打的戳。每个 keg 的 .brew/ 目录里有 formula,能看出版本号是怎么塞进去的:gh 通过 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 个。这一点下一节会用到。

查依赖模块有没有已知漏洞

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 的汇总数和它自己的明细对不上,原因我们没有追下去。这六个模块在 go version -m 里版本完全一致。

至于 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),换新工具链重编即可。

需要 JSON 和字节偏移时

go version -m 没有 JSON 输出。第三方开源工具 REA 用 rea inspect-go-binary <path>(MCP 工具 inspect_go_binary)读同一份记录,另外给出文件的 SHA-256 和每个值所在的字节范围。我们用的是 npm 上的 [email protected]、Node.js 22.22.3;JSON 由全局参数 --format json 输出,--filter-output 可以挑字段。out/hello 的默认 TOON 输出(有删减):

evidence_id: ev_215a4c355b654ef5a753e839389146d7e534c66d0e04e95940656e3a66cc8866
subject:
  name: hello
  digest:
    sha256: e75cba19b1898b016c6ef6f6e9dfc42cdab719245d30c83b0dd73a0d0cb99cc9
  format: mach-o
  architecture: arm64
normalized_result:
  build_info:
    header_offset: 1703936
    encoding: inline
    go_version: go1.27.1
    version_location:
      offset: 1703969
      bytes: 8
    module_location:
      offset: 1703979
      bytes: 519
    module:
      main:
        path: example.com/hello
        version: v0.0.0-20261020050849-e614ba482e10
      dependencies[1]{path,version,sum,replacement}:
        golang.org/x/text,v0.43.0,"h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=",null
      settings[14]{key,value}:
        (14 rows, identical to go version -m)
      unparsed_lines: []
      complete: true

偏移我们核对过,没有直接相信:

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

标志字节 02 表示字符串内联存放(encoding: inline),go1.27.1 从 0x1a0021 = 1703969 开始,和 version_location 一致。519 字节的模块区间是 487 字节模块文本加两端各 16 字节的标记。在六个构建以及 gh、cloudflared 上,REA 的依赖和替换集合与 go version -m 完全一致(按 (path, version) 比较);cloudflared 的替换输出为嵌套的 replacement 对象,哈希在替换目标上,原依赖是 sum: null。每次耗时约 0.5 秒,主要是 Node 启动,go version -m 只要 0.02 秒。

踩过的坑:

  • 不接受 fat Mach-O,报 unsupported_target: … Universal/fat Mach-O containers require selecting a thin slice。先 lipo -thin x86_64 -output out/slice-x86_64 out/hello-universal,拆出的切片和原 x86_64 构建逐字节相同。macOS 27 的 /bin/ls 有三个切片,同样报这个错。
  • --filter-output 会吞掉错误。 带过滤的调用失败时只输出 {}、退出码 1,因为过滤也作用在错误信息上。去掉过滤重跑。
  • zsh 会吃掉切片语法。 不加引号写 settings[7,9] 会报 zsh: no matches found,整段加单引号。
  • 非 Go 文件返回 build_info: null,退出码 0,并注明没找到元数据不代表不是 Go 程序。
  • MCP 调用必须用绝对路径。 CLI 能用的相对路径在 MCP 里返回 invalid_request … Expected an absolute filesystem path on this host. 同一文件 MCP 返回的 evidence_id 和 CLI 相同,结果标了 untrusted_content: true:模块文本由构建者写入,agent 应该把它当数据而不是指令。

我们只见到了 encoding: inline,即 Go 1.18 以来的布局,更早的指针布局没测。以上都是构建时写入、事后可改的元数据:它描述了文件,但证明不了文件来源,也恢复不出函数名或代码。想再往下看,可以按 用 Ghidra 分析原生二进制 反编译 main.main,或者用 检查二进制加固选项 确认去符号的发布版还保留了 PIE 等保护。