Skip to content

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.

Linux screenshot: Go Summary

Go Summary

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\" …"
One field gave the protocol (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:

$ ppee-cli --callees func:main.main 847d8f49….exe
Callees of func:main.main:
  0x140135460  main.main
      0x1400072E0  runtime.mapassign_fast64
      …
      0x14006E8A0  syscall.Syscall
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:

  • disassemble shows a lea's string at the length that follows it ("APPDATA", not "APPDATAAppDataAvestan…"), so a function such as main.init reads as the strings it builds.
  • read_bytes with as: "go_strings" follows a table of {ptr, len} headers, a []string such 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