Athena Wiki

Reversing Go Binaries

reversingintermediate

Recover symbols, decode the Go calling convention, and tame runtime noise when reversing stripped Go binaries for CTF.

reversinggolanggogoresympclntabmoduledataghidraida

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.so to leak from; ldd usually 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, and runtime.gcWriteBarrier everywhere.
  • 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 metadata

Filter to the author's code — anything under main. or a non-standard module path:

jq -r '.UserFunctions[] | "\(.Start)  \(.FullName)"' syms.json | head

The pclntab and moduledata

These two structures are the backbone of symbol recovery, so it is worth knowing what they are.

Mermaid diagram
  • pclntab maps every code address to a function name, source file, and line number — built so the runtime can produce stack traces and panic messages. Recovering it gives names like main.validateInput.
  • moduledata is the root descriptor the runtime keeps per linked module. It points at the pclntab, the type table, the text segment bounds, and string/symbol tables. Tools locate moduledata (often via the runtime.moduledataverify / runtime.modulesinit signature) 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 .rodata blob with no separators. strings shows 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/mov of an address plus an immediate length into the second register/slot).
  • Tools like golang_loader_assist and 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 context

When 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 gopclntab

Recover symbols

GoReSym -t -d -p ./chal > syms.json
jq -r '.UserFunctions[] | "\(.Start) \(.FullName)"' syms.json
redress pkg ./chal          # cross-check the package list

Load 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 morestack prologue and all runtime.* / reflect.* noise
  • Treat strings as ptr+len — slice the exact length, never to a null byte
  • Focus on main.*; follow runtime.newproc to find goroutine bodies
  • Sanity-check with go tool objdump -s 'main\.'

See Also


Last updated on

On this page