17 KiB
17 KiB
name, description, capabilities
| name | description | capabilities | |
|---|---|---|---|
| re-go | Go 二进制逆向:符号保留、字符串表、goroutine。触发词:Go、golang、Go二进制、goroutine、go build |
|
Go 二进制逆向(符号 / 字符串表 / goroutine)
何时使用 / 何时不用
- 用:确认是 Go 编译的二进制(
file输出 Go BuildID / 节表含.gopclntab)后,利用 Go 默认保留符号的特性还原逻辑;识别 Go 版本与构建信息;分析多 goroutine 程序的控制流;恢复 strip 后的 Go 符号 - 用:恶意 Go 样本(re-malware 静态还原——Go 恶意程序多带明文 C2 配置与 buildinfo)
- 不用:非 Go 原生程序(C/C++ 直接 re-binary-core;Rust 走 re-rust)
- 不用:确认带壳先走 re-anti-analysis(Go 二进制被加壳后 pclntab 偏移失效,脱壳后再回本技能)
- 注意:动态步骤默认沙箱(re-analyze/platform-tips 最高原则);Go 二进制体积大,静态优先(大型样本原则)
工具准备
参考 re-analyze/platform-tips——Go 二进制通常 1MB 起(runtime 占大头),遵循「静态优先(大型样本)」;动态(dlv/gdb 运行样本)默认进沙箱。
Go 工具链(go version / go tool buildid / go version -m)
- Debian/Ubuntu:
apt install golang-go(官方包;Debian 版可能滞后——go version -m的 buildinfo 指纹需 Go 1.18+,工具太旧可用官方 tarball) - Fedora:
dnf install golang;Arch:pacman -S go - macOS:
brew install go;Windows:choco install golang或官方安装器 - 验证:
go version;go tool buildid sample能输出 build ID(非 Go 程序会报错)
Ghidra(内置 Go 支持)与 Go 插件
- Ghidra 11.2+ 内置 Go 分析器(识别 Go 二进制、从 pclntab 恢复符号、runtime 类型标注),装法见 re-ghidra
- IDA 侧插件(可选): GitHub
0xjiayu/go_parser——IDA Pro 的 Go 解析脚本,恢复类型与结构体 - 验证: Ghidra 导入后函数树出现
main.*/runtime.*命名
GoReSym(strip 后符号恢复,可选但关键)
- 无发行版官方包 → 官方安装(需 Go 工具链):
go install github.com/mandiant/GoReSym@latest - 用法:
GoReSym -t -d -p sample > gosyms.json(-t 类型、-d 标准库包、-p 文件路径) - 导入反编译器:
goresym_rename.py(GoReSym 官方仓库脚本,Ghidra/IDA 导入,PR #11 已合入) - 验证:
GoReSym -about输出 about 与 license 信息;输出 JSON 含函数名/地址
redress(Go 二进制元数据,可选)
- 无发行版官方包 → 官方安装:
go install github.com/goretk/redress@latest - 用法:
redress info sample(编译器版本/GoRoot/main 包路径)、redress packages sample --std --vendor(包列表)、redress source sample(源码树投影)、redress types struct sample --methods(类型) - 验证:
redress version
garble —— Go 混淆识别(恶意样本常见,无独立工具)
- garble 是社区维护的第三方 Go 混淆器(构建期变换,非运行时壳;作者 mvdan,非 Go 官方项目)——Go 恶意样本越来越常见,需先识别再分析
- 识别特征:①字符串/节表偶见
garble标识(默认剥离构建信息,非必然残留)②garble 构建默认无 DWARF(readelf -S无.debug_*)③函数名 hash 化(main.main→ 短 hash 名,pclntab 保留但名字不可读)④-literals构建字符串乱码 - 常见构建标志:
garble -literals build(字符串混淆;去调试为默认行为无需标志;另有-tiny移除位置信息/剥离 panic 打印与符号名,体积约 -15%) - 分析要点见步骤 3(hash 函数名 / 字符串惰性解密 / interface 方法名保留)
bloaty(体积分析,可选)
- Debian/Ubuntu: 无官方包 → GitHub
google/bloatyrelease 或源码构建(cmake+ninja,需 protobuf) - Fedora:
dnf install bloaty;Arch:pacman -S bloaty;macOS:brew install bloaty - Windows: 官方 release(或 WSL 内 Linux 版)
- 验证:
bloaty --version;用法:bloaty -d symbols sample
操作步骤
按顺序执行,每步记录证据(路径 + sha256,见 re-triage)。
-
识别 Go 二进制:
file sample # 含 "Go BuildID=..." 即 Go(通常静态链接) readelf -S sample | grep -i go # .go.buildinfo / .gopclntab / .gosymtab nm sample | grep -E 'main\.main|runtime\.main' # 符号存在性.gopclntab是函数表(含函数名/地址/行号),节名即判定依据- 确认符号保留状态:
nm为空 → 坑 1(strip)
-
Go 版本指纹:
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- 版本决定 pclntab 布局与 GoReSym/redress 的解析方式;无 buildinfo → 坑 5
go version -m的 module 路径直接给出项目名/依赖(恶意样本常泄露 C2 框架),先记下来- pclntab magic 直接判版本段:
.gopclntab开头 4 字节0xfffffffb= Go 1.2–1.15、0xfffffffa= 1.16–1.17、0xfffffff0= 1.18–1.19、0xfffffff1= 1.20+(头部布局见步骤 6;GoReSym 等工具按这四个值扫描)
-
符号表利用:
- 工具选择(目标 → 工具):
- 恢复函数名(strip 后符号)→ GoReSym:
GoReSym -p sample > gosyms.json→goresym_rename.py导入反编译器恢复命名 - 类型恢复(结构体字段/接口/方法签名)→ redress:
redress types struct sample --methods(或 GoReSym-t输出类型信息) - 版本识别 → buildinfo(
go version -m/go tool buildid)+ pclntab magic(步骤 2)——不需要符号工具
- 恢复函数名(strip 后符号)→ GoReSym:
- Go 默认保留符号(除非
-ldflags "-s -w"):nm sample | grep ' main\.'列出用户代码函数 - Ghidra 导入后函数树按包分组;注意 ELF 入口点
_rt0_amd64_linux只是平台桩,真正用户入口是main.main(由runtime.main调用),main.init是初始化逻辑 - 反编译
main.main读主逻辑,从main.命名函数沿调用链展开;符号全保留时无需猜名 - garble 混淆样本(恶意 Go 样本常见,见工具准备):pclntab 仍可解析但函数名 hash 化——GoReSym 列出 hash 名,结构/调用链仍可用;
-literals字符串在使用点经顶层解码函数惰性解密(仅被触达的字符串才解码)——触发使用后内存取明文,或静态还原解码函数(GoStringUngarbler 可自动化);interface 方法名(导出)garble 不混淆(为保 itab 分派),接口调用可直接按方法名定位实现,被 hash 的是未导出方法名
- 工具选择(目标 → 工具):
-
字符串表:
strings -n 6 sample | grep -E '^main\.|/.*\.go' # 函数名与源文件路径 strings sample | grep -E 'go1\.|module |\.go$'- pclntab 内含函数名与源文件路径,
strings直接可见——先收集main.命名即调用关系草稿 - 源路径揭示项目布局(如
/home/user/c2/agent/main.go) - Ghidra 中字符串交叉引用(Ctrl+Shift+F)回溯使用点(对应 re-binary-core R2 方法)
- pclntab 内含函数名与源文件路径,
-
goroutine/调度器理解:
go关键字在反编译中展开为runtime.newproc调用(内部再调newproc1),其 funcval 参数指向的地址即该 goroutine 的入口函数- goroutine 入口函数返回后走
runtime.goexit;runtime.gopark/runtime.gosched/runtime.schedule是调度与挂起点——看到它们不要当作主流程 - 每个函数序言的
runtime.morestack调用只是栈增长检查,忽略(噪声) - 动态(沙箱内): dlv 对 Go 最友好——
go install github.com/go-delve/delve/cmd/dlv@latest,dlv attach <pid>后goroutines列出所有 goroutine、goroutine <n> stack看栈;re-tracing 的 strace 观察并发系统调用
-
运行时结构要点(pclntab / goroutine 栈 / buildinfo):
- pclntab 头(1.20+ 布局):magic(4) + pad1 + pad2 + minLC + ptrSize + nfunc(8) + nfiles(8) + 保留(8) + funcnameOffset(8) + cuOffset(8) + filetabOffset(8) + pctabOffset(8) + pclnOffset(8);funcnameOffset 指向函数名字符串池,pctab 是 PC→行号表——1.18/1.19 与 1.20+ 的 pcHeader 同为 72 字节(debug/gosym ver118/ver120 同一解析分支;textStart 经 relocation 写入真实地址、Go 1.26 起恒为 0(不再存储);1.20 起符号名加冒号
go.→go:);真正更短的头在 ≤1.17(ver116 无 textStart 字段);1.18 的 Go118PCLnTabMagic 变更了 functab 偏移(func 数据入口从地址改偏移),工具按 magic 分派 - goroutine 栈:g 结构(runtime 私有)首字段
stack{lo, hi}即栈区间,stackguard0是栈增长检查阈值——每个函数序言的runtime.morestack检查的是它(噪声来源),sched.gobuf保存挂起时的 sp/pc;dlv 的goroutines/goroutine <n> stack输出即来自这些字段,挂起点在runtime.gopark恢复 - buildinfo 手工解析:
.go.buildinfo内嵌\xff Go buildinf:(14 字节)+ ptrSize(1) + 字节序标记(1),16 字节对齐;go version -m失效时按此魔数提取版本与 module 信息(与 go tool 同源,解码失败才是真没有) - 平台差异:Windows Go 产物是 PE 但节名与符号体系相同(
.gopclntab/.go.buildinfo都在),nm/GoReSym/redress 在 Linux 上可直接分析 PE Go 样本;入口桩符号随平台(Linux_rt0_amd64_linux、Windows_rt0_amd64_windows、macOS_rt0_amd64_darwin),都只是跳到 runtime.main
- pclntab 头(1.20+ 布局):magic(4) + pad1 + pad2 + minLC + ptrSize + nfunc(8) + nfiles(8) + 保留(8) + funcnameOffset(8) + cuOffset(8) + filetabOffset(8) + pctabOffset(8) + pclnOffset(8);funcnameOffset 指向函数名字符串池,pctab 是 PC→行号表——1.18/1.19 与 1.20+ 的 pcHeader 同为 72 字节(debug/gosym ver118/ver120 同一解析分支;textStart 经 relocation 写入真实地址、Go 1.26 起恒为 0(不再存储);1.20 起符号名加冒号
跨域联合
- re-binary-core:本技能由 core 网关引用——初勘 re-triage、格式 re-format-elf/re-format-pe、反编译底座 re-ghidra/re-ida、库指纹 re-imports
- re-ghidra / re-ida:Go 符号/类型恢复产物(GoReSym JSON、redress types)导入反编译器
- 动态侧:沙箱内 re-gdb/re-tracing;Go 程序调试首选 dlv(goroutine 感知)
- 恶意场景:Go 恶意样本(C2、loader)静态按本技能还原 → 行为分析转 re-malware
- re-analyze/platform-tips 相关分支:静态优先(大型样本)、动态默认沙箱、跨平台 Go 样本(Windows PE Go 在 Linux 侧走 Wine 分支)
常见坑与陷阱
- strip 后符号全丢,恢复难:现象——
nm无输出、Ghidra 函数全sub_*、找不到main.main;原因——-ldflags "-s -w"或发布流程 strip 掉 symtab/DWARF(pclntab 数据仍在,只是没有符号引用它);对策——GoReSym/redress 从.gopclntab(节定位失败则按 pclntab magic 字节扫描)恢复函数名/地址 →goresym_rename.py导入 Ghidra/IDA;garble 等混淆会混淆函数名(pclntab 结构保留、函数边界仍可恢复,但名字无意义)→ 需 GoResolver/GoStringUngarbler 配合,必要时退化为 strings + 动态行为分析 - 二进制巨大,runtime 占大头:现象——Ghidra 导入几万函数、自动分析卡顿、
runtime.*淹没目标;原因——Go 静态链接 runtime(调度/GC/内存管理)+ 每函数 morestack 栈检查,通常占 1-2MB+;对策——符号恢复后先过滤:nm | grep ' main\.'、GoReSym JSONgrep '^main\.'缩小到用户代码;Ghidra 只对目标函数按需反编译;遵循 platform-tips「静态优先(大型样本)」——先静态定位、动态按需补充 - goroutine 入口识别:现象——反编译看不到线性主流程,只见
runtime.gopark/schedule调度调用;原因——用户代码以多个 goroutine 并发(go关键字/runtime.newproc派生),无单一主路径,执行顺序由调度器决定;对策——在runtime.newproc调用点读 funcval 参数得入口地址;入口返回后走runtime.goexit;动态用 dlv(沙箱内)goroutines列出并看各 goroutine 栈 - 逃逸分析影响栈布局:现象——源码里的局部变量不在栈上,变成
runtime.newobject+ 堆指针;原因——Go 编译器逃逸分析把逃逸对象移到堆(GC 管理),栈帧不含预期局部变量;对策——别按 C 风格从栈帧找局部变量;跟踪 newobject 返回的堆指针与类型(GoReSym-t/redresstypes恢复类型结构),对象字段按恢复的类型布局读取 - buildinfo 版本指纹有门槛:现象——
go version -m报错或无输出,无法确定 Go 版本;原因——buildinfo(.go.buildinfo)自 Go 1.18 才默认嵌入,旧版或被裁剪的二进制没有;对策——go tool buildid一直可用(拿 build ID);旧版看strings | grep '^go1\.'的runtime.buildVersion;redress info也能按 pclntab 布局推断编译器版本 - 自定义结构序列化数据(gob):现象——程序数据段是"字节流"但反编译显示用
encoding/gob解码;原因——作者用 gob 编码自定义结构(配置/VM 程序/内嵌对象),LoadProgram类函数里gob.NewDecoder+Decode就是入口;对策——从反编译重建结构体(字段名/字段数从 gob 流里的类型定义提取),用 Go 生成同结构样本对比字节(逐字节比对类型定义段,字段名/类型/数量一目了然),字段名损坏(材料/编码导致缺字节)时 gob 按字段号越界报错,可改本地 gob 源码做宽松解码(跳过字段号检查)逼近数据 - LD_PRELOAD 对纯 Go syscall 路径通常无效:现象——hook libc 函数(execve/openat)拦截不到 Go 程序的调用;原因——纯 Go(
CGO_ENABLED=0)产物直接 syscall、不经过 libc 符号,无PT_INTERP也无 dynamic section,LD_PRELOAD无从 interpose;对策——先判定产物形态:readelf -l/-d/-Ws、ldd看PT_INTERP、DT_NEEDED与 cgo 痕迹——纯 Go 走 strace/eBPF/ptrace(观察真实 syscall 或 hook 更底层),存在 cgo 或动态 ELF 符号时LD_PRELOAD仍可正常 interpose(如 cgo 产物DT_NEEDED libc.so.6,hook 可命中,见下条「cgo 混合产物」);另可 hook 子进程——Go 的exec.Commandfork 出的 bash/ls 等 C 程序仍走 libc,可被LD_PRELOAD拦截 - 运行时行为重放(伪服务端):现象——Go 恶意程序逻辑复杂但需要网络交互(IRC/HTTP 服务端)才能触发;原因——程序是客户端(信标/C2 bot),离线静态分析看不到完整行为;对策——伪造服务端重放:DNS hook(getaddrinfo 劫持域名→127.0.0.1)+ 假 IRC/HTTP 服务器(按 pcap 提取的真实交互序列重放),让程序自己跑完逻辑链;注意握手时序(等 bot JOIN 完成再发消息)与消息格式(bot 按名字/频道校验消息)
- Garble 混淆的 Go 二进制:现象——stripped + 随机函数名 + 字符串全空;原因——Garble 同时混淆函数名与字符串;对策——GoReSym 恢复 pclntab(函数边界不受名字混淆影响)→ GoResolver CFG 签名恢复标准库名 → GoStringUngarbler 批量解密字符串 → 从解密串找 C2/密钥
- Go 加密密钥定位:现象——找不到 AES 密钥;原因——密钥运行时从多个常量拼接,且常见入口是 AES-GCM 封装而非直接的
aes.NewCipher;对策——跟踪crypto/aes.NewCipher的 key 参数来源、回溯拼接点;注意crypto/cipher.NewGCM(及NewGCMWithNonceSize/NewGCMWithTagSize/NewGCMWithRandomNonce)的参数是cipher.Block不是密钥,若先定位到这些封装,需继续回溯其Block到aes.NewCipher再取 key - Go 接口调用看不懂:现象——反编译的 interface 调用是间接跳转;原因——Go interface 经 itab 分派;对策——定位 itab 表手动标注接口类型
- 符号可用但签名缺失:现象——函数名有、参数/类型没有;原因——新版本 Go 产物不带完整符号信息;对策——接受函数名可用、签名靠其他证据(配置反序列化类型/调用点)重建
- 字符串噪声大:现象——恢复的字符串混入大量标准库常量;原因——Go 标准库字符串常量;对策——按包级别过滤后再筛业务字符串
- 源码重建原则:现象——逐行还原不现实;原因——产物无源码对应;对策——按包重建可读代码、保留逻辑而非逐行一致(逻辑优先)
- cgo 混合产物,C 部分无 Go 命名:现象——
nm里混着一批裸名符号(printf、pthread_*),与main./runtime.风格不一致;原因——cgo 把 C 代码/静态库直接链接进 Go 二进制,C 符号不参与 Go 命名体系;对策——按命名风格分片:Go 侧(main./包名.)走本技能,C 侧裸名符号走 re-binary-core(re-imports 库指纹);cgo 块通常只做薄封装,主逻辑仍在 Go 侧 - Windows PE Go 样本在 Linux 上直接静态分析:现象——
file报 PE、Ghidra 导入后入口不是_rt0_amd64_linux,误以为需要 Windows 环境;原因——Go 产物跨平台结构一致,只是容器格式(PE/Mach-O/ELF)不同;对策——nm/GoReSym/redress 照常用(符号表与 pclntab 与平台无关),入口按平台认(Windows 侧 PE 入口指向_rt0_amd64_windows桩);动态侧才需要 Windows/Wine(re-analyze/platform-tips 分支) (来源:reverse-skill field-journal,MIT)