使用Binary Ninja进行反向污点分析:自动化程序崩溃溯源

本文介绍了如何利用Binary Ninja的API实现反向污点分析技术,通过SSA形式的MLIL中间语言自动追踪程序崩溃根源,大幅减少手动调试工作量,并开源了配套工具链KRFAnalysis。

反向污点分析使用Binary Ninja - The Trail of Bits博客

Henry Wildermuth, Horace Mann高中
2019年8月29日

binary-ninja, internship-projects, reversing, static-analysis

我们开源了一套静态分析工具KRFAnalysis,用于分析和分类系统调用(syscall)故障注入工具KRF的输出。现在您可以轻松找出KRF在何处以及为何使程序崩溃。

在我于Trail of Bits的暑期实习期间,我致力于KRF项目——一个直接对系统调用进行故障注入以引发崩溃的模糊测试工具。KRF效果极佳,能大量生成核心转储。但由于单次运行可能存在数百个故障系统调用,确定具体是哪个故障调用导致崩溃十分困难。通过源代码或反汇编二进制文件手动追踪崩溃原因既繁琐又容易出错。

我尝试使用称为"反向污点分析"的技术解决这个问题,并通过Binary Ninja API实现了解决方案。该脚本能提供导致崩溃的可能原因短列表,极大减少了所需的手动工作量。下文我将描述创建算法和脚本的过程,并简要概述为简化使用而构建的附加工具。

人工介入循环

如何可靠确定崩溃来源?人类会如何确定崩溃原因?首先查看堆栈跟踪并确定崩溃发生位置。以下面这个易受攻击的示例程序为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdlib.h>

void fillBuffer(char *string, unsigned len) {
  for (unsigned i = 0; i < len; ++i) {
      string[i] = 'A'; // 如果string = NULL,无效写入导致段错误
  }
}

int main() {
  char *str;
  str = (char *) malloc(16); // 如果malloc失败,str = NULL
  fillBuffer(str, 16); // str未检查malloc错误!
  free(str);
  return 0;
}

对此程序运行KRF引发了故障。本例中我们可轻松猜测崩溃原因——故障的brk或mmap导致malloc返回NULL,当fillBuffer尝试向NULL写入时产生段错误。但假设我们无法访问源代码,仍需确定崩溃原因。

首先用gdb查看核心转储的堆栈跟踪:

1
2
3
(gdb) bt
#0 0x00005555555546a8 in fillBuffer ()
#1 0x00005555555546e1 in main ()

查看进程内存映射以定位二进制文件中的指令:

1
2
3
4
5
(gdb) info proc mappings
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x555555554000 0x555555555000 0x1000 0x0 /vagrant/shouldve_gone_for_the_head
[输出截断]

通过堆栈跟踪地址交叉引用,发现顶部堆栈帧指令位于二进制文件/vagrant/shouldve_gone_for_the_head中。通过从映射地址空间起始位置减去内存映射objfile起始地址并加上偏移量,计算指令指针在二进制文件中的偏移:

0x00005555555546a8 - 0x555555554000 + 0x0 = 0x6a8

现在可在反汇编器(Binary Ninja)中检查二进制文件。此处可见fillBuffer()函数的反汇编,导致段错误的指令以红色高亮显示。该指令将rax指向的字节设置为字符A的代码,因此问题必定是rax的值无效。回溯发现rax = rax + rdx,这两个寄存器先前分别设置为局部变量string和i。在0x68e处的指令中可见string最初存储在rdi中,这是函数的第一个参数。i初始化为零且仅递增,因此可忽略,因为我们知道它不可能被函数调用或函数参数污染。

了解到fillBuffer()的第一个参数被污染后,可查看堆栈跟踪中的下一帧。对堆栈跟踪地址0x00005555555546e1执行相同的内存映射减法操作:

0x00005555555546e1 - 0x555555554000 + 0x0 = 0x6e1

该地址将是fillBuffer()函数调用后的一条指令,因为它是返回地址。因此需要检查0x6e1之前的指令。在Binary Ninja中打开!

此处0x6e1处的指令以蓝色高亮显示,前一条指令以红色高亮显示。从对fillBuffer的手动分析中得知第一个参数存储在rdi中,因此应跟踪存储在rdi中的数据。在前一条指令中,我们看到rdi被设置为rax,而在其上方有一个对malloc的调用,该调用将其返回值存储在rax中。

很好!现在我们知道malloc的输出被传递到fillBuffer中并导致段错误。问题找到了!但这过程非常烦人。要是有更好的方法就好了……

引入MLIL静态单赋值

事实证明确实有更好的方法!Binary Ninja可以将代码反编译为中级中间语言(MLIL),这是一种更易读的汇编形式。然后可将其转换为静态单赋值(SSA)形式,其中每个变量仅被赋值一次。这非常有用,因为我们无需担心变量在其定义之外被更改。作为SSA示例,考虑以下伪代码函数:

1
2
3
4
5
6
def f(a):
  if a < 5:
    a = a * 2
  else:
    a = a - 5
  return a

SSA形式为:

1
2
3
4
5
6
7
def f(a0):
  if a0 < 5:
    a1 = a0 * 2
  else:
    a2 = a0 - 5
  a3 = Φ(a1, a2) // 表示"a3是a1或a2"
  return a3

现在通过SSA MLIL视角再次查看我们的示例。以下是SSA MLIL形式的fillBuffer:

此处可轻松追踪rax_2#4到rax_1#3 + rdx_1#2,然后追踪rax_1#3到string#1(即arg1)。也可轻松回溯i并看到它被设置为0。我们再次发现fillBuffer的第一个参数是崩溃根源。现在查看main函数。

这里我们真正看到了SSA MLIL相对于常规反汇编的优势。它让我们看到传递给fillBuffer的参数以及malloc返回的值,使分析更加容易。通过向后追踪rdi#1的来源,我们再次看到malloc污染了fillBuffer的第一个参数从而导致崩溃。

进入终局阶段

既然我们(再次)意识到malloc是问题根源,让我们写出一直应用的过程,以便轻松转换为代码:

  1. 创建空堆栈
  2. 将崩溃指令压入堆栈
  3. 当堆栈不为空时:
  4. 从堆栈弹出一条指令
  5. 如果是MLIL函数调用指令:
  6. 该函数调用的返回值可能是崩溃原因
    
  7. 否则:
  8. 对于MLIL指令中使用的每个SSA变量:
    
  9.   如果未在此函数中赋值:
    
  10.     # 这是函数参数
    
  11.     需向上查看堆栈跟踪的另一帧
    
  12.     # 与发现arg1被污染后转到main相同
    
  13.   否则:
    
  14.     将分配SSA变量的指令加入堆栈
    

这很简单!只需使用Binary Ninja API用Python编写。需要编写一个函数,接收指令地址和BinaryView(保存二进制信息的类),并打印指令的污染源。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def checkFunction(self, inst_addr, bv):
  # 获取包含指令的函数的MLILFunction对象
  func = bv.get_functions_containing(inst_addr)[0].medium_level_il

  # 获取inst_addr处指令的MLILInstruction对象
  inst = func[func.get_instruction_start(inst_addr)].ssa_form

  # 将MLILFunction转换为SSA形式
  func = func.ssa_form

  # 跟踪已查看内容
  visited_instructions = set()

  # 感兴趣的变量
  var_stack = []

  # 将第一条指令使用的变量加入堆栈
  for v in inst.vars_read:
    var_stack.append(v)

  # 堆栈中有元素时持续运行分析
  while len(var_stack) > 0:
    var = var_stack.pop()
    if var not in visited_instructions:
      visited_instructions.add(var)

    # 获取变量声明
    decl = func.get_ssa_var_definition(var)

    # 检查是否为参数
    if decl is None:
      print("参数 " + var.var.name + " 从函数调用污染")
      continue

    # 检查是否为函数调用
    if decl.operation == MediumLevelILOperation.MLIL_CALL_SSA:
      # 直接调用
      if decl.dest.value.is_constant:
        # 从地址获取被调用函数的MLILFunction对象
        func_called = bv.get_function_at(decl.dest.value.value)
        print("被调用污染", func_called.name, "(" + hex(decl.dest.value.value) + ")")
      else:
        # 间接调用
        print("被指令处的间接调用污染", hex(decl.address))
      continue

    # 如果不是参数或调用,将指令中使用的变量加入堆栈。常量被过滤
    for v in decl.vars_read:
      var_stack.append(v)

SSA的强大功能体现在vars_read和get_ssa_var_definition方法中。MLIL通过decl.operation == MediumLevelILOperation.MLIL_CALL_SSA使检测调用变得容易。

扩展脚本

我们可在许多方面进行扩展:错误处理、边缘情况、自动分析堆栈跟踪中的上一帧、自动从堆栈跟踪提取信息等。幸运的是,我已经用一组Python脚本完成了部分工作。

python3 main.py binary coredump1 [coredump2] …
自动从核心转储提取所需信息,然后将这些信息和二进制文件插入tarball中,以便复制到其他计算机,包括堆栈跟踪中调用的库。

gdb.py
使用GDB Python API从每个核心转储提取数据。由main.py调用,因此它们必须在同一目录中。

python3 analyze.py tarball.tar.gz
获取main.py输出的tarball,并自动对其中的每个核心转储运行反向污点分析,自动将污染参数级联到下一帧。它使用krf.py运行分析,因此它们必须在同一目录中。

krf.py包含分析代码,这是本文中编写的脚本的功能更丰富版本。(需要Binary Ninja API。)

在我们的测试二进制文件上尝试它们:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ # 使用KRF的Linux虚拟机
$ python3 main.py shouldve_gone_for_the_head core
在/vagrant中生成tar归档krfanalysis-shouldve_gone_for_the_head.tar.gz

$ # 使用Binary Ninja的机器
$ python3 analyze.py krfanalysis-shouldve_gone_for_the_head.tar.gz
分析二进制文件shouldve_gone_for_the_head
完成
分析崩溃krfanalysis-shouldve_gone_for_the_head/cores/core.json
被malloc调用污染(0x560)
所有路径已检查

结论

编写此分析脚本让我认识到Binary Ninja API非常出色。其多功能性和自动分析能力令人惊叹,特别是它直接作用于二进制文件,其中间语言易于使用和理解。

我还想提一下LLVM,这是另一个静态分析框架,其API与Binary Ninja非常相似。它比Binary Ninja有许多优势,包括更好的调试和类型信息访问、免费、更成熟的代码库以及始终完美的调用约定分析。其缺点是需要源代码或要分析内容的LLVM IR。

KRFAnalysis存储库中提供了三个LLVM通道来运行静态分析:一个检测因使用前检查系统状态而导致的竞争条件(即检查时间/使用时间或TOC/TOU),另一个检测标准库调用的未检查错误,第三个重新实现反向污点分析。

我的夏天:为救赎付出的小代价

我非常感谢Trail of Bits的所有人给予我实习机会。我获得了惊人的技术经验,并有机会接触Linux内核、FreeBSD内核和LLVM——这些代码库我曾认为是神秘的。

我的一些亮点:

  • 将KRF移植到FreeBSD
  • 为KRF添加按PID、GID、UID或特定打开文件定位进程的能力
  • 编写用于静态分析的LLVM通道
  • 上游化LLVM更改
  • 学会使用Binary Ninja及其API
  • 掌握良好编码实践
  • 获得安全行业意识

我还遇到了一些了不起的人。特别感谢我的导师Will Woodruff(@8x5clPW2),他总是愿意讨论实现、想法或审查我的拉取请求。我迫不及待地想要在职业生涯中应用在Trail of Bits学到的知识。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计