冬日故事:改进GDB Python API的消息与类型 - Trail of Bits博客
Matheus Branco Borella, 圣保罗大学
2023年4月18日
实习项目
作为Trail of Bits的冬季实习生,我的目标是对GNU项目调试器(GDB)进行两项改进:提升运行速度并优化其Python API,以支持并增强依赖它的工具(如Pwndbg)。主要目标是通过并行运行符号解析来更好地利用所有可用的CPU核心。最终我实现了三项增强GDB Python API的更改。
除了实际代码之外,我还学习了如何在GDB中上游化补丁。这个过程可能需要一段时间,有一定的学习曲线,并且需要与项目维护者进行大量来回沟通。我将在本文中讨论这一点,您也可以跟随我的工作进展,因为我的工作仍在GDB补丁邮件列表中进行讨论。
为什么让GDB更快?
GDB有三种从程序加载DWARF符号的方式:
- 部分符号表加载器:索引加载器负责加载符号名称并将其连接到各自的编译单元(CU),将解析和构建符号表的工作留给完整加载器。只有在需要符号的完整信息时才会进行解析。
- 完整符号表加载器:通过解析CU并根据需要构建符号表来完成索引加载器留下的工作。此加载器完全解析文件中的DWARF信息并将其存储在内存中。
- 索引解析器:ELF可以有一个特殊的.gdb_index部分,通过–gdb-index链接器标志或GDB提供的gdb-add-index工具添加。该工具存储内部符号表的索引,使GDB能够跳过索引构建过程,显著减少在GDB中加载二进制文件所需的时间。
最初的想法是将Meta的开源调试器drgn中的并行解析方法移植到GDB。并行解析已经为索引加载器实现,只剩下完整加载器和索引解析器作为并行化的潜在候选者。
您可以将GDB的解析例程视为在每个CU基础上拆分为并发任务,因为它们已经按每个CU顺序调用一次。然而,这种理解存在一个主要问题:尽管数据在表面上分离,但并未完全分离为完全读写、部分读写(具有隐式同步)和只读数据。解析子例程完全期望所有这些数据结构至少在一定程度上是可读写的。
虽然解决大多数这些问题只需将值拆分为单独的读写副本(每个线程拥有一个),但像注册表、缓存,特别是obstack这样的东西很难迁移到并发模型。
什么是obstack?
通用分配(如malloc())非常耗时。当用户需要尽可能快地分配许多小对象时,它们可能效率不高,因为它们在每个分配中存储元数据。
于是有了分配栈。每个新对象按顺序在顶部分配并从顶部释放。GNU Obstack是这种分配器的一个实现,在GDB中大量使用。每个合理长生命周期的容器对象,包括objfile和gdbarch,都有自己的obstack实例,用于保存它引用的对象,并在对象本身释放时一次性释放它们。
如果您了解对象生命周期跟踪——无论是动态的(如std::shared_ptr)还是静态的(如Rust中的引用)——最后一段听起来会很熟悉。根据obstack分配在GDB中的使用方式,有人可能会假设有一种方法可以保证对象的生命周期与拥有它们的容器一样长。
在与IRC和邮件列表中的其他人讨论后,我得出两个结论:调查需要相当长的时间,我应该优先处理Python API,以便有机会按时完成改进。最终,我将大部分时间花在了这些可实现的目标上。
GDB对象的__repr__方法
第一个更改相当简单。它为GDB Python API中的几种类型添加了__repr__()实现。这一更改使得我们在Python REPL中检查类型时获得的消息更能说明这些类型代表什么。
以前,我们会得到这样的信息,几乎毫无帮助(注意:pi是运行Python REPL的GDB命令):
|
|
现在,我们可以得到以下信息,告诉我们这是什么类型的类型及其名称,而不是对象在内存中的位置:
|
|
这也适用于gdb.Architecture、gdb.Block、gdb.Breakpoint、gdb.BreakpointLocation和gdb.Symbol。
这帮助我理解了GDB如何与Python接口以及Python C API的一般工作原理。它使我能够在以后添加自己的函数和类型。
类型来了!
第二个更改增加了从Python API创建类型的能力,而以前只能使用gdb.lookup_type()查询现有类型。现在您可以直接创建GDB支持的任何原始类型,如果您正在处理代码但没有其符号,或者正在编写插件帮助人们处理此类代码,这将非常方便。来自奇怪额外二进制文件的类型不需要申请!
GDB支持相当多的类型。所有类型都可以直接使用gdb.init_type或专门的gdb.init_*_type函数创建,这些函数允许您指定与正在创建的类型相关的参数。大多数函数的工作方式类似,除了gdb.init_float_type,它有自己的新gdb.FloatFormat类型。这使您可以指定要创建的浮点类型在内存中的布局。
此更改带来的一个额外考虑是这些新类型的内存究竟来自哪里。由于这些函数基于GDB内部已有的函数,并且这些函数使用给定objfile的obstack,因此obstack是这些分配的内存源。这有一个很大的优势:引用这些类型并属于同一objfile的对象保证永远不会比它们存活更久。
您可能已经意识到这种方法的一个显著缺点:当Python运行时释放类型时,分配在obstack上的任何类型很可能不在栈顶。因此,无论它们的实际生命周期要求如何,类型只能与拥有它们的objfile一起释放。主要含义是,不可达类型将在objfile的生命周期内泄漏其内存。
手动跟踪类型的初始化需要对现有类型对象基础设施进行更深入的更改。对于第一个补丁来说,这太雄心勃勃了。
以下是此方法的一些示例:
|
|
这将创建一个名为“long short int”的新24位整数类型:
|
|
这将创建一个新的浮点类型,让人想起标准x86机器中可用的类型。
符号呢?
第三个更改增加了注册三种符号的能力:类型、goto标签和静态变量。这使得添加新符号变得更加容易,特别是在逆向工程且没有任何原始符号时非常有用。没有此补丁,添加新符号的主要方法涉及将它们添加到单独的文件中,将文件编译到目标架构,并在加载基础程序后使用add-symbol-file命令将其加载到GDB中。
GDB的内部符号基础设施大多不适用于动态添加。让我们看看GDB如何创建、存储和查找符号。
GDB中的符号通过称为compunit_symtab的结构深处的指针找到。这些结构通过一个构建器设置,允许在构建过程中将符号添加到表中。此构建器后来负责将新结构注册到(在此补丁的情况下)拥有它的objfile。在objfile的情况下,这些表存储在一个列表中,在查找期间——不考虑符号查找缓存——会遍历该列表,直到在其中一个表中找到符合给定要求的符号。
目前,表没有设置成在构建后可以随意向表中添加符号。因此,如果我们不想在第一个补丁之前对GDB进行深度更改,我们必须找到一种方法来绕过这个限制。我最终采用的方法是构建一个新的符号表,并将其连接到列表的末尾以获取每个新符号。尽管这是一种相当低效的方法,但足以使功能正常工作。
随着此补丁继续上游化,我旨在改进实现此功能的机制。
最后,我想展示一个创建新类型并将其注册为符号以供将来查找的示例:
|
|
全部合并
总体而言,这个冬天在Trail of Bits产生了更信息丰富的消息,以及能够在GDB的Python API中创建支持的类型,这在您没有正在处理的代码的符号时非常有用。
GDB在处理贡献方面非常老派。其维护者使用电子邮件提交、测试和评论补丁,然后才上游化。这通常意味着在提交补丁时需要遵循非常严格的礼仪。
作为一个从未处理过基于电子邮件的项目的人,我第一次尝试提交补丁很糟糕。我拼凑了一个包含git diff输出的文本文件,然后手动编写了整个消息,然后通过一个处理非UNIX行结尾很差的客户端发送。这造成了一场混乱,可以理解的是,邮件列表中没有维护者愿意修补和测试。不过,他们很好心地告诉我应该使用Git的内置电子邮件功能:git send-email直接发送。
在那次事件之后,我花时间将我的更改拆分到适当的分支中,并重新设置基础,以便它们全部压缩为每个主要更改的单个提交。这创建了更合理和描述性的消息,涵盖了整个更改,并且更适合与git send-email一起使用。自那以后,事情进展得相当顺利,尽管为了让我所有的更改都进入,进行了大量的来回沟通。
虽然这三个更改已经提交,但实现__repr__()的更改在流水线中更靠后,而其他两个仍在等待审核。请密切关注它们!
如果您喜欢这篇文章,请分享:
Twitter LinkedIn GitHub Mastodon Hacker News