Reversing Go Binaries
Recover symbols, decode the Go calling convention, and tame runtime noise when reversing stripped Go binaries for CTF.
Reversing Go Binaries
Open a Go binary in Ghidra for the first time and you will be greeted by thousands of functions, megabytes of code, and almost none of it is the challenge author's logic. Go statically links its entire runtime, garbage collector, and standard library into every executable. A "hello world" is 1.5 MB+. Your job is to separate the handful of main.* functions you care about from the ocean of runtime.* noise.
The good news: Go binaries are far more recoverable than they look. Go embeds rich metadata about its own functions and types so the runtime can do reflection, panics, and stack traces. That metadata survives stripping, and the right tools turn it back into symbols.
Even a strip-ed Go binary keeps the pclntab (program counter line table) and moduledata structures. Recovering them gives you back nearly every function name — this is the single most important fact in Go reversing.
Why Go Binaries Are Huge and Static
- Static linking by default. The Go toolchain links the runtime, scheduler, GC, and every imported package into one file. There is no
libgo.soto leak from;lddusually reports "not a dynamic executable". - The runtime is always present. Goroutine scheduling, channel operations, and GC write barriers generate code in nearly every function. Expect calls to
runtime.morestack_noctxt,runtime.newobject,runtime.mallocgc, andruntime.gcWriteBarriereverywhere. - Reflection metadata is embedded. To support
reflect, interfaces, and panics, the compiler emits type descriptors and a function table. This bloat is also your map back to symbols.
file ./chal
# ELF 64-bit LSB executable, x86-64, statically linked, stripped
# Quick "is this Go?" tells:
strings ./chal | grep -m1 "go1\." # e.g. go1.24.3 → build version
strings ./chal | grep -m1 "Go build ID"
readelf -S ./chal | grep -E "gopclntab|go.buildinfo"The .gopclntab section (or its in-memory equivalent located via moduledata) is the giveaway and the prize.
Symbol Recovery: The First Real Step
Never start reading a stripped Go binary cold. Recover symbols first.
GoReSym (Mandiant FLARE) parses pclntab and moduledata directly — exactly the way the Go runtime does — so it works even on stripped binaries and recovers function names, source paths, type metadata, and the build version. Recent releases parse layouts up to Go 1.24 and can even repair a tampered pclntab magic.
# Download a prebuilt binary from the Releases tab, or:
go install github.com/mandiant/GoReSym@latest
# Dump everything (functions, types, build info) as JSON:
GoReSym -t -d -p ./chal > syms.json
# -t recover type metadata
# -d include default/standard packages (omit to see only user code)
# -p include file path metadataFilter to the author's code — anything under main. or a non-standard module path:
jq -r '.UserFunctions[] | "\(.Start) \(.FullName)"' syms.json | headThe pclntab and moduledata
These two structures are the backbone of symbol recovery, so it is worth knowing what they are.
pclntabmaps every code address to a function name, source file, and line number — built so the runtime can produce stack traces andpanicmessages. Recovering it gives names likemain.validateInput.moduledatais the root descriptor the runtime keeps per linked module. It points at thepclntab, the type table, the text segment bounds, and string/symbol tables. Tools locatemoduledata(often via theruntime.moduledataverify/runtime.modulesinitsignature) and walk outward from there.
CTF and malware authors sometimes wipe or corrupt the .gopclntab magic to break naive parsers. GoReSym specifically detects and repairs this. If symbol recovery fails, check whether the magic (0xFFFFFFF1 for Go 1.20+, earlier values for older versions) was zeroed.
The Go Calling Convention (This Trips Everyone Up)
The single most important thing to get right when reading Go disassembly is which calling convention the binary uses, because it changed.
Since Go 1.17, the amd64 ABI ("ABIInternal") passes integer arguments and results in registers, in this order:
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11 (integer args, then results)
X0–X14 (float args/results)This looks much more like a normal C ABI, but note it does not match the System V order (RDI, RSI, RDX, ...). A decompiler that assumes System V will mislabel every argument. In Ghidra, set the calling convention on Go functions to the Go register convention (the GolangAnalyzer extension defines __golang/unknown_register profiles for this).
Other convention quirks that hold across versions:
- Multiple return values are normal (e.g.
(result, error)); they occupy successive registers/stack slots. - The receiver of a method is just the first argument.
- Every non-leaf function begins with a stack-growth check (
cmp rsp, [r14+0x10]; jbe runtime.morestack). Treat this prologue as boilerplate and skip past it.
Strings Are Not Null-Terminated
This is the second classic Go pitfall. A Go string is a two-word struct:
struct GoString {
char *ptr; // pointer to bytes — NOT null-terminated
int64 len; // explicit length
};Consequences for reversing:
- Many "strings" are packed back-to-back in one giant
.rodatablob with no separators.stringsshows them concatenated, e.g....flagcorrectwrongusage.... The length lives in the code, not the data. - A string load looks like loading a pointer and a length together (often
lea/movof an address plus an immediate length into the second register/slot). - Tools like
golang_loader_assistand the Ghidra extension specifically fix this: they read the length from the instruction and slice the correct substring.
# The naive way fails — everything runs together:
strings ./chal | grep -i flag
# Better: let a Go-aware tool extract length-correct strings
GoReSym -p ./chal | jq -r '.Files' # source paths sometimes leak the flag contextWhen you find the address of an interesting string in the decompiler, look at the adjacent immediate moved alongside it — that is the length. Slice exactly that many bytes; do not read to the next null byte.
Cutting Through Runtime Noise
Reproducible Workflow
Confirm it's Go and grab the version
file ./chal
strings ./chal | grep -m1 "go1\." # build version drives the ABI
readelf -S ./chal | grep gopclntabRecover symbols
GoReSym -t -d -p ./chal > syms.json
jq -r '.UserFunctions[] | "\(.Start) \(.FullName)"' syms.json
redress pkg ./chal # cross-check the package listLoad with a Go-aware disassembler
Ghidra 11+ with the GolangAnalyzer extension, or IDA with golang_loader_assist. Re-run auto-analysis so names and string lengths are applied.
Set the correct calling convention
Go 1.17+ → register ABI (RAX, RBX, RCX, RDI, RSI, ...). Older → stack-based. Fix this before trusting any decompiled argument.
Read main. and decode the check*
Navigate to main.main, follow into main.* helpers, and identify the comparison. Remember strings carry an explicit length — slice exactly.
Cross-check with the native disassembler
go tool objdump -s 'main\.' ./chal # Go's own disassembler, symbol-aware
go tool objdump -S ./chal # interleave source (if not stripped of it)go tool objdump understands Go's symbol naming and is an excellent sanity check against Ghidra/IDA output.
Checklist
-
file/strings | grep go1.→ confirm Go and read the build version - Recover symbols with GoReSym before reading anything
- Cross-check packages/types with redress
- Load in Ghidra (GolangAnalyzer ext) or IDA (
golang_loader_assist); re-analyze - Pick the right calling convention: register-based (Go 1.17+) vs stack-based (older)
- Skip the
morestackprologue and allruntime.*/reflect.*noise - Treat strings as ptr+len — slice the exact length, never to a null byte
- Focus on
main.*; followruntime.newprocto find goroutine bodies - Sanity-check with
go tool objdump -s 'main\.'
See Also
Last updated on