安全路透社
当前位置:安全路透社 > 安全客 > 正文

【技术分享】利用动态符号执行进行代码覆盖测试

http://p1.qhimg.com/t018bc6291b2cd39f2c.jpg

翻译:myswsun

预估稿费:200RMB

,或登陆


0x00 前言


代码覆盖测试主要用于漏洞研究领域。主要目的是使用不同输入覆盖程序代码的不同部分。如果某个输入导致程序崩溃,我们将检测崩溃是否能被利用。有很多代码覆盖测试的方法,比如随机测试等。但是本文重点关注使用动态符号执行进行代码覆盖测试。覆盖代码不意味着能找到所有的可能的缺陷。一些缺陷不会引起程序崩溃。然而2017年刚到,勒索软件以惊人的速度爆发。这周我们发现了大量的新变种,尤其是以很有名的名次命名的FSociety。我们也发现了一些解密工具,圣诞节有关的勒索软件,CryptoMix/CryptFile2的分析,大量的小的勒索软件。


0x01 代码覆盖和动态符号执行(DSE)


不像SSE(静态符号执行),DSE应用于跟踪,并且只有在执行期间达到这些分支时才能发现新的分支。要想到达另一个路径,我们必须搞清楚从上个跟踪发现的分支约束条件。然后我们重复操作,直到到达所有分支。

举个例子,我们假设程序P 有称为I的输入。I可以是模型M或者随机种子R。执行P(I)能返回一组PC约束。所有的φi表示基础块,πi表示分支约束。模型Mi是约束πi的一种可靠的解决方案,M1 = Solution(¬π1 ∧ π2)。为了发现所有的路径,我们维护一个叫W的工作列表,它是一组M。

在第一轮迭代,执行I = R, W = ∅ 和P(I) → PC。然后是∀π ∈ PC, W = W ∪ {Solution(π)} ,再次执行∀M ∈ W, P(M)。当模型M作为程序的输入后,将它从列表W中删除。重复操作知道W为空。

http://p5.qhimg.com/t01740bea9c3d6e4a77.png

符号执行的代码覆盖测试既有优点又有缺点。它对于在混淆的二进制文件中很有帮助。确实利用符号覆盖能检测到隐藏的、不可达到的代码但会是个平面的图表。最糟糕的是当你的表达式太复杂,可能会超时或巨大的内存消耗(在过去,我们符号表达式在超时前消耗了差不多450 G的 RAM)。这种场景主要发生在分析超大二进制文件或者包含复杂功能的混淆的二进制文件。

0x02 使用Triton进行代码覆盖测试


从版本v0.1 build 633开始,Triton整合了我们代码覆盖测试需要的一切。它可以让我们更好的处理及计算SMT2-lib表达的AST。下面我们将关注代码覆盖的设计和使用的算法。

算法

以下面的代码为例。

01. char *serial = "\x31\x3e\x3d\x26\x31";
02. int check(char *ptr)
03. {
04.   int i = 0;
05.   while (i < 5){
06.     if (((ptr[i] - 1) ^ 0x55) != serial[i])
07.       return 1;
08.     i++;
09.   }
10.   return 0;
11. }

函数控制流图如下显示。它是一个好例子,因为我们需要找到好的输入才能覆盖所有的基础块。

http://p4.qhimg.com/t012003ba6bf1d2a31f.png

可以看到可以看到只有一个变量可控,位于地址rbp+var_18,指向argv[1]的指针。目标是通过计算约束条件和使用快照引擎到达check函数的所有基础块。例如到达位于地址0x4005C3基础块的约束条件是[rbp+var_4] > 4,但是我们不能直接控制这个变量。另一方面,在地址0x4005B0处的跳转依赖于用户的输入并且这个约束条件可以通过符号执行解决。

归纳以前的想法的算法是使用基于微软的Fuzzer算法(SAGE),下图表示包含约束条件的check函数。这个start和end节点表示了我们的函数开端(0x40056D)和函数的结尾(0x4005C8)

http://p6.qhimg.com/t015d099b3c3b215b40.png

在首次执行前,我们不知道任何的分支约束。因此我们按照上文所述,我们注入一些随机种子来收集第一个PC并且构建我们的W集合。首次执行P(I)的跟踪结果由下图蓝色表示。

执行结果给了我们第一条路径约束P(I) → (π0 ∧ ¬π1)。

http://p5.qhimg.com/t010f32c619cc26952b.png

基于第一次跟踪,我们知道发现了两条分支(π0 ∧ ¬π1),并且还有两条没有发现。为了到达基础块φ3,我们计算第一个分支约束的否定条件。当且仅当Solution(¬π0) 是SAT,我们将它添加到模型工作列表W中。

同样到达 φ4 可以得到W = W ∪ {Solution(π0 ∧ ¬(¬π1))}。所有解决方案都生成了并且模型被添加到工作列表中,我们执行工作列表中每个模型。

http://p8.qhimg.com/t015a8952a7523898b3.png

实现

执行代码覆盖的一个条件是在一个跳转指令处能预测下一条指令的地址。这是构建路径约束的必要条件。

我们不能在一个分支指令后放置一个回调,因为RIP寄存器已经改变了。因为Triton为所有的寄存器创建语义表达式,所以在分支指令时可以判定RIP。

第一次,我们开发了一个SMT判定器用来计算RIP,但是我们发现Pin提供的用于获得下一个RIP值的IARG_BRANCH_TARGET_ADDR和IARG_BRANCH_TAKEN有点滞后。使用Pin计算下一个地址非常简单,但是SMT判定器用来检查指令的语义是很有用的。

为了更好的演示判定,我们实现了visitor pattern来将SMT的抽象语法树(AST)转化为Z3的抽象语法树。这个设计能够用于将SMT AST转化为任意其他的表达。

Z3的AST使用Z3 API处理更加简单。转化代码是src/smt2lib/z3AST.h 和 src/smt2lib/z3AST.cpp

现在我们解释代码覆盖的工具如何工作。让我们假定输入来自命令行。

首先,我们有:

160. def run(inputSeed, entryPoint, exitPoint, whitelist = []):
161. ...
175. if __name__=='__main__':
176.   TritonExecution.run("bad !", 0x400480, 0x40061B, ["main", "check"]) # crackme_xor

在176行,我们定义了输入种子bad!,代表程序的第一个参数。然后我们给出代码覆盖的起始地址,在这个地址我们将做一个快照。第三个参数将匹配最后一个块,这个地址我们将恢复快照。最后,我们设置一个避免库函数、加密函数等的白名单。

134. def mainAnalysis(threadId):
135.
136.    print "[+] In main"
137.    rdi = getRegValue(IDREF.REG.RDI) # argc
138.    rsi = getRegValue(IDREF.REG.RSI) # argv
139.
140.    argv0_addr = getMemValue(rsi, IDREF.CPUSIZE.QWORD)      # argv[0] pointer
141.    argv1_addr = getMemValue(rsi + 8, IDREF.CPUSIZE.QWORD)  # argv[1] pointer
142.
143.    print "[+] In main() we set :"
144.    od = OrderedDict(sorted(TritonExecution.input.dataAddr.items()))
145.
146.    for k,v in od.iteritems():
147.        print "\t[0x%x] = %x %c" % (k, v, v)
148.        setMemValue(k, IDREF.CPUSIZE.BYTE, v)
149.        convertMemToSymVar(k, IDREF.CPUSIZE.BYTE, "addr_%d" % k)
150.
151.    for idx, byte in enumerate(TritonExecution.input.data):
152.        if argv1_addr + idx not in TritonExecution.input.dataAddr: # Not overwrite the previous setting
153.            print "\t[0x%x] = %x %c" % (argv1_addr + idx, ord(byte), ord(byte))
154.            setMemValue(argv1_addr + idx, IDREF.CPUSIZE.BYTE, ord(byte))
155.            convertMemToSymVar(argv1_addr + idx, IDREF.CPUSIZE.BYTE, "addr_%d" % idx)

下一个执行的代码是mainAnalysis回调函数,我们注入一些值到输入中(行148,154),我们能通过符号变量覆盖这些输入(行149,155)。

所有被选择的输入存储在全局变量TritonExecution.input中。然后我们开始代码检测。

58. if instruction.getAddress() == TritonExecution.entryPoint and not isSnapshotEnabled():
59.        print "[+] Take Snapshot"
60.        takeSnapshot()
61.        return

当我们在入口点时,我们做一个快照,为了用新的输入重新执行代码检测。

52. if instruction.getAddress() == TritonExecution.entryPoint + 2:
53.   TritonExecution.myPC = []                                  # Reset the path constraint
54.   TritonExecution.input = TritonExecution.worklist.pop()     # Take the first input
55.   TritonExecution.inputTested.append(TritonExecution.input)  # Add this input to the tested input
56.   return

我们重置路径约束(行53),从工作列表中取出一个新的输入。

63. if instruction.isBranch() and instruction.getRoutineName() in TritonExecution.whitelist:
64.
65.   addr1 = instruction.getAddress() + 2                # Address next to this one
66.   addr2 = instruction.getOperands()[0].getValue()     # Address in the instruction condition
67.
68.   # [PC id, address taken, address not taken]
69.   if instruction.isBranchTaken():
70.     TritonExecution.myPC.append([ripId, addr2, addr1])
71.   else:
72.     TritonExecution.myPC.append([ripId, addr1, addr2])
73.
74.   return

上述代码检测是否位于分支指令(如jnz,jle等)或者位于白名单中的函数中。如果是,我们得到两种可能的地址(addr1和addr2),通过isBranchTaken()(行69)计算有效的地址。

然后,我们将条件约束存储在RIP表达式中。

81. if instruction.getAddress() == TritonExecution.exitPoint:
82.  print "[+] Exit point"
83.
84.  # SAGE algorithm
85.  # http://research.microsoft.com/en-us/um/people/pg/public_psfiles/ndss2008.pdf
86.  for j in range(TritonExecution.input.bound, len(TritonExecution.myPC)):
87.      expr = []
88.      for i in range(0,j):
89.          ripId = TritonExecution.myPC[i][0]
90.          symExp = getFullExpression(getSymExpr(ripId).getAst())
91.          addr = TritonExecution.myPC[i][1]
92.          expr.append(smt2lib.smtAssert(smt2lib.equal(symExp, smt2lib.bv(addr,  64))))
93.
94.      ripId = TritonExecution.myPC[j][0]
95.      symExp = getFullExpression(getSymExpr(ripId).getAst())
96.      addr = TritonExecution.myPC[j][2]
97.      expr.append(smt2lib.smtAssert(smt2lib.equal(symExp, smt2lib.bv(addr,  64))))
98.
99.
100.      expr = smt2lib.compound(expr)
101.      model = getModel(expr)
102.
103.      if len(model) > 0:
104.          newInput = TritonExecution.input
105.          newInput.setBound(j + 1)
106.
107.          for k,v in model.items():
108.              symVar = getSymVar(k)
109.              newInput.addDataAddress(symVar.getKindValue(), v)
110.          print newInput.dataAddr
111.
112.          isPresent = False
113.
114.          for inp in TritonExecution.worklist:
115.              if inp.dataAddr == newInput.dataAddr:
116.                  isPresent = True
117.                  break
118.          if not isPresent:
119.              TritonExecution.worklist.append(newInput)
120.
121.  # If there is input to test in the worklist, we restore the snapshot
122.  if len(TritonExecution.worklist) > 0 and isSnapshotEnabled():
123.      print "[+] Restore snapshot"
124.      restoreSnapshot()
125.
126.  return

当我们在出口点是是最后一步。行84-120是SAGE的实现。简言之,我们浏览路径约束列表,对于每个PC,我们尝试获得满足否定的模型。如果有不可靠的模型到达了新的目标块中,我们将这个模型添加到工作列表中。一旦所有的模型被插入工作列表中,我们恢复快照并且重新执行每个模型。

全部代码能在这里找到,我们例子的执行过程如下:

http://p5.qhimg.com/t0186b36ffdbd39bdb1.png

http://p7.qhimg.com/t01e56150d187d0bca1.png


0x03 总结


尽管代码覆盖使用符号执行是一个好的方法,但它是个复杂的任务。路径遍历意味着内存消耗,并且一些情况下要计算的表达式太过复杂。目前,判定器非常慢,判定表达式非常慢。


原文链接:https://triton.quarkslab.com/blog/Code-coverage-using-dynamic-symbolic-execution/

未经允许不得转载:安全路透社 » 【技术分享】利用动态符号执行进行代码覆盖测试

赞 (0)
分享到:更多 ()

评论 0

评论前必须登录!

登陆 注册