迈向两全其美的二进制反汇编器 - Trail of Bits博客
去年冬天,我有幸作为研究生实习生加入Trail of Bits,在Peter Goodman和Artem Dinaburg的指导下工作。在实习期间,我开发了Dr. Disassembler——一个基于Datalog的透明可变二进制反汇编框架。虽然该项目仍在进行中,但本篇博客将介绍Dr. Disassembler设计背后的高层愿景,并讨论当前原型的关键实现决策。
引言
二进制反汇编异常困难。许多反汇编任务(如代码/数据区分和函数边界检测)是不可判定的,需要精细的启发式方法和算法来覆盖现实世界中各种二进制语义。理想的反汇编器应具备两个关键特性:(1)透明性,即其底层逻辑可访问和解释;(2)可变性,即允许临时交互和优化。遗憾的是,尽管现有反汇编工具众多,但没有一个同时具备透明性和可变性。
大多数现成反汇编器(如objdump、Dyninst、McSema和Angr)执行"一次性"反汇编,虽然其底层启发式方法和算法是开源的,但即使是最微小的更改(如启用某个启发式方法)也需要完全重新构建工具并重新生成反汇编结果。相反,流行的商业反汇编器如IDA Pro和Binary Ninja提供了丰富的用户插件接口,但这些工具几乎完全专有,无法全面评估其核心启发式方法和算法的不足之处。
背景:反汇编、Datalog和Dr. Lojekyll
反汇编是将二进制可执行文件从机器代码转换为人类可解释的汇编语言表示的过程。在软件安全领域,反汇编构成许多关键任务(如二进制分析、静态重写和逆向工程)的基础。
从高层次看,反汇编器首先解析二进制逻辑段以定位包含可执行代码的段,然后通过指令解码将机器代码转换为更高级的指令语义。这一过程使用两种策略之一:线性扫描或递归下降。
Datalog是逻辑编程语言家族中较受欢迎的成员。与围绕程序控制流和状态构建的命令式编程语言(如Python、Java、C和C++)不同,逻辑编程(如Prolog和Datalog)完全围绕逻辑语句构建。
Dr. Lojekyll是Trail of Bits新的Datalog编译器和执行引擎,也是Dr. Disassembler构建的基础。它采用发布/订阅模型,其中Dr. Lojekyll编译的程序"订阅"消息(如在地址X存在指令)。当接收到消息时,程序可以引入新消息或移除先前的消息。
介绍Dr. Disassembler
传统的"一次性"反汇编器在运行时执行分析,局限于从一开始获得的任何结果——即使是错误的结果。相反,Datalog使我们能够将所有分析移至反汇编后,从而简化即插即用的优化和追溯更新。
我们将透明可变反汇编的愿景实现为原型工具Dr. Disassembler。虽然Dr. Disassembler理论上可以使用任何Datalog引擎(如DDLog),但我们目前使用Trail of Bits自有的Dr. Lojekyll。
反汇编过程
Dr. Disassembler的反汇编工作流包含三个组件:(1)解析,(2)解码,和(3)后处理。在解析过程中,我们扫描二进制段以定位包含指令的段,以及任何可恢复的元数据(如入口点、符号和导入/导出/本地函数)。
恢复控制流
使用Datalog的一个优势是能够将复杂程序语义表达为一系列简单的递归关系。然而,在处理控制流时,纯递归方法通常会破坏某些分析(如函数边界检测):递归分析会跟随控制流到每个指令的目标,并从那里恢复分析。
为了统一递归和线性下降反汇编方法,我们开发了非控制流后继指令的概念:对于任何无条件转移跳转或返回指令,我们记录从该指令到下一个顺序指令的人工直落边。虽然这条边对实际程序没有影响,但它有效地编码了逻辑"下一个"指令,从而统一了我们的线性和递归分析。
后处理
在解析和解码的每个步骤中,我们将发现的任何有趣对象发布到Dr. Lojekyll数据库。这些核心对象——符号、段、函数、指令和转移——构成了我们启发式和递归分析的基础模块。
示例导出和查询
Dr. Disassembler通过专注于反汇编工件本身而非获取它们所需的大量步骤来简化二进制分析。本节重点介绍由Dr. Disassembler两个基本构造:“导出”(更改/移除事实的消息)和"查询"(检索有关事实的信息)促进的严格二进制分析任务的几个实现示例。
查询:将指令分组到函数中
给定任意函数地址FuncEA,此查询返回该函数中包含的所有指令地址。两个消息构成此查询:(1)function(u64 StartEA)和(2)instruction(u64 InsnEA, type Type, bytes Bytes)。
导出:主导无效指令的指令
此导出返回其控制流导致无效指令(即指令解码失败)的所有指令。这种启发式方法对于Dr. Disassembler过滤掉在解码每个可能的字节序列时不可避免地出现的许多"垃圾"指令序列至关重要。
导出:函数间填充
此导出返回作为函数间"填充"的所有指令地址(如不属于任何函数的NOP)。这里我们使用以下消息:(1)function,(2)section,(3)raw_transfer,和(4)basic_block(u64 BlockEA, u64 InsnEA)。识别函数间填充是优化函数-指令分组的关键步骤。
未来工作和扩展
我们的直接计划是将Dr. Disassembler扩展到完整的C++实现。除了提高工具性能外,我们预计这一转变将为二进制分析研究打开许多新大门:
- 简化的二进制分析平台
- 用于二进制分析和转换的GUI界面
- 暴露分析盲点
如果您对这些想法感兴趣,请随时与Trail of Bits的Stefan Nagy或Peter Goodman联系。
我们将在https://github.com/lifting-bits/dds发布Dr. Disassembler的Python原型实现,并提供本文的PDF版本。祝反汇编愉快!