Find the Go Version and Dependencies of a Binary (go version -m)
On this page
Run go version -m <binary>. Every Go executable built in module mode carries a build-info record with 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). -ldflags="-s -w" and cross-compiling do not remove it, and go version -m reads ELF, PE and Mach-O files from any host.
Everything below was run on macOS 27.0.1 (Apple silicon) with Go 1.27.1, on a small program built for this article and on two open-source tools from Homebrew. Long lines are trimmed and marked. Without a Go toolchain, the browser-side ELF, PE and Mach-O viewer prints the same text line for line.
A sample with one dependency
The test program uses golang.org/x/text so the record 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 .
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 is the main module with a pseudo-version built from the commit time and hash; dep lines are what got linked, not everything in go.mod.
+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 ?? out/: the output directory sat inside the repository, untracked, so after the first binary landed in it Go treated the tree as modified. Adding out/ to .gitignore and committing fixed it, which is why the hash above is e614ba4 (the second commit). If you build an SBOM from these fields, a +dirty version can come from a stray build artifact, not only from uncommitted code.
Stripped and cross-compiled builds
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 | Size (bytes) | Build-info offset | go tool nm |
|---|---|---|---|
hello (darwin/arm64) |
2,857,570 | 1703936 | 2896 lines |
hello-stripped |
1,905,346 | 1703936 | 58 lines, all U imports |
hello-linux-amd64 |
2,806,372 | 1708032 | 3000 lines |
hello-linux-stripped |
1,835,168 | 1695744 | no symbol section |
hello.exe |
2,958,336 | 1803264 | not checked |
hello-darwin-amd64 |
2,970,192 | 1785856 | not checked |
All six still report go1.27.1 and the full module list. -s -w removed the symbol table, so go tool nm has nothing to show on the stripped Linux build, but the build info stays. The record starts with the magic \xff Go buildinf:; on Mach-O it is its own __go_buildinfo section (otool -l out/hello | grep -A10 __go_buildinfo gave offset 1703936), and grep -obUa ' Go buildinf:' finds it one byte after that offset in every file above.
Two changes in the settings list are easy to misread. 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, drop the four empty CGO_*FLAGS lines (11 settings instead of 14) and also have no -ldflags line, even though we passed -s -w. Build without -trimpath and it comes back:
go build -ldflags="-s -w" -o out/hello-sw .
go version -m out/hello-sw | grep ldflags
# build -ldflags="-s -w"
So a missing -ldflags line in a trimmed build does not mean no linker flags were used.
A universal (fat) Mach-O made with lipo -create is read fine: go version -m out/hello-universal printed go1.27.1.
Real tools: gh and cloudflared
GitHub CLI 2.96.0 (MIT, 38,299,474 bytes) and cloudflared 2026.10.0 (Apache-2.0, 26,056,034 bytes), both from Homebrew:
go version -m /opt/homebrew/Cellar/gh/2.96.0/bin/gh
| gh 2.96.0 | cloudflared 2026.10.0 | |
|---|---|---|
| Go version | go1.26.4 | go1.27.1 |
dep lines |
165 | 66 |
=> replacements |
0 | 3 |
A replacement shows as a dep line with no hash followed by a => line carrying the replacement’s version and hash. In cloudflared, github.com/quic-go/quic-go v0.59.1 is replaced by github.com/chungthuang/quic-go v0.45.1-0.20260529212404-a9fddf436fc4. That is a fork, and the question 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.
Both builds show the main module as (devel) with no vcs.* settings, so neither was stamped from a 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 this is gh 2.96.0; we relied on the Cellar path. 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.
Checking the modules for known vulnerabilities
govulncheck (v1.9.0, database updated 2026-10-20) has a binary mode that reads the same record:
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 summary disagreeing with its own list; we did not track down why. All six appear in go version -m at exactly those versions.
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, including the Homebrew gh above, read 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.
Getting the same record as JSON with offsets
go version -m has no JSON output. REA, a third-party open-source tool, reads the same record with rea inspect-go-binary <path> (MCP tool inspect_go_binary) and adds a SHA-256 of the file and the byte range each value came from. We used [email protected] from npm on Node.js 22.22.3; JSON comes from the global --format json option and --filter-output picks key paths. The default TOON output for out/hello, trimmed:
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
We checked the offsets instead of trusting them:
$ 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...
The flags byte 02 means the strings are stored inline (encoding: inline), and go1.27.1 starts at 0x1a0021 = 1703969, as version_location says. The 519-byte module range is 487 bytes of module text plus a 16-byte marker on each side. On the six builds, gh and cloudflared, REA’s dependency and replacement sets matched go version -m exactly (compared as (path, version) pairs); cloudflared’s replacements come out as a nested replacement object with the hash on the replacement and sum: null on the original. Runs took about 0.5 s, mostly Node startup, against 0.02 s for go version -m.
What tripped us up:
- Fat Mach-O files are rejected with
unsupported_target: … Universal/fat Mach-O containers require selecting a thin slice. Runlipo -thin x86_64 -output out/slice-x86_64 out/hello-universalfirst; the slice was byte-identical to the original x86_64 build./bin/lson macOS 27 has three slices and hit the same error. --filter-outputhides errors. A filtered call that fails prints{}with exit code 1, because the filter applies to the error envelope too. Rerun without the filter.- zsh eats the slice syntax. Unquoted
settings[7,9]fails withzsh: no matches found; quote the whole filter. - A non-Go file returns
build_info: nullwith exit 0 and a note that absent metadata does not prove the binary is not Go. - Over MCP the path must be absolute. A relative path that works on the CLI returns
invalid_request … Expected an absolute filesystem path on this host.The MCP call returned the sameevidence_idas the CLI run on the same file, and marks the resultuntrusted_content: true: module text is written by whoever built the binary, so an agent should treat it as data.
We only saw encoding: inline, the layout used since Go 1.18; the older pointer layout was not tested. All of this is build metadata written by the build and editable afterwards: it describes the file but does not prove where it came from, and it recovers no function names or code. To go further, decompile main.main as in native binary analysis with Ghidra, or check that a stripped release kept PIE and the other protections with Check Binary Hardening Flags.