riven-labs/unstrip
GitHub: riven-labs/unstrip
unstrip:从Go二进制文件中恢复符号信息,支持多种逆向工程工具。
Stars: 10 | Forks: 2
# unstrip
[](https://crates.io/crates/unstrip)
[](https://github.com/riven-labs/unstrip/actions/workflows/ci.yml)
[](#license)
[](https://www.rust-lang.org)
[](https://go.dev)
[](#install)
Go的二进制文件仍然携带`pclntab`、moduledata、typelinks和itablinks,因为运行时需要它们来生成堆栈跟踪和反射。`unstrip`读取所有这些内容:每个带有文件和行的函数,每个带有其Go语法签名的函数(链接器保留了类型记录),每个带有完整结构布局的类型,每个调度器使用的`(interface, concrete)`对,以及模块依赖树。ELF、Mach-O、PE。amd64和arm64。Go 1.18至1.25。
## 安装
```
cargo install unstrip
```
Linux、macOS和Windows的预构建二进制文件可在[发行版页面](https://github.com/riven-labs/unstrip/releases)找到。
## 快速开始
```
unstrip ./bin # function names, files, lines
unstrip ./bin --info # container, Go version, garble heuristic
unstrip ./bin --format ghidra > apply.py # Python script for Ghidra Script Manager
```
`unstrip -h`打印一屏速查表。`unstrip --help`按部分列出每个标志。每个标志一个段落的全参考文档位于[docs/USAGE.md](./docs/USAGE.md)。
## 为什么unstrip
恢复符号附加的方法签名。Go链接器为每个发出`_type`记录的类型上的方法都附加了一个Go语法签名,例如`(_0 []uint8) (int, error)`,并将其附加到默认列表、`--addr`查找和Ghidra/IDA/Binja导出器注释中的名称中。其他Go二进制符号恢复工具根本不恢复签名。
内联调用堆栈,而不仅仅是叶函数。`--addr`返回`FUNCDATA_InlTree`中的完整内联树,因此PC落在内联代码中解析为导致该PC的链,每个帧都有文件和行。
通过`--format`内置Ghidra、IDA和Binary Ninja Python导出器,带有C结构声明和正确的字段偏移。一个Rust二进制文件为所有三个RE工具生成脚本;`--install-plugin `将包装器插件放入用户的插件目录。
`--data-at `通过恢复的itab表和函数表解释数据地址处的字节。Iface头解析为具体类型加上调度方法体。切片头展开为ptr/len/cap与数据指针符号化。字符串头打印一个引用预览。
`--xref `列出所有针对函数的调用站点,在amd64和arm64二进制文件中一样。直接CALL/BL覆盖率在两者上都很稳定。通过itab方法槽的间接调度与直接调用一起报告,并带有内联解析的itab和方法名称。
1.4 MiB的静态Rust二进制文件,无运行时依赖。Go 1.18至1.25,ELF + Mach-O + PE,amd64 + arm64,PIE + non-PIE,一个工具。
## 与其他工具比较
与GoReSym、gore和redress的类别比较位于[COMPARISON.md](./COMPARISON.md)。标题:类似的核心功能恢复;工具在它们包含的类型目录中有所不同,在它们提供的RE工具集成中有所不同,以及它们如何处理混淆的二进制文件。
以下是一个简短的验证表。更长的差异和“何时使用哪个”指南在COMPARISON.md中。
| 功能 | unstrip | GoReSym | redress | gore |
|-----------------------------------------|------------------|------------|----------|----------|
| CLI vs library | CLI | CLI | CLI | library |
| Go 1.18 through 1.25 | yes | yes | yes | yes |
| Pre-Go-1.18 | no | Go 1.2+ | Go 1.5+ | Go 1.5+ |
| Method-signature recovery | yes | no | no | no |
| Ghidra + IDA + Binja exporters | yes | yes | no | no |
| Inlined call stacks on PC lookup | yes | unverified | no | no |
| `--data-at` symbolic data inspection | yes | no | no | no |
| `--xref` on amd64 and arm64 | yes | n/a | n/a | n/a |
| Static binary, no runtime deps | yes (Rust) | yes (Go) | yes (Go) | n/a |
## 使用
```
unstrip ./samples/hello.stripped
```
函数名称、源文件、行号,每行一个函数。二进制文件携带`_type`记录的类型上的方法会附加其Go语法签名:
```
$ unstrip ./crypto-pipeline.stripped
0x00000000004b3f20 main.(*HashingReader).Read main.go
0x00000000004b4000 main.(*CountingWriter).Write(_0 []uint8) (int, error) (itab thunk) main.go
0x00000000004b4280 main.(*AesGcm).Encrypt(_0 []uint8) ([]uint8, error) (itab thunk) main.go
0x00000000004b4820 main.(*Pipeline).ProcessFrame main.go
0x00000000004b4a40 main.(*Pipeline).StreamThrough main.go
0x0000000000465b40 errors.(*errorString).Error() string errors/errors.go
0x0000000000479e00 os.(*File).Write(_0 []uint8) (int, error) os/file.go
0x00000000004b4d40 main.main main.go
...
```
参数名称不在二进制文件中;位置占位符(`_0`、`_1`、...)保持形状正确。覆盖范围和预期:
- **实现接口的方法**(`CountingWriter.Write`、`AesGcm.Encrypt`、`errorString.Error`、`os.File.Write`以上):签名恢复可靠。`(itab thunk)`标记显示iface调度路径;该行的签名来自接口声明的funcType。
- **仅通过内部调用可达的类型上的方法**(`HashingReader.Read`、`Pipeline.ProcessFrame`、`Pipeline.StreamThrough`以上):Go链接器可能省略了类型的`_type`记录,因为没有运行时路径需要它。函数名称从`pclntab`恢复;签名不在二进制文件中。unstrip为这些打印裸名称。
- **自由顶级函数**(`main.main`、`main.NewAesGcm`等.):一开始就没有每个函数的签名记录。裸名称。
使用`--no-signatures`获取较旧的较短的列表。
### 容器、版本、混淆检查
```
$ unstrip hello.stripped --info
go version: go1.22.2
container: ELF (amd64, little-endian)
pclntab: 0x00000000000c0c40 (405 KB)
functions: 1556
text start: 0x0000000000401000
ptr size: 8
quantum: 1
```
当二进制文件被混淆时,`--info`会指出这一点,而不是假装符号是真实的:
```
$ unstrip suspicious.bin --info
go version: (not detected)
...
garble heuristic: likely garbled
- pclntab magic is not the standard 0xfffffff1 (garble rewrites it)
- runtime.buildVersion is missing or non-standard (garble overwrites it)
- 554/4310 user-package function names look hashed
```
对于结构化判断和退出代码语义(0混淆,1干净,2不确定),请使用`--detect-garble`。
### 模块依赖树
```
$ unstrip ./samples/myapp --buildinfo
go version: go1.22.2
path: example.com/myapp
main module: example.com/myapp v1.4.2
dependencies (12)
github.com/spf13/cobra v1.8.0
github.com/aws/aws-sdk-go-v2 v1.30.5
github.com/jmespath/go-jmespath v0.4.0
...
build settings
buildmode exe
GOOS linux
vcs git
vcs.revision 3f8a912...
vcs.modified false
```
### 类型
从剥离的二进制文件,没有调试信息,没有安装SDK:
```
$ unstrip ./samples/myapp --types --filter cobra.Command
0x00000000005c01a0 struct size=728 *cobra.Command
+0000 Use: type@0x589ec0
+0010 Aliases: type@0x588960
+0028 SuggestFor: type@0x588960
+0040 Short: type@0x589ec0
+0050 GroupID: type@0x589ec0
...
+02c8 TraverseChildren: type@0x58a2c0
+02c9 Hidden: type@0x58a2c0
+02ca SilenceErrors: type@0x58a2c0
+02d0 SuggestionsMinimumDistance: type@0x58a100
```
完整的结构布局,包括未导出字段。字段类型通过地址交叉引用,以便您可以导航类型图。JSON输出为工具连接。
### 接口
运行时用于调度的`(interface, concrete)`对,您的动态调度站点实际上会到达:
```
$ unstrip ./samples/myapp --itabs --filter Writer
0x00000000005a18c8 *io.Writer => *os.File
0x00000000005a1948 *io.Writer => *bytes.Buffer
0x00000000005a19a8 *io.Writer => *gzip.Writer
0x00000000005a1a08 *io.StringWriter => *bufio.Writer
```
### 反向PC查找,带有内联调用堆栈
用于调试器内部、崩溃转储解析器或反汇编注释生成器。当PC落在内联代码中时,您会从内联树中获得完整的调用堆栈,而不仅仅是叶函数:
```
$ unstrip caddy --addr 0x4151a4
(inlined) runtime.evacuated /usr/lib/go-1.22/src/runtime/map.go:205
(physical) runtime.mapaccess2 /usr/lib/go-1.22/src/runtime/map.go:457
```
对于针对崩溃转储的批量查找,请使用`--addr-file`。一次解析,N次查找:
```
$ cat crash.txt | unstrip suspicious.bin --addr-file -
(inlined) io.copyBuffer /usr/lib/go-1.22/src/io/io.go:418
(physical) io.Copy /usr/lib/go-1.22/src/io/io.go:388
0x000000000045a200 net.(*conn).Read /usr/lib/go-1.22/src/net/net.go:179
0x00000000004b8c40 main.handleClient /tmp/build/main.go:42
```
如果运行时地址来自ASLR重定位的过程,请传递`--rebase `(实际加载基与链接时间首选基之间的差异)并让unstrip将其转换回磁盘上的链接时间VA。
### 应用到您选择的RE工具
`--format ida`、`--format ghidra`或`--format binja`生成一个Python脚本,在工具内部运行,将每个恢复的函数(以文件:行作为函数注释)**以及每个恢复的结构类型**作为C声明,该工具的解析器可以理解:
```
$ unstrip ./samples/myapp --format ghidra > apply.py
# 在 Ghidra 中:窗口 -> 脚本管理器 -> 运行 apply.py
```
函数名称净化到有效的反汇编器标签;原始Go名称(带有括号、方括号、泛型参数)作为函数注释保留。
对于菜单项安装而不是每次都运行脚本:
```
unstrip --install-plugin ida # ~/.idapro/plugins/, registers as Edit -> Plugins -> Load Go symbols (unstrip), Ctrl-Shift-G
unstrip --install-plugin ghidra # ~/ghidra_scripts/, run from Script Manager
```
Binary Ninja安装程序(`--install-plugin binja`)将包装器放入Binary Ninja插件目录并在“插件”->“加载Go符号”(unstrip)下注册菜单项。
### 将符号嵌入到可运行的二进制文件中
`--symbols-as elf`将新的ELF写入一个具有填充的`.symtab`+`.strtab`,该`.symtab`+`.strtab`由恢复的函数构建。结果是输入的严格超集,但仍可运行,但`nm`、`gdb`、`objdump --syms`、`perf`、`addr2line`、eBPF堆栈跟踪和`delve`都可以看到名称。
```
$ nm helm
nm: helm: no symbols
$ unstrip --symbols-as elf -o helm.symbols helm
wrote 75630 symbols to helm.symbols
$ nm helm.symbols | head -3
0000000000401000 T fatalf
00000000004012b0 T _cgo_get_context_function
00000000004011e0 T _cgo_set_stacklo
$ gdb -q helm.symbols -ex 'info functions main.main' -ex quit
0x00000000027f50e0 main.main
0x00000000027f53a0 main.main.func1
```
使用`--in-place --yes`覆盖输入文件。今天仅支持ELF64小端;Mach-O和PE重写将在稍后推出。
### 在构建之间传输符号
`--diff `是一个名称端口辅助工具,而不是结构化二进制差异。它在两个构建之间配对函数,在两个步骤中:首先通过精确地址匹配(`identical`),然后通过新地址上的精确Go符号名称(`renamed`)。其余一切均归入`added`或`removed`。它不哈希基本块,指纹控制流图,或跟踪函数跨内联、拆分或编译器驱动的重命名。如果一个函数被内联掉,被工具链重命名或重构,此工具将报告它为`removed`加`added`而不是匹配它。
对于在优化和内联更改之间进行真实结构差异,请使用BinDiff或Diaphora。`--diff`在这里存在,用于狭窄但常见的场景,即您在反汇编器中注释了v1.0,v1.1刚刚发布,并且您希望已恢复的符号名称落在新构建的明显对应物上:
```
$ unstrip ./malware-v1.1.bin --diff ./malware-v1.0.bin
old: 4687 functions
new: 4823 functions
identical: 4421
renamed: 198
moved addr: 12 (same address, different name)
added: 192 (in new, not in old)
removed: 68 (in old, not in new)
```
与`--port-symbols ida|ghidra|binja`配对以生成一个脚本,该脚本使用旧二进制文件中的任何名称重命名新二进制文件中的每个配对函数:
```
$ unstrip ./malware-v1.1.bin --diff ./malware-v1.0.bin --port-symbols ghidra > port.py
# 在 Ghidra(已加载 v1.1):脚本管理器 -> port.py
```
### 交叉引用和调用图
`--xrefs`扫描`.text`中的直接CALL/BL目标,并将每个对与恢复的函数集进行解析。结合`--from`、`--to`、`--depth`或`--callgraph`,它回答分析师打开IDA时提出的问题:
```
$ unstrip ./helm --xrefs --from main.main --depth 1 | head
main.main -> github.com/spf13/cobra.(*Command).ExecuteC
main.main -> main.newRootCmd
main.main -> main.warning
main.main -> os.Exit
$ unstrip ./helm --xrefs --to crypto/rsa.SignPSS
crypto/tls.(*signOpts).Sign -> crypto/rsa.SignPSS
$ unstrip ./helm --xrefs --callgraph > graph.dot && dot -Tsvg graph.dot -o graph.svg
```
在65 MiB helm二进制文件上,250,000+调用者-调用者边在不到300毫秒内枚举。仅直接调用;通过itab方法槽的虚拟调度在`--itabs`中报告。
### 通过符号进行目标化xref
`--xref `回答“谁调用此”,范围限定于一个目标。当您只有一个问题时,比构建整个图更快,并且按调用者分组,以便输出按人类阅读xrefs的方式阅读:
```
$ unstrip ./helm --xref runtime.mallocgc | head
runtime.makechan @ 0x000000000040a260:
0x000000000040a2ee direct
0x000000000040a340 direct
0x000000000040a380 direct
runtime.convT @ 0x000000000040fb00:
0x000000000040fb29 direct
```
接受函数名称(通过pclntab解析)或十六进制地址(`0x...`)。当目标是接口方法实现时,会报告形式为`CALL [rip+itab+slot]`的间接调度站点,与直接调用一起,并带有内联解析的itab和方法名称。编译器通常首先将槽指针加载到寄存器中;这些寄存器间接站点不会显示在这里,因为解析它们需要基本块跟踪,我们故意不提供。在实际中,间接-itab扫描器在手动编写的汇编和异常代码生成中找到匹配项;请勿期望从标准Go编译器输出中获得零命中。严格编码的命中通常没有零误报。
amd64和arm64。直接-CALL覆盖率在两者上都很稳定;arm64上的间接-itab捕获了规范的对`LDR Xt, [Xn, #slot]; BLR Xt`对,但错过了编译器发出的其他调度模式,等待真正的反汇编器集成。
### Goroutines和deferred calls
`--goroutines`列出`.text`中的每个`runtime.newproc`和`runtime.deferproc`调用站点,并在可能的情况下解析goroutine或deferred call将运行的目标函数。揭示了Go程序中隐藏在`go func() { ... }`和`defer`后面的控制流,而其他工具则不提供:
```
$ unstrip ./sliver-client --goroutines | head
0x0000000000424798 runtime.newproc -> runtime.runFinalizers (0x424880) runtime/mfinal.go:169
0x00000000004259c0 runtime.newproc -> (target unresolved) runtime/mgc.go:209
0x00000000004479d5 runtime.newproc -> runtime.forcegchelper (0x447a00) runtime/proc.go:360
0x000000000045e480 runtime.newproc -> runtime.ensureSigM.func1 (0x4767e0) runtime/signal_unix.go:1061
0x000000000049c4ae runtime.newproc -> (target unresolved) sync/waitgroup.go:235
```
当目标解析(在真实二进制文件中的40-50%的站点)时,您可以看到goroutine运行的确实是哪个函数。当LEA模式不匹配启发式方法(funcval来自寄存器或堆栈槽)时,调用站点和源文件:行仍然显示,以便您可以打开源文件。仅限amd64和arm64。
### Ghidra的调度解析器
`--dispatch-resolver ghidra`生成一个Ghidra Python脚本,该脚本嵌入恢复的itab调度表,并在调用虚拟调用站点时打印包含解引用槽的每个`(interface, concrete impl)`对:
```
$ unstrip ./target --dispatch-resolver ghidra > unstrip_dispatch.py
# 在 Ghidra 内部:脚本管理器 -> 添加 unstrip_dispatch.py
# 将光标放在 `CALL qword ptr [reg + 0x18]` 并运行操作。
# 输出(控制台):
# unstrip 分发:寻找槽位 3(偏移 0x18)的方法 itabs
# io.Writer => *os.File :: Write -> 0x4b9020
# io.Writer => *bytes.Buffer :: Write -> 0x4c1a40
# io.Writer => *bufio.Writer :: Write -> 0x4cd900
# unstrip 分发:3 个候选
```
这将虚拟调度通过itab,这是剥离Go逆向工程的最差目标,变成了五秒钟的查找。槽偏移量启发式方法从调用的第二个操作数中提取整数标量;结合itab表unstrip已经恢复,您可以得到候选目标集,而无需重新运行任何分析。今天仅限amd64;arm64 BLR/BR-through-register支持很容易添加,一旦有了测试用例。
### 功能
`--capabilities`将恢复的类型、itab和函数名称与 curated 规则集进行匹配,并报告二进制文件似乎执行的操作。这是对“这是什么?”的第一个答案:
```
$ unstrip ./sliver-client --capabilities
[crypto]
TLS client/server
X.509 / PKI
RSA, ECDSA, AES
[network]
HTTP server, HTTP client, gRPC, DNS, raw TCP, raw UDP
[offensive-hint]
shell command execution
raw socket / ICMP
Sliver C2 implant
[process]
child process spawn
syscall direct invocation
signal handling
[serialization]
JSON encoding
Protocol Buffers
```
规则涵盖了HTTP服务器和客户端、TLS、X.509、RSA/ECDSA/AES、AWS/GCP/Azure SDK、Kubernetes客户端、Docker/containerd、SQL/SQLite/Postgres/Redis/MongoDB、JSON/proto/YAML、文件和子进程操作、原始套接字以及已知的攻击框架(Sliver)。每个匹配项都包含最多五个证据字符串,显示哪些恢复的类型或函数触发了它。
### 三级提示
`--info`包括二进制文件中stdlib接口实现的计数。对于“这个二进制文件是否说HTTP、执行加密、写入文件?”的第一个问题很有用。
```
stdlib interface implementations:
*error 142
*io.Reader 47
*io.Writer 38
*http.Handler 14
*net.Conn 11
*crypto.Signer 3
```
(`--fingerprint --behavioral`存在,用于将计数器哈希到SHA-256;比`--fingerprint`更粗糙,但为了完整性而包含。)
### 内联调用图(库API)
对于想要消费Rust中unstrip的恢复的工具,`unstrip::inline`模块公开了编译器在`FUNCDATA_InlTree`中记录的内联调用图。每个物理函数一个`Node`;每个编译器在优化后保留的内联调用站点一个`Edge`:
```
use unstrip::gobin::GoBinary;
use unstrip::inline::{inline_callgraph, EdgeKind, NodeKind};
use unstrip::moduledata::ModuleData;
use unstrip::pclntab::Pclntab;
let bin = GoBinary::open("./target")?;
let md = ModuleData::locate(&bin)?;
let pcln = Pclntab::parse(&bin)?.with_gofunc(md.gofunc);
let graph = inline_callgraph(&bin, &pcln)?;
for e in &graph.edges {
if matches!(e.kind, EdgeKind::Inlined) {
let caller = graph.node(e.from).unwrap();
let callee = graph.node(e.to).unwrap();
println!("{} -> {} @ 0x{:x}", caller.name, callee.name, e.call_site);
}
}
```
`NodeKind::Physical`节点是真实的pclntab函数,其入口PC为`addr`。`NodeKind::AnonymousInline`节点携带其父PC加上内联树的起始行,其`addr`是一个合成的高位标记ID(`Node::is_anonymous_addr`),因此它们不能与真实的文本VA冲突。匿名内联记录出现在编译器保留了内联调用的拓扑结构,但二进制文件是用`-tiny`、`-ld
标签:amd64架构, Apache许可证, arm64架构, Binary Ninja, DNS解析, ELF文件, Ghidra, Go语言, Go逆向工程, IDA, Mach-O文件, PE文件, Rust语言, 二进制分析, 云安全运维, 具体类型, 分发器, 反射, 可视化界面, 堆栈跟踪, 开源项目, 接口, 方法签名, 日志审计, 模块依赖, 版本兼容性, 程序破解, 符号恢复, 类型布局, 类型记录, 类型链接, 软件安装, 逆向工具, 通知系统