When to use / when not to use
- Use: after confirming it is a Go-compiled binary (
fileoutputs Go BuildID / section table contains.gopclntab), leverage the fact that Go retains symbols by default to recover logic; identify the Go version and build information; analyze the control flow of multi-goroutine programs; recover symbols after strip - Use: malicious Go samples ([[re-malware]] static recovery—Go malware often carries plaintext C2 configuration and buildinfo)
- Don't use: non-Go native programs (C/C++ go directly to [[re-binary-core]]; Rust goes to [[re-rust]])
- Don't use: if confirmed packed, first go to [[re-anti-analysis]] (after a Go binary is packed, the pclntab offsets become invalid; unpack first, then return to this skill)
- Note: dynamic steps default to sandbox ([[re-analyze/platform-tips]] highest principle); Go binaries are large, so prioritize static analysis (large sample principle)
Tool preparation
Refer to [[re-analyze/platform-tips]]—Go binaries usually start at 1MB (runtime takes the bulk), follow "static first (large samples)"; dynamic (dlv/gdb running the sample) defaults to the sandbox.
Go toolchain (go version / go tool buildid / go version -m)
- Debian/Ubuntu:
apt install golang-go(official package; the Debian version may lag—thego version -mbuildinfo fingerprint requires Go 1.18+; if the tool is too old, use the official tarball) - Fedora:
dnf install golang;Arch:pacman -S go - macOS:
brew install go;Windows:choco install golangor the official installer - Verify:
go version;go tool buildid samplecan output the build ID (non-Go programs will error)
Ghidra (built-in Go support) and Go plugins
- Ghidra 11.2+ has a built-in Go analyzer (identifies Go binaries, recovers symbols from pclntab, annotates runtime types); for installation see [[re-ghidra]]
- IDA-side plugin (optional): GitHub
0xjiayu/go_parser—IDA Pro's Go parsing script, recovers types and structs - Verify: after importing into Ghidra, the function tree shows
main.*/runtime.*naming
GoReSym (symbol recovery after strip, optional but key)
- No official distro package → official install (requires Go toolchain):
go install github.com/mandiant/GoReSym@latest - Usage:
GoReSym -t -d -p sample > gosyms.json(-t types, -d standard library packages, -p file path) - Import into decompiler:
goresym_rename.py(GoReSym official repo script, imports into Ghidra/IDA, merged in PR #11) - Verify:
GoReSym -aboutoutputs about and license information; the output JSON contains function names/addresses
redress (Go binary metadata, optional)
- No official distro package → official install:
go install github.com/goretk/redress@latest - Usage:
redress info sample(compiler version/GoRoot/main package path),redress packages sample --std --vendor(package list),redress source sample(source tree projection),redress types struct sample --methods(types) - Verify:
redress version
garble — Go obfuscation identification (common in malicious samples, no standalone tool)
- garble is a community-maintained third-party Go obfuscator (a build-time transformation, not a runtime packer; author mvdan, not an official Go project)—Go malicious samples are increasingly common, so identify before analyzing
- Identification features: ① strings/section table occasionally show
garblemarker (build info is stripped by default, not necessarily leftover) ② garble builds have no DWARF by default (readelf -Shas no.debug_*) ③ function names are hashed (main.main→ short hash names, pclntab is retained but the names are unreadable) ④-literalsBuild string garbled - Common build flags:
garble -literals build(string obfuscation; debug removal is default behavior and requires no flag; there is also-tinyremoves location information/strips panic prints and symbol names, size about -15%) - See step 3 for analysis points (hash function names / lazy string decryption / interface method names retained)
bloaty (size analysis, optional)
- Debian/Ubuntu: no official package → GitHub
google/bloatyrelease or source build (cmake+ninja, requires protobuf) - Fedora:
dnf install bloaty;Arch:pacman -S bloaty;macOS:brew install bloaty - Windows: official release (or the Linux version inside WSL)
- Verify:
bloaty --version; usage:bloaty -d symbols sample
Operating steps
Execute in order, recording evidence at each step (path + sha256, see [[re-triage]]).
-
Identify Go binaries:
file sample # 含 "Go BuildID=..." 即 Go(通常静态链接) readelf -S sample | grep -i go # .go.buildinfo / .gopclntab / .gosymtab nm sample | grep -E 'main\.main|runtime\.main' # 符号存在性.gopclntabis the function table (containing function names/addresses/line numbers); the section name is the basis for identification- Confirm symbol retention status:
nmempty → pitfall 1 (strip)
-
Go version fingerprint:
go tool buildid sample # build ID(go build 唯一标识,一直可用) go version -m sample # Go 1.18+:Go 版本 + module 路径 + 依赖列表 strings sample | grep -E '^go1\.[0-9]+' # 旧版兜底:runtime.buildVersion- The version determines the pclntab layout and how GoReSym/redress parse it; no buildinfo → pitfall 5
go version -m's module path directly gives the project name/dependencies (malicious samples often leak the C2 framework); note it down first- pclntab magic directly determines the version range:
.gopclntabfirst 4 bytes0xfffffffb= Go 1.2–1.15、0xfffffffa= 1.16–1.17、0xfffffff0= 1.18–1.19、0xfffffff1= 1.20+ (for the header layout see step 6; tools such as GoReSym scan by these four values)
-
Symbol table exploitation:
- Tool selection (target → tool):
- Recover function names (symbols after strip) → GoReSym:
GoReSym -p sample > gosyms.json→goresym_rename.pyimport into a decompiler to restore naming - Type recovery (struct fields/interfaces/method signatures) → redress:
redress types struct sample --methods(or GoReSym-toutput type information) - Version identification → buildinfo (
go version -m/go tool buildid) + pclntab magic (step 2) — no symbol tool needed
- Recover function names (symbols after strip) → GoReSym:
- Go retains symbols by default (unless
-ldflags "-s -w"):nm sample | grep ' main\.'lists user code functions - After Ghidra imports, the function tree is grouped by package; note that the ELF entry point
_rt0_amd64_linuxis only a platform stub, and the real user entry ismain.main(called byruntime.main),main.initis the initialization logic - Decompile
main.mainto read the main logic, and expand along the call chain from functions namedmain.; when all symbols are retained, there is no need to guess names - garble-obfuscated samples(common in malicious Go samples, see tool preparation): pclntab can still be parsed but function names are hashed — GoReSym lists hash names, while structures/call chains are still usable;
-literalsstrings at their point of use go through a top-level decoding functionLazy decryption(only strings that are reached are decoded) — after triggering use, take the plaintext from memory, or statically restore the decoding function (GoStringUngarbler can automate this);interface method names (exported) are not obfuscated by garble(to preserve itab dispatch), so interface calls can directly locate implementations by method name; what is hashed is unexported method names
- Tool selection (target → tool):
-
String table:
strings -n 6 sample | grep -E '^main\.|/.*\.go' # 函数名与源文件路径 strings sample | grep -E 'go1\.|module |\.go$'- pclntab contains function names and source file paths,
stringsis directly visible — first collectmain.Naming is the call relationship draft - Source paths reveal project layout (e.g.,
/home/user/c2/agent/main.go) - String cross-references in Ghidra (Ctrl+Shift+F) trace back to use sites (corresponds to the [[re-binary-core]] R2 method)
- pclntab contains function names and source file paths,
-
goroutine/scheduler understanding:
gokeyword expands in decompilation toruntime.newproccall (internally callsnewproc1), and the address its funcval parameter points to is the entry function of that goroutine- After the goroutine entry function returns, it goes to
runtime.goexit;runtime.gopark/runtime.gosched/runtime.scheduleare scheduling and suspension points—when you see them, do not treat them as the main flow - Each function prologue's
runtime.morestackcall is just a stack growth check; ignore it (noise) - Dynamic (inside sandbox): dlv is the most Go-friendly—
go install github.com/go-delve/delve/cmd/dlv@latest,dlv attach <pid>thengoroutineslists all goroutines,goroutine <n> stackto view the stack; [[re-tracing]]'s strace observes concurrent system calls
-
Runtime structure key points (pclntab / goroutine stack / buildinfo):
- pclntab header (1.20+ layout): magic(4) + pad1 + pad2 + minLC + ptrSize + nfunc(8) + nfiles(8) + reserved(8) + funcnameOffset(8) + cuOffset(8) + filetabOffset(8) + pctabOffset(8) + pclnOffset(8); funcnameOffset points to the function name string pool, pctab is the PC→line number table—the pcHeader of 1.18/1.19 and 1.20+ is the same 72 bytes (debug/gosym ver118/ver120 use the same parsing branch; textStart is written with the real address via relocation, and from Go 1.26 onward it is always 0 (no longer stored); starting from 1.20, symbol names get a colon prefix
go.→go:); the truly shorter header is in ≤1.17 (ver116 has no textStart field); 1.18's Go118PCLnTabMagic changed the functab offset (func data entry changed from address to offset), and tools dispatch by magic - goroutine stack: the first field of the g structure (runtime-private) is
stack{lo, hi}i.e., the stack interval,stackguard0is the stack growth check threshold—this is what each function prologue'sruntime.morestackchecks (source of noise),sched.gobufsaves the sp/pc when suspended; dlv'sgoroutines/goroutine <n> stackoutput comes from these fields, and the suspension point resumes atruntime.goparkresumes - buildinfo manual parsing:
.go.buildinfohas embedded\xff Go buildinf:(14 bytes) + ptrSize(1) + byte order marker(1), 16-byte aligned;go version -mwhen invalid, extract version and module information by this magic number (same source as go tool; only if decoding fails is it truly absent) - Platform differences: Windows Go artifacts are PE but section names and symbol system are the same (
.gopclntab/.go.buildinfoare both present), nm/GoReSym/redress on Linux can directly analyze PE Go samples; entry stub symbols vary by platform (Linux_rt0_amd64_linux、Windows_rt0_amd64_windows、macOS_rt0_amd64_darwin), they all just jump to runtime.main
- pclntab header (1.20+ layout): magic(4) + pad1 + pad2 + minLC + ptrSize + nfunc(8) + nfiles(8) + reserved(8) + funcnameOffset(8) + cuOffset(8) + filetabOffset(8) + pctabOffset(8) + pclnOffset(8); funcnameOffset points to the function name string pool, pctab is the PC→line number table—the pcHeader of 1.18/1.19 and 1.20+ is the same 72 bytes (debug/gosym ver118/ver120 use the same parsing branch; textStart is written with the real address via relocation, and from Go 1.26 onward it is always 0 (no longer stored); starting from 1.20, symbol names get a colon prefix
Cross-domain combination
- [[re-binary-core]]: This skill is referenced by the core gateway—initial survey [[re-triage]], formats [[re-format-elf]]/[[re-format-pe]], decompilation foundation [[re-ghidra]]/[[re-ida]], library fingerprints [[re-imports]]
- [[re-ghidra]] / [[re-ida]]: Import Go symbol/type recovery artifacts (GoReSym JSON, redress types) into the decompiler
- Dynamic side: [[re-gdb]]/[[re-tracing]] inside the sandbox; for debugging Go programs, dlv (goroutine-aware) is preferred
- Malicious scenarios: Go malicious samples (C2, loader) statically restored according to this skill → behavior analysis shifts to [[re-malware]]
- [[re-analyze/platform-tips]] Related branches: static-first (large samples), dynamic default sandbox, cross-platform Go samples (Windows PE Go on Linux goes through the Wine branch)
Common pitfalls and traps
- After strip, all symbols are lost and hard to recover: Symptom—
nmno output, all Ghidra functionssub_*, cannot findmain.main; Cause—-ldflags "-s -w"or the release process strips symtab/DWARF (the pclntab data is still there, just no symbols reference it); Countermeasure—GoReSym/redress recover function names/addresses from.gopclntab(if section location fails, scan by pclntab magic bytes) →goresym_rename.pyimport into Ghidra/IDA; obfuscation such as garble obscures function names (the pclntab structure is preserved and function boundaries can still be recovered, but the names are meaningless) → requires GoResolver/GoStringUngarbler, and if necessary fall back to strings + dynamic behavior analysis - The binary is huge, with runtime taking up the bulk: Symptom—Ghidra imports tens of thousands of functions, auto-analysis lags,
runtime.*drowns out the target; Cause—Go statically links the runtime (scheduling/GC/memory management) + per-function morestack stack check, usually taking 1-2MB+; Countermeasure—after symbol recovery, filter first:nm | grep ' main\.'、GoReSym JSONgrep '^main\.'narrow down to user code; in Ghidra, decompile only target functions on demand; follow platform-tips "static-first (large samples)"—locate statically first, supplement dynamically as needed - Identifying goroutine entry points: Symptom—decompilation shows no linear main flow, only
runtime.gopark/schedulescheduling calls; Cause—user code runs concurrently as multiple goroutines (gokeyword/runtime.newprocderivation), with no single main path, and execution order is determined by the scheduler; Countermeasure—read the funcval argument at theruntime.newproccall site to get the entry address; after the entry returns, it goes throughruntime.goexit; dynamically use dlv (inside the sandbox)goroutinesto list and inspect each goroutine's stack - Escape analysis affects stack layout: Symptom—local variables in the source are not on the stack, but become
runtime.newobject+ heap pointers; Cause—the Go compiler's escape analysis moves escaping objects to the heap (managed by GC), so the stack frame does not contain the expected local variables; Countermeasure—do not look for local variables from the stack frame in C style; track heap pointers and types returned by newobject (GoReSym-t/redresstypesrecovers type structures), and read object fields according to the recovered type layout - The buildinfo version fingerprint has hurdles: Symptom—
go version -merrors out or has no output, and the Go version cannot be determined; Cause—buildinfo (.go.buildinfo) is only embedded by default since Go 1.18, and older or trimmed binaries do not have it; Countermeasure—go tool buildidis always available (gets the build ID); for older versions, look atstrings | grep '^go1\.'in theruntime.buildVersion;redress info, which can also infer the compiler version from the pclntab layout - Custom structured serialized data (gob): Symptom—the program's data section is a "byte stream" but decompilation shows it is decoded with
encoding/gob; Cause—the author uses gob to encode a custom structure (config/VM program/embedded object), and inLoadProgram-type functions,gob.NewDecoder+Decodeis the entry point; Countermeasure—rebuild the struct from the decompilation (field names/field counts extracted from the type definitions in the gob stream),use Go to generate a sample with the same structure and compare bytes(Compare the type definition section byte by byte; field names/types/counts are clear at a glance.) When field names are corrupted (missing bytes due to materials/encoding), gob reports an out-of-bounds error according to the field number. You can modify the local gob source code to perform lenient decoding (skipping field number checks) to get closer to the data. - LD_PRELOAD is usually ineffective against pure Go syscall paths: Symptom—hooking libc functions (execve/openat) fails to intercept calls made by Go programs; Cause—pure Go (
CGO_ENABLED=0) binaries call syscalls directly, without going through libc symbols, and have noPT_INTERPnor a dynamic section, soLD_PRELOADhas no way to interpose; Countermeasure—first determine the binary form:readelf -l/-d/-Ws、lddcheckPT_INTERP、DT_NEEDEDand cgo traces—for pure Go, use strace/eBPF/ptrace (observe real syscalls or hook at a lower level); when cgo or dynamic ELF symbols are present,LD_PRELOADcan still interpose normally (e.g., cgo binariesDT_NEEDED libc.so.6, hooks can hit; see the next item "cgo mixed binaries"); alternatively, hook child processes—C programs such as bash/ls forked by Go'sexec.Commandstill go through libc and can be intercepted byLD_PRELOAD. - Runtime behavior replay (fake server): Symptom—Go malware has complex logic but requires network interaction (IRC/HTTP server) to trigger; Cause—the program is a client (beacon/C2 bot), and offline static analysis cannot see the full behavior; Countermeasure—fake server replay: DNS hook (hijack the domain via getaddrinfo → 127.0.0.1) + fake IRC/HTTP server (replay the real interaction sequence extracted from the pcap), letting the program run through its own logic chain; pay attention to handshake timing (wait for the bot to finish JOINing before sending messages) and message format (the bot validates messages by name/channel).
- Garble-obfuscated Go binary: Symptom—stripped + random function names + all strings empty; Cause—Garble obfuscates both function names and strings; Countermeasure—GoReSym recovers pclntab (function boundaries are unaffected by name obfuscation) → GoResolver CFG signatures recover standard library names → GoStringUngarbler decrypts strings in batches → find the C2/key from the decrypted strings.
- Locating Go encryption keys: Symptom—the AES key cannot be found; Cause—the key is assembled at runtime from multiple constants, and the common entry point is an AES-GCM wrapper rather than direct
aes.NewCipher; Countermeasure—trace the source of the key parameter ofcrypto/aes.NewCipherand backtrack to the assembly point; note that the parameters ofcrypto/cipher.NewGCM(andNewGCMWithNonceSize/NewGCMWithTagSize/NewGCMWithRandomNonce) arecipher.Blocknot the key, so if you first locate these wrappers, you need to continue backtracking theirBlocktoaes.NewCipherand then obtain the key. - Go interface calls are hard to understand: Symptom—decompiled interface calls are indirect jumps; Cause—Go interfaces are dispatched through itab; Countermeasure—locate the itab table and manually annotate the interface types.
- Symbols available but signatures missing: Symptom—function names are present, but parameters/types are not; Cause—newer Go binaries do not carry complete symbol information; Countermeasure—accept that function names are available and reconstruct signatures from other evidence (configuration deserialization types/call sites).
- Strings are very noisy: Symptom—recovered strings are mixed with many standard library constants; Cause—Go standard library string constants; Countermeasure—filter by package level first, then screen business strings.
- Source code reconstruction principles: Symptom — line-by-line reconstruction is unrealistic; Cause — the artifact has no source correspondence; Countermeasure — rebuild readable code by package, preserving logic rather than line-by-line consistency (logic first)
- cgo mixed artifact, the C part has no Go naming: Symptom —
nmcontains a batch of bare-name symbols (printf、pthread_*), inconsistent with themain./runtime.style; Cause — cgo links C code/static libraries directly into the Go binary, and C symbols do not participate in the Go naming system; Countermeasure — split by naming style: the Go side (main./package name.) goes through this skill, while bare-name symbols on the C side go through [[re-binary-core]] ([[re-imports]] library fingerprinting); cgo blocks usually only do thin wrapping, and the main logic is still on the Go side - Direct static analysis of a Windows PE Go sample on Linux: Symptom —
filereports PE, and after importing into Ghidra the entry is not_rt0_amd64_linux, leading one to mistakenly think a Windows environment is needed; Cause — Go artifacts have a consistent cross-platform structure, only the container format (PE/Mach-O/ELF) differs; Countermeasure — use nm/GoReSym/redress as usual (the symbol table and pclntab are platform-independent), and recognize the entry by platform (on the Windows side, the PE entry points to the_rt0_amd64_windowsstub); only the dynamic side requires Windows/Wine ([[re-analyze/platform-tips]] branch) (Source: reverse-skill field-journal, MIT)