“Unstripping” binaries: 用Pwndbg在GDB中恢复调试信息
当调试缺乏调试符号(也称为“剥离的二进制文件”)的二进制文件时,GDB会失去大量功能。函数和变量名变成无意义的地址;设置断点需要从外部源追踪相关函数地址;打印结构化值需要盯着内存转储手动识别字段边界。
这就是今年夏天在Trail of Bits,我扩展了Pwndbg——一个由我的导师Dominik Czarnota维护的GDB插件——添加了两个新功能,使剥离调试体验更接近您在IDE调试器中的预期。Pwndbg现在集成了Binary Ninja以增强GDB+Pwndbg的智能,并支持转储Go结构以改进Go二进制调试。
Binary Ninja集成
为了在调试期间提高GDB+Pwndbg的智能,我将Pwndbg与Binary Ninja(一个具有多功能脚本API的流行反编译器)集成,通过在Binary Ninja内部安装XML-RPC服务器,然后从Pwndbg查询它。这使得Pwndbg能够访问Binary Ninja的分析数据库,用于同步符号、函数签名、栈变量偏移等,恢复大部分调试体验。
图1:Pwndbg显示从Binary Ninja同步的符号和参数名称在一个剥离的二进制文件中
对于反编译,我从Binary Ninja拉取令牌,而不是先将它们序列化为文本。这允许完全语法高亮的反编译,可配置为使用Binary Ninja的3个IL级别中的任何一个。反编译直接显示在Pwndbg上下文中,当前行高亮,就像在汇编视图中一样。
图2:从Binary Ninja拉取的反编译并显示在Pwndbg中
我还实现了一个功能,在Binary Ninja内部将当前程序计数器(PC)寄存器显示为箭头,以及一个从Binary Ninja内部设置断点的功能,以减少在Pwndbg之间切换的次数。
图3:Binary Ninja显示当前PC和断点的图标
集成中最复杂的组件是同步栈变量名称。在Pwndbg中任何出现栈地址的地方,比如在寄存器视图、栈视图或函数参数预览中,集成将检查它是否是Binary Ninja中的命名栈变量。如果是,它将显示正确的标签。它甚至会检查父栈帧,以便调用者的变量仍然被正确标记。
图4:栈变量标签显示方式的演示
实现此功能的主要困难在于Binary Ninja仅将栈变量提供为从栈帧基址的偏移,因此需要推断帧基址以计算绝对地址。大多数架构,如x86,有一个指向帧基址的帧指针寄存器,但大多数架构,包括x86,实际上不需要帧指针,因此编译器可以自由地像使用任何其他寄存器一样使用它。
幸运的是,Binary Ninja具有常量值传播,因此它可以判断寄存器是否是帧基址的可预测偏移。因此,我的实现将首先检查帧指针是否实际上是帧基址,如果不是,它将查看栈指针是否推进了可预测的量(现代编译器通常如此);否则,它将检查每个其他通用寄存器,尝试找到一个具有一致偏移的寄存器。从技术上讲,这种方法不会一直有效,但在实践中,它几乎永远不会失败。
Go调试
调试从非C编程语言(有时甚至是C)编译的可执行文件时,一个常见的痛点在于它们往往具有复杂的内存布局,使得转储值变得困难。一个良性的例子是在Go中转储切片,这需要一个命令来转储指针和长度,另一个命令来检查切片内容。另一方面,转储映射对于小映射可能需要超过十个命令,对于较大的映射可能需要数百个,这对人类来说完全不切实际。
这就是我创建go-dump
命令的原因。使用Go编译器的源代码作为参考,我实现了对所有Go内置类型的转储,包括整数、字符串、复数、指针、切片、数组和映射。内置类型的表示方式与在Go中相同,因此您不需要学习任何新语法来正确使用该命令。
图5:使用go-dump命令转储一个简单的映射类型
go-dump
命令还能够解析和转储任意嵌套的类型,以便每个类型都可以仅用一个命令转储。
图6:使用go-dump命令转储一个更复杂的映射类型切片
解析Go的运行时类型
虽然特定于Go的转储比手动内存转储好得多,但它仍然带来许多可用性问题。您需要知道要转储的值的完整类型,这可能很难确定,通常涉及大量猜测,特别是在处理具有许多字段或嵌套结构体的结构体时。即使您已经推断出完整类型,有些东西仍然是不可知的,因为它们对编译没有影响,比如结构体字段名称和用户定义类型的类型名称。
方便的是,Go编译器为程序中使用的每个类型发出一个运行时类型对象(与reflect包一起使用),其中包含任意嵌套结构体的结构布局、类型名称、大小、对齐等。这些类型对象也可以与该类型的值匹配,因为接口值存储一个指向类型对象的指针以及一个指向数据的指针,并且堆分配的值将其类型对象传递到它们的分配函数中(通常是runtime.newobject
)。
我编写了一个能够递归提取此信息的解析器,以便处理任意嵌套类型的类型信息。此解析器通过go-type
命令公开,该命令显示给定其地址的运行时类型的信息。对于结构体,此信息包括每个字段的类型、名称和偏移。
图7:检查一个由int和string组成的结构体类型
这可以用于以两种方式转储值。第一种更简单的方法仅适用于接口值,因为类型指针与数据指针一起存储,使得自动检索变得容易。这些可以使用Go的any
类型转储空接口(没有方法的接口),以及接口类型转储非空接口。转储时,命令将自动检索和解析类型,从而实现无缝转储,无需输入任何类型信息。
图8:在不指定任何类型信息的情况下转储接口值
第二种方法适用于所有值,但需要您找到并指定值的类型指针。在许多情况下,这就像查找传递到分配值的函数中的指针一样容易,但对于全局变量或分配可能难以找到的变量,找到类型可能涉及一些猜测。然而,这种方法通常仍然比尝试手动推断类型布局更容易,并且能够转储甚至最复杂的类型。我在Go编译器的一个剥离构建中测试了它,这是最大和最复杂的开源Go代码库之一,它能够毫无问题地转储所有类型。
图9:仅指定类型地址转储Go编译器中的复杂结构,使用-p标志进行漂亮打印
回顾与展望
今年夏天,我增强了Pwndbg,使其可以与Binary Ninja集成以访问其丰富的调试信息。我还添加了go-dump
命令用于转储Go值。所有这些都可在Pwndbg开发分支及其最新版本(2024.08.29)中获得。
展望未来,还有更多可以做的事情来改善调试体验。我以模块化设计开发了我的Binary Ninja集成,以便将来易于添加对更多反编译器的支持。我认为完全支持Ghidra(当前集成仅同步反编译)将非常棒,因为Ghidra是一个免费开源的反编译器,使所有想要使用该功能的人都可以访问。
在Go调试方面,可以开展工作以添加更好的支持来显示和处理goroutine,这目前是Delve调试器(专门用于调试Go的调试器)相对于GDB/Pwndbg的主要优势之一。例如,Delve能够列出每个goroutine和创建它们的指令,并且它还有一个命令可以在goroutine之间切换。
致谢
今年夏天在Trail of Bits工作是一次绝对惊人的经历,我要感谢他们给我机会在Pwndbg上工作。特别感谢我的经理Dominik Czarnota,他在审查我的代码和给我的工作提供反馈和想法方面非常及时,以及Pwndbg社区,他们在开发过程中回答我遇到的任何问题方面提供了极大的帮助。