SuperAPI AI Directory

re-go

Security & auditing

View repository

Updated 10-16 05:06

About this skill

Go binary reverse engineering (symbols / string table / goroutine)

When to use / when not to use

  • Use: after confirming it is a Go-compiled binary (file outputs 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 -m buildinfo 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 golang or the official installer
  • Verify: go version;go tool buildid sample can 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 -about outputs 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 garble marker (build info is stripped by default, not necessarily leftover) ② garble builds have no DWARF by default (readelf -S has no .debug_*) ③ function names are hashed (main.main → short hash names, pclntab is retained but the names are unreadable) ④-literals Build string garbled
  • Common build flags:garble -literals build(string obfuscation; debug removal is default behavior and requires no flag; there is also -tiny removes 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/bloaty release 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]]).

  1. 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'   # 符号存在性
    
    • .gopclntab is the function table (containing function names/addresses/line numbers); the section name is the basis for identification
    • Confirm symbol retention status:nm empty → pitfall 1 (strip)
  2. 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:.gopclntab first 4 bytes 0xfffffffb = 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)
  3. Symbol table exploitation:

    • Tool selection (target → tool):
      • Recover function names (symbols after strip) → GoReSym:GoReSym -p sample > gosyms.json → goresym_rename.py import into a decompiler to restore naming
      • Type recovery (struct fields/interfaces/method signatures) → redress:redress types struct sample --methods(or GoReSym -t output type information)
      • Version identification → buildinfo (go version -m / go tool buildid) + pclntab magic (step 2) — no symbol tool needed
    • 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_linux is only a platform stub, and the real user entry is main.main(called by runtime.main ),main.init is the initialization logic
    • Decompile main.main to read the main logic, and expand along the call chain from functions named main. ; 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;-literals strings 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
  4. 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,strings is directly visible — first collect main. 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)
  5. goroutine/scheduler understanding:

    • go keyword expands in decompilation to runtime.newproc call (internally calls newproc1), 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.schedule are scheduling and suspension points—when you see them, do not treat them as the main flow
    • Each function prologue's runtime.morestack call 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> then goroutines lists all goroutines,goroutine <n> stack to view the stack; [[re-tracing]]'s strace observes concurrent system calls
  6. 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,stackguard0 is the stack growth check threshold—this is what each function prologue's runtime.morestack checks (source of noise),sched.gobuf saves the sp/pc when suspended; dlv's goroutines/goroutine <n> stack output comes from these fields, and the suspension point resumes at runtime.gopark resumes
    • buildinfo manual parsing:.go.buildinfo has embedded \xff Go buildinf:(14 bytes) + ptrSize(1) + byte order marker(1), 16-byte aligned;go version -m when 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.buildinfo are 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

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—nm no output, all Ghidra functions sub_*, cannot find main.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.py import 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 JSON grep '^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/schedule scheduling calls; Cause—user code runs concurrently as multiple goroutines (go keyword/runtime.newproc derivation), with no single main path, and execution order is determined by the scheduler; Countermeasure—read the funcval argument at the runtime.newproc call site to get the entry address; after the entry returns, it goes through runtime.goexit; dynamically use dlv (inside the sandbox)goroutines to 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/redress types recovers type structures), and read object fields according to the recovered type layout
  • The buildinfo version fingerprint has hurdles: Symptom—go version -m errors 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 buildid is always available (gets the build ID); for older versions, look at strings | grep '^go1\.' in the runtime.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 + Decode is 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 no PT_INTERP nor a dynamic section, soLD_PRELOAD has no way to interpose; Countermeasure—first determine the binary form:readelf -l/-d/-Ws、ldd check PT_INTERP、DT_NEEDED and 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_PRELOAD can still interpose normally (e.g., cgo binaries DT_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's exec.Command still go through libc and can be intercepted by LD_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 of crypto/aes.NewCipher and backtrack to the assembly point; note that the parameters of crypto/cipher.NewGCM(and NewGCMWithNonceSize/NewGCMWithTagSize/NewGCMWithRandomNonce) are cipher.Block not the key, so if you first locate these wrappers, you need to continue backtracking their Block to aes.NewCipher and 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 —nm contains a batch of bare-name symbols (printf、pthread_*), inconsistent with the main./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 —file reports 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_windows stub); only the dynamic side requires Windows/Wine ([[re-analyze/platform-tips]] branch) (Source: reverse-skill field-journal, MIT)