Go Binaries¶
Go has become a favorite for implants, loaders and ransomware: one static binary, easy cross-compilation, and a runtime that makes classic signatures useless. But every Go binary carries a large amount of build metadata that the toolchain embeds on purpose, and it survives stripping (-s -w) far better than most authors expect.
How PPEE detects Go¶
PPEE looks for the Go build info block (the \xff Go buildinf: header that holds the version and the module/settings text) and a pclntab, the function-table structure the runtime uses for stack traces. Both are reported with their file offsets in the Detected line, for example Go build info at offset 0x5B0220; Go 1.20+ pclntab at offset 0x8C61E0.
Views¶
| View | Columns | Use it to |
|---|---|---|
| Summary | identity, build, code, dependencies, layout clues | The report below |
| Packages | Package · Functions · Source files | See what code is compiled in: the program's own packages stand out among the standard library |
| Modules | Module · Version · Replaced by · Checksum | The full dependency list from the build info, with go.sum hashes |
| Build settings | Setting · Value | Every -ldflags, -tags, CGO_*, GOARCH, … the binary was built with |
Each function row in Packages links to its code: click the corner mark or right-click → Show Code to open the Code window at that function (in JSON the row's link carries va).
What the Summary tells you¶
| Section | Facts |
|---|---|
| Identity | Go version (go1.24.2), main package path |
| Build | GOOS, GOARCH (and GO386, GOAMD64, …), CGO_ENABLED, -buildmode, -compiler, -ldflags, -tags, -trimpath |
| Code | Function table version and offset, function and package counts, source-file count, the main package's function count, text start, and whether COFF symbols are still present |
| Dependencies | Every module with its version |
| Layout clues | Overlay size, sections with no file data, writable + executable sections |
Reading a Go build like an analyst¶
Configuration baked in with -ldflags -X¶
Go lets a build set string variables at link time: -ldflags "-X main.url=https://…". Frameworks and builders use this to bake the C2 configuration into each binary, and the linker flags are stored in the build info in clear text.
Walkthrough: a Merlin C2 agent's configuration, no execution needed
$ ppee-cli --json --analysis merlin.dll | jq -r '.analysis.runtimes[0].views[] | select(.key | endswith("build")) | .table.rows[] | .cells | @tsv' | grep ldflags
-ldflags "-s -w -X \"main.secure=false\" -X \"main.addr=127.0.0.1:4444\" -X \"main.auth=opaque\"
-X \"main.transforms=jwe,gob-base\" -X \"main.protocol=h2\" -X \"main.url=https://127.0.0.1:443\"
-X \"main.psk=merlin\" -X \"main.sleep=30s\" -X \"main.skew=3000\" -X \"main.padding=4096\"
-X \"main.useragent=Mozilla/5.0 (Windows NT 6.1; Win64; x64) … Chrome/40.0.2214.85 Safari/537.36\" …"
h2), the URL, the pre-shared key, the beacon interval and jitter (30s, skew 3000), the traffic padding and the User-Agent: exactly the network IOCs a detection engineer needs. The same view shows -buildmode c-archive (a DLL), GOARCH 386 and CGO_ENABLED=1. Dependencies reveal the toolkit¶
The Modules view names the offensive tooling directly. In the same sample: github.com/Ne0nd0g/merlin-agent/v2, go-clr (host the .NET CLR in-process), winhttp, go-socks5, quic-go and the cloudflare/circl and kyber cryptography libraries. A replaced-by entry means the build substituted a fork: a strong hint at a modified toolkit.
The pclntab and package list¶
The function table lists every function with its package and source file, which survives -s -w. Read the Packages view starting with main:
| Observation | What it suggests |
|---|---|
A handful of packages under main, hundreds from the standard library | Normal. Focus on the small set of non-library packages |
| Package paths with a repository host | The project name and author (github.com/<user>/<repo>/…), useful for attribution |
| The Summary reports most function names were rewritten | Obfuscated with a tool such as garble. Names are meaningless, so lean on imports, strings and behavior |
| A large overlay | Appended payload or configuration. Run the deep pass to measure its entropy |
| COFF symbols still present | The build wasn't stripped, so function names may also be recoverable from the symbol table |
Expand a package in the GUI (or read detail in JSON) to see each function's name, entry RVA, size and source file. The source paths reveal the build environment: for the sample above they are /home/runner/work/merlin-agent-dll/main.go, the layout of a GitHub Actions runner, so the binary was produced by a CI pipeline, not on a developer's machine.
Package and function counts also cluster samples: an agent built from the same source and toolchain has the same package list.
Function names in the code¶
The pclntab names every Go function, and PPEE gives those names to the disassembler: listings, cross-references, call trees and the Code window show main.main, main.sendData, syscall.Syscall instead of sub_…. The scan also starts from every function the table lists, so code reached only through a closure or an interface is decoded too. Start reading a Go sample at its own code:
PS C:\> ppee-cli.exe --callees func:main.main C:\MalwareSamples\847d8f4998d22fde37eb76f99b6d91012c42965b741fe2cec453ca5876cdf147.exe
C:\MalwareSamples\847d8f4998d22fde37eb76f99b6d91012c42965b741fe2cec453ca5876cdf147.exe: 2996608 bytes, PE32+
Callees of func:main.main:
0x140135460 main.main
0x1400072E0 runtime.mapassign_fast64
0x140007A20 runtime.mapaccess1_faststr
0x140007F60 runtime.mapassign_faststr
0x1400084E0 runtime.mapdelete_faststr
0x140014AE0 runtime.mapIterStart
0x140014B40 runtime.mapIterNext
0x140039AC0 runtime.panicdivide
0x140039F40 runtime.deferreturn
0x14003D160 runtime.printlock
0x14003D1C0 runtime.printunlock
0x14003D360 runtime.printsp
0x14003D3A0 runtime.printnl
0x14003D800 runtime.printint
0x14003D9E0 runtime.printstring
0x140054DA0 runtime.slicebytetostring
0x140067200 runtime.panicunsafeslicelen
0x1400672A0 runtime.panicunsafeslicenilptr
0x14006A3A0 runtime.convT64
0x14006A420 runtime.convTstring
0x14006C400 runtime.rand
0x14006CB40 runtime.makeslice
0x14006CC20 runtime.growslice
0x14006E8A0 syscall.Syscall
0x140070900 runtime.morestack_noctxt
0x140072800 runtime.gcWriteBarrier1
0x140072820 runtime.gcWriteBarrier2
0x140072BC0 runtime.panicIndex
0x140072C40 runtime.panicSliceAcap
0x140072C80 runtime.panicSliceB
0x140072E34 sub_140072E34
0x140072E4B sub_140072E4B
0x140072E62 sub_140072E62
0x140072E6B sub_140072E6B
0x140072E70 sub_140072E70
0x140073240 runtime.memclrNoHeapPointers
0x140073540 runtime.memmove
0x140082D60 strings.Repeat
0x140083780 bufio.NewWriter
0x140083880 bufio.(*Writer).Flush
0x140097E40 time.Time.Format
0x14009F380 time.Now
...
RemusStealer (Go). In MCP: disassemble or get_callees with target: "func:main.main".
CLI and JSON¶
$ ppee-cli --analysis merlin.dll
Go
Detected: Go build info at offset 0x5B0220; Go 1.20+ pclntab at offset 0x8C61E0
Go > Summary (built from the Go build info, build ID and pclntab, and the file layout)
Identity
Go version: go1.24.2 [offset 0x5B0220]
Main package: command-line-arguments [offset 0x5B025B]
Code
Functions: 12519 functions in 313 packages [Analysis > Go > Packages], 1319 source files
Package main: 4 functions
# Go version and target of every Go binary in a folder
for f in samples/*; do
ppee-cli --no-similarity --json --analysis "$f" 2>/dev/null | jq -r --arg f "$f" '
.analysis.runtimes[] | select(.key == "go") | .views[] | select(.key == "analysis.go.build")
| ([.table.rows[] | {(.cells[0]): .cells[1]}] | add) as $b | "\($f)\t\($b.GOOS)/\($b.GOARCH)\t\($b["-buildmode"])"'
done
# Every -X variable injected at link time
ppee-cli --json --analysis f.exe | jq -r '.analysis.runtimes[] | select(.key == "go") | .views[] | select(.key == "analysis.go.build")
| .table.rows[] | select(.cells[0] == "-ldflags") | .cells[1]' | grep -oE '\-X \\*"[^"]+' | sed 's/-X \\*"//; s/\\*$//'
# The non-library packages
ppee-cli --json --analysis f.exe | jq -r '.analysis.runtimes[] | select(.key == "go") | .views[] | select(.key == "analysis.go.packages")
| .table.rows[] | .cells[0] | select(test("^(main|[a-z0-9.-]+\\.[a-z]+/)"))' | grep -v '^golang.org/'
Strings in Go code¶
Go strings have no NUL: literals sit back to back, and the code passes a pointer and a length. Two things in PPEE read them that way:
disassembleshows alea's string at the length that follows it ("APPDATA", not"APPDATAAppDataAvestan…"), so a function such asmain.initreads as the strings it builds.read_byteswithas: "go_strings"follows a table of{ptr, len}headers, a[]stringsuch as a blocklist of sandbox host names, and returns every string in one call.
Related: Analysis overview · --analysis · Strings · Export directory (_cgo_dummy_export)
References¶
- runtime/debug.BuildInfo (Go): the build information Go embeds in every binary.
- Go documentation: the language and toolchain.
