rea-reverse

Analyze a Go Binary with REA: Go Version, Modules and VCS

Written for REA 6.7.0Published by rea-reverseUpdated 10 min read
On this page

Short answer: every Go executable built with module support carries a build-info record: the compiler version, the main module, every dependency with its version and go.sum hash, any replace directives, and the build settings (GOOS, GOARCH, -trimpath, vcs.revision, vcs.modified). REA 6.7.0, the open-source project by morluto, reads that record with rea inspect-go-binary <path> (MCP tool inspect_go_binary), without Go installed and without running the target. Stripping with -ldflags="-s -w" does not remove it.

This guide is for people who need to answer “what is this Go binary made of?” for files they ship or run: release engineers building an SBOM, someone triaging a Go CVE across a fleet of tools, or a reviewer checking that a release was built from the commit it claims. We built a small program several ways, ran REA on each build and on two open-source tools installed with Homebrew, and compared every result with go version -m. All output below is real; long lines are trimmed and marked.

Only inspect binaries you built, open-source releases, or files you are authorized to assess. The REA command here only reads the file, but the vulnerability scan at the end queries vuln.go.dev over the network. Read REA’s DISCLAIMER.md before using it on anything else.

Analyze a Go Binary with REA: Go Version, Modules and VCSSilent walkthrough with English captions.

The setup we tested on

macOS 27.0.1 on Apple silicon, Go 1.27.1 from Homebrew, Node.js 22.22.3, and [email protected] installed from npm into an empty directory. We first ran npm pack [email protected] and read the tarball: the command is registered in dist/cli/goCommands.js and the MCP contract in dist/contracts/go/goToolContracts.js, so nothing below relies on the GitHub main branch.

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
(global options trimmed)

There is no --json flag in the help. JSON comes from the global --format json option, and --filter-output picks out key paths. We use both below.

A sample with one dependency

The test program prints one title-cased line using golang.org/x/text, so the build info has a real dependency to show:

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 .

Before using REA we took a baseline with the Go toolchain’s own reader:

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

The first pitfall: +dirty on a clean checkout

Our first round of builds did not look like that. The first build reported vcs.modified=false, and every build after it reported this:

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

Nothing in the source had changed. git status --porcelain showed the cause: ?? out/. The output directory sat inside the repository, untracked, so after the first binary landed in it, Go treated the working tree as modified. Adding out/ to .gitignore and committing fixed it, which is why the hash above is e614ba4 (the second commit) and not d7f09ab. If you build an SBOM from these fields, this matters: a +dirty version can come from a stray build artifact, not only from uncommitted code.

Reading the same record with REA

npx rea inspect-go-binary out/hello

The default output format is TOON, a compact YAML-like layout. Trimmed to the parts that matter:

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

The parsed fields match go version -m line for line: same Go version, same pseudo-version, the same 14 settings in the same order. The run took 0.48 s wall clock, most of it Node.js startup. go version -m took 0.02 s, so speed is not why you would pick REA.

The reasons to pick it are the extra fields. artifact.sha256 ties the result to one exact file. header_offset and the two *_location ranges say where in the file each value came from, and we checked them instead of trusting them:

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

Offset 1703936 (0x1a0000) is the start of the __go_buildinfo section and of the \xff Go buildinf: magic. The flags byte 02 means the strings are stored inline, which REA reports as encoding: inline. go1.27.1 starts at 0x1a0021 = 1703969, as version_location says. The 519-byte module range is the 487 bytes of module text plus a 16-byte marker on each side; the raw bytes in module_bytes_base64 start with 30 77 af 0c 92 74 08 02 …, the same bytes xxd shows. Keeping the raw bytes means a line REA cannot parse is kept too, in unparsed_lines, and not dropped.

Stripped and cross-compiled builds

Next we made the builds people actually ship:

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
Build Size (bytes) REA format / arch header_offset go tool nm
hello (darwin/arm64) 2,857,570 macho / arm64 1703936 2896 lines
hello-stripped 1,905,346 macho / arm64 1703936 58 lines, all U imports
hello-linux-amd64 2,806,372 elf / amd64 1708032 3000 lines
hello-linux-stripped 1,835,168 elf / amd64 1695744 no symbol section
hello.exe 2,958,336 pe / amd64 1803264 not checked
hello-darwin-amd64 2,970,192 macho / amd64 1785856 not checked

Every build returned go1.27.1 and complete: true. A grep -obUa ' Go buildinf:' on each file found the magic one byte after REA’s offset every time (the byte before it is the 0xff). -s -w removed the symbol table, so go tool nm has nothing left to show on the Linux build, but the build info stays.

The settings list changes with the flags, and two of the changes are easy to misread. The cross-compiled builds record CGO_ENABLED=0 with no CGO_*FLAGS lines, because Go turns cgo off when cross-compiling unless you give it a C toolchain. The -trimpath builds add -trimpath=true and drop the four empty CGO_*FLAGS lines, so hello-stripped has 11 settings where hello has 14. They also have no -ldflags line, even though we passed -s -w. Build without -trimpath and the line comes back:

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

So the absence of -ldflags in a trimmed build does not mean no linker flags were used.

Three things that did not work the first time

Universal binaries. We merged the arm64 and x86_64 builds with lipo -create and got an error, not a report:

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 reads the same file without complaint and prints go1.27.1. With REA, extract a slice first. The extracted slice was byte-identical to the original x86_64 build (same SHA-256, 5bc2022e…):

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

The same error hit us on /bin/ls, which on macOS 27 is a three-slice universal file (x86_64, arm64e, arm64e.x1). For more on slices, see Is This Mac App Universal?.

An error hidden by --filter-output. Our first /bin/ls attempt used a filter, and the whole output was:

{}

with exit code 1. The filter applied to the error envelope too, and none of the requested keys exist in an error. Once a filtered command returns {}, run it again without the filter to see the error.

zsh and the slice syntax. The help gives a[0,3] as filter syntax. In zsh an unquoted settings[7,9] fails before REA runs, with zsh: no matches found. Quote the whole filter: --filter-output 'normalized_result.build_info.module.settings[7,9]'.

A non-Go file gives a clean result once it is thin. On the arm64e slice of /bin/ls, REA returned build_info: null with exit code 0 and added a fourth limitation: “No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.” So null means “not found in this file”, not “not Go”.

Real tools: gh and cloudflared

Next we tried two open-source Go tools installed with Homebrew: GitHub CLI 2.96.0 (MIT, 38,299,474 bytes) and cloudflared 2026.10.0 (Apache-2.0, 26,056,034 bytes). For each one we saved REA’s JSON and go version -m output and compared the sets of (path, version) pairs with a short Python script.

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 version go1.26.4 go1.27.1
Dependencies (REA / go version -m) 165 / 165 66 / 66
Replacements (REA / => lines) 0 / 0 3 / 3
Pairs found by only one tool none none
REA wall time 0.52 s 0.43 s
JSON report size 63,063 bytes 27,646 bytes

The cloudflared replacements come out as nested objects. The original dependency has sum: null, because a replaced module’s dep line has no hash, and the hash sits on the replacement:

{"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="}}

That is a fork, and the question to ask in a CVE triage is which one you have. A quic-go advisory written against upstream version ranges cannot be read off v0.59.1, because the code in the binary is the fork’s pseudo-version.

Both Homebrew builds show the main module as (devel) with no vcs.* settings, so neither was stamped from a version-control checkout. The formulas in each keg’s .brew/ directory show how the release version gets in: gh’s passes it to the Makefile as GH_VERSION, and cloudflared’s sets -X main.Version=… in its ldflags. With -trimpath on, no -ldflags setting is recorded, so the build info alone cannot tell you that this is gh 2.96.0. We relied on the Cellar path for that. Both are also effectively stripped: gh’s formula sets ldflags = %w[-s -w], and go tool nm finds no defined symbols in gh and only 33 in cloudflared. That matters for the next step.

Handing the module list to a vulnerability scanner

REA stops at the inventory. For “does this binary have a known vulnerability”, the Go team’s govulncheck (v1.9.0, database updated 2026-10-20) has a binary mode:

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.

The individual findings name six modules, not five: 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] and github.com/klauspost/[email protected]. That is govulncheck’s own count disagreeing with its list; we did not track down why. All six appear in REA’s dependency list at exactly those versions, so the inventory and the scanner agree on what is in the file.

Whether gh really calls the vulnerable code is less clear, and our own builds show why:

Build govulncheck -mode=binary
hello, hello-trim, hello-linux-amd64 affected by 0 vulnerabilities (12 in required modules, not called)
hello-sw, hello-stripped, hello-linux-stripped affected by 12 vulnerabilities from the Go standard library

The only difference is -s -w. On the stripped builds govulncheck listed net/http symbols such as http2.ClientConn.Close as found, but go tool nm on the unstripped build shows no net/http symbols at all. Without a symbol table it cannot see which functions were linked and reports more than is there. For stripped binaries, and that includes the Homebrew gh above, treat its output as a module-level list: these modules and this Go version have advisories. All 12 for our hello builds were fixed in go1.27.2 (crypto/tls, html/template, net/http, net/http/internal/http2, net/textproto), so rebuilding with a newer toolchain is the fix, and the REA report shows which binaries need it.

Calling it from an agent over MCP

npx rea mcp starts REA’s stdio MCP server. (Getting started with REA and MCP covers registering it with Claude Code, Cursor or Codex.) To check the contract without an agent in the loop, we wrote a short Node client that sends initialize, tools/list and one tools/call. The server listed 207 tools. The input schema of inspect_go_binary has one property, path, described as “Absolute filesystem path to the selected executable or library”.

The CLI resolves out/hello against the current directory. Over MCP the same relative path is rejected:

{"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."}]}}

With an absolute path the server logged durationMs: 6.83 for the tool itself and returned the same evidence_id (ev_215a4c35…) as the CLI run on the same file, so you get the same identifier whichever way you call it. The result also carries _meta.provenance.untrusted_content: true. Module text is written by whoever built the binary, and an agent should treat it as data, not instructions.

Where REA and the other tools disagreed

Check REA 6.7.0 Reference tool Notes
Fat Mach-O unsupported_target go version -m reads it Use lipo -thin first
arm64e slice of /bin/ls architecture: arm64 otool -hv: ARM64, subtype E REA drops the subtype
Format name subject.format: mach-o, normalized_result.format: macho — Two spellings in one report
Error with --filter-output prints {}, exit 1 — The error text is hidden
Modules, versions, settings identical on 8 files go version -m No differences found
Header offset matched on 6 builds otool -l, grep -obUa Off by one only because of the leading 0xff

None of these changed the module data, which is what matters for an SBOM. The fat-binary error is the one you will hit most on a Mac.

Limits and next steps

We only saw encoding: inline, the layout used since Go 1.18. REA says it also reads the older pointer layout, but we did not build an old enough binary to test it. The 256 MiB file limit and the other resource limits in the limitations array were not tested either; our largest file was 38 MB. And this is build metadata only: inspect-go-binary recovers no function names, types or code. Everything in it was written by the build and can be edited, so it describes the file, but does not prove where the file came from.

For more than metadata: