angr入门(一)


之前一直想学习一下 angr 符号执行,在 CTF 题目中虽然接触过很多次,也用过那么一两次,但都是用的别人的代码,完全不理解其中的运行机制,脚本也是不知其义,还埋怨 angr 不好用。在重新读了一遍官方文档后才知道 angr 其实很强大,可以只执行自己想要执行的函数而不用执行整个程序。正好最近看到一个开源项目用于练习 angr,那就从0开始彻底把 angr 学习一下,对 Fuzz 工作也是很有帮助的。

Introduction

首先介绍一下 angr 吧。它是一个用于二进制文件分析的 Python 框架,结合了静态分析和动态符号的分析。angr 学习的路线比较陡峭,不是说它有多难,而是没有合适的学习资料和连贯的学习路径。这里我找到了一个开源项目 angr_ctf,它从0开始一步一步教我们使用 angr 的功能。

符号执行的意义就是在不实际运行程序的情况下对程序进行分析,以了解在什么状态下,执行了哪条路径的代码。举一个最常见的例子,CTF 中的逆向题目,通常是知道程序的输出结果,要我们去逆向加密算法,得到我们应该输入的正确内容,也就是 flag。

符号执行就允许我们把程序当作一个方程来求解,就像是方程式中的 X、Y 等符号,而程序执行的路径用于“约束”符号。举个例子:

int x;
scanf("%d", &x);

if ((x > 1) && (x < 10)) {
	puts("Success!!");
} 

else {
	puts("Fail.");
}

在上面的代码里,if 语句产生两条路径,要么打印”Success!!”,要么打印”Fail”,其实就是 if 语句对符号 x 的约束。假设现在我们对 “Success!!”这条路径比较感兴趣,那么就必须满足 1 < x < 10,这个公式就是符号执行中的约束,符号执行引擎将此语句注入一个符号标识,继续向后运行以找到符合约束的值。

上面的程序比较简单,因为它只有两条路径,很容易就可以求解。当遇到特别大的程序时会有很多路径,此时就会遇到“路径爆炸”的问题,因为每条路径向下执行时,路径都是呈指数增长的,相对于求解的时间就比较长了,可能到 热寂 都无法求解出来。

Part 0 angr find

进入正题,首先把 angr_ctf 克隆下来,进入到 dist/ 文件夹下,这里可以找到许多练习题目和一堆 xx.py 的文件。找到 00_angr_find 的文件,这是第一个题目,逻辑相当简单,要求输入一个字符串,程序对字符串进行处理后要和“JACEJGCS”相同:

image-20220426233408003

通常情况下需要对 complex_function 函数进行手动逆向,现在直接用 angr 约束求解,得到应该输入的正确字符串。接下来查看解题代码 scaffold00.py :

import angr
import sys

def main(argv):
  path_to_binary = ???
  project = angr.Project(path_to_binary)
  initial_state = project.factory.entry_state()
  simulation = project.factory.simgr(initial_state)
  
  print_good_address = ???
  simulation.explore(find=print_good_address)
  
  if simulation.found:
    solution_state = simulation.found[0]
    print solution_state.posix.dumps(sys.stdin.fileno())
  
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

现在逐行分析,首先导入了 angr 库和 sys 库:

import angr
import sys

然后看 main 函数的内容:

def main(argv):
  path_to_binary = ???
  project = angr.Project(path_to_binary)
  initial_state = project.factory.entry_state()
  simulation = project.factory.simgr(initial_state)
  • path_to_binary 指定可执行程序路径
  • angr.Project() 创建一个 Project 实例
  • project.factory.entry_state() 在程序入口点创建程序状态,类似于一个快照
  • project.factory.simgr() 告诉符号执行引擎从 initial_state 处开始符号执行

接下来的两行代码是关键:

print_good_address = ???
simulation.explore(find=print_good_address)
  • print_good_address 是打印”Good Job”的地址

    image-20220426235759712

  • simulation.explore() 告诉符号执行引擎,我们想要到达的代码位置

最后几行代码:

if simulation.found:
    solution_state = simulation.found[0]
    print solution_state.posix.dumps(sys.stdin.fileno())
  
else:
    raise Exception('Could not find the solution')

若符号执行引擎找到正确路径,将输入给 stdin,否则抛异常。

修改整体代码,得到完整代码:

import angr
import sys

def main(argv):
  path_to_binary = "./00_angr_find" # path of the binary program
  project = angr.Project(path_to_binary)
  initial_state = project.factory.entry_state()
  simulation = project.factory.simgr(initial_state)

  print_good_address = 0x8048678  # :integer (probably in hexadecimal)
  simulation.explore(find=print_good_address)
  
  if simulation.found:
    solution_state = simulation.found[0]
    solution = solution_state.posix.dumps(sys.stdin.fileno())
    print("[+] Success! Solution is: {}".format(solution.decode("utf-8")))
  
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

最后执行 scaffold00.py 得到需要输入的字符串”JXWVXRKX”:

image-20220427000053246

Part 1 angr find condition

在上面简单的认识了一下 angr,接下来学习如何避免不需要的状态以减少符号执行的时间。先跳过 01_angr_avoid,因为它和上面的题目是一样的,但需要我们指定和避免某些路径,可以借 02_angr_find_condition 来学习 angr 的 avoid 功能。用 IDA 打开二进制文件,可以发现有很多块都会打印“Good Job”或“Try again”:

image-20220427105401322

记录所有的这些块起始地址不太现实,数量太多了,但可以根据它打印到标准输出的内容告诉 angr 保留或丢弃。打开 scaffold02.py 文件:

import angr
import sys

def main(argv):
  path_to_binary = argv[1]
  project = angr.Project(path_to_binary)
  initial_state = project.factory.entry_state()
  simulation = project.factory.simgr(initial_state)
  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???  # :boolean
  
  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???  # :boolean

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]
    print(solution_state.posix.dumps(sys.stdin.fileno()))
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

代码大部分都和前一个例子相同,就不赘述了。主要看中间部分:

def is_successful(state):
   stdout_output = state.posix.dumps(sys.stdout.fileno())
   return ???  # :boolean
 
 def should_abort(state):
   stdout_output = state.posix.dumps(sys.stdout.fileno())
   return ???  # :boolean

 simulation.explore(find=is_successful, avoid=should_abort)

猜测 is_successful() 对状态进行检测,检测它的结果是否输出 “Good Job”。根据分析修改代码:

def is_successful(state):
	stdout_output = state.posix.dumps(sys.stdout.fileno()) 
    if b'Good Job.' in stdout_output: 
    	return True 
    else: 
      return False

然后再修改 should_abort() 函数,可以看出来和 is_successful() 功能相同:

def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())   
    if b'Try again.' in  stdout_output:
      return True
    else: return False

simulation.explore(find=is_successful, avoid=should_abort) 告诉 angr,只对”Good Job”感兴趣,要避免”Try again”的路径。findavoid 参数可以是一个地址或地址列表。

最后完整代码如下:

import angr
import sys

def main(argv):
  path_to_binary = "./02_angr_find_condition"
  project = angr.Project(path_to_binary)
  initial_state = project.factory.entry_state()
  simulation = project.factory.simgr(initial_state)
  
  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    if b'Good Job.' in stdout_output:
      return True
    else: return False

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
   
    if b'Try again.' in  stdout_output:
      return True
    else: return False

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]
    solution = solution_state.posix.dumps(sys.stdin.fileno())
    print("[+] Success! Solution is: {}".format(solution.decode("utf-8")))

  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

最后很快得到结果”HETOBRCU”

image-20220427111953296

Part 2 angr symbolic registers

现在考虑一种情况,如果 angr 无法从指定的地址开始符号执行该如何处理?换个说法,angr 在处理类似 scanf("%x %x %x", &a, &b, &c) 这种输入方式的时候尤为吃力,就得考虑另外一种办法。我们可以跳过这个输入函数,在用到输入参数的时候我们自定义寄存器的值,这样就可以达到我们的目的。

首先老规矩,先在 IDA 看看二进制程序 03_angr_symbolic_registers。主函数中 get_user_input 接收用户输入,将用户输入经过 complex_function 的处理,最后输出”Good Job”或”Try again”:

image-20220427140721086

再看看 get_user_input 函数,正如上面说的那样,使用 scanf 这种复杂的方式接受输入。它将参数依次给了 EAX、EBX、EDX寄存器,了解参数传递的寄存器有利于使用 angr 进行参数模拟:

image-20220427141347745

然后查看 scaffold03.py 代码:

import angr
import claripy
import sys

def main(argv):
  path_to_binary = argv[1]
  project = angr.Project(path_to_binary)
  start_address = ???  # :integer (probably hexadecimal)
  initial_state = project.factory.blank_state(addr=start_address)
  
  password0_size_in_bits = ???  # :integer
  password0 = claripy.BVS('password0', password0_size_in_bits)
  
  initial_state.regs.??? = password0
  ...
  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    return ???

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]
    
    solution0 = solution_state.se.eval(password0)
    ...
    solution = ???  # :string
    print(solution)
    
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

首先 start_address 依然设置我们希望的起始地址,也就是调用 scanf 之后的地址 0x08048937。然后注意这里是使用的 blank_state 而不是 entry_state,实际上是告诉 angr 在该地址创建一个新的 state。claripy.BVS 表示使用 claripy 中的 BVS 方法生成位向量,它有两个参数,第一个参数”password0”就像是方程里面的符号“x”,第二个参数 password0_size_in_bits 表示所用的数值的二进制位数,int 型就是32位。initial_state.regs 将使用创建的位向量,将值指定给寄存器。之后定义 findavoid 状态。solution_state.se.eval 方法可以看作是求解定义的位向量,最后打印求解的结果。

依照上面分析的结果修改代码,但是有一个问题,那就是在我们的起始地址在 scanf 函数之后,函数返回需要平衡栈,也就是说还得伪造一个栈的内容。其实这就有点过于复杂了,再次观察代码可以得知在 get_user_input 函数返回之后将参数给到了 EAX、EBX、EDX,我们可以直接从这里开始给寄存器赋值就行了:

image-20220427144214207

完整代码如下:

import angr
import claripy
import sys

def main(argv):
  path_to_binary = "./03_angr_symbolic_registers"
  project = angr.Project(path_to_binary)

  start_address = 0x08048980  # :integer (probably hexadecimal)
  initial_state = project.factory.blank_state(addr=start_address)

  password_size_in_bits = 32  # :integer
  password0 = claripy.BVS('password0', password_size_in_bits)
  password1 = claripy.BVS('password1', password_size_in_bits)
  password2 = claripy.BVS('password2', password_size_in_bits)
  
  initial_state.regs.eax = password0
  initial_state.regs.ebx = password1
  initial_state.regs.edx = password2

  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    if b'Good Job.\n' in stdout_output:
      return True
    else:
      return False

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    if b'Try again.\n' in stdout_output:
      return True
    else:
      return False

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]

    solution0 = format(solution_state.solver.eval(password0), 'x')
    solution1 = format(solution_state.solver.eval(password1), 'x')
    solution2 = format(solution_state.solver.eval(password2), 'x')

    solution = solution0 + " " + solution1 + " " + solution2  # :string
    print(solution)
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

运行后得到结果:

image-20220427145237549

Part 3 angr symbolic stack

在 Part 2 的例子中,为了图方便,没有构造栈的内容,而是越过输入函数直接构造寄存器内容,但有时候我们不得不去构造栈的内容。

IDA 打开 04_angr_symbolic_stack 二进制文件,scanf 函数完成后直接对输入内容进行操作:

image-20220427151637284

从汇编代码来看,scanf 函数是外平栈,将两个输入内容放入了 EBP-0x10 和 EBP-0xC 的位置:

image-20220427152733646

那么可以从 0x08048697 地址开始绕过 scanf 函数,然后构造这两个栈上的内容。关键在于如何构造这两个内容呢?我们可以利用 EBP 寄存器,因为 EBP 在函数返回之前是不会变动的,并且在函数开始的时候就执行了 MOV EBP, ESP,那么在此基础上,算出 ESP 在我们指定的起始地址前变化多少个字节就可以了。

直接上代码:

import angr
import claripy
import sys

def main(argv):
  path_to_binary = "./04_angr_symbolic_stack"
  project = angr.Project(path_to_binary)
  
  start_address = 0x08048697
  initial_state = project.factory.blank_state(addr=start_address)
  
  initial_state.regs.ebp = initial_state.regs.esp
  password0 = claripy.BVS('password0', 32)
  password1 = claripy.BVS('password1', 32)
  
  padding_length_in_bytes = 8  # :integer
  initial_state.regs.esp -= padding_length_in_bytes
  
  initial_state.stack_push(password0)  # :bitvector (claripy.BVS, claripy.BVV, claripy.BV)
  initial_state.stack_push(password1)

  simulation = project.factory.simgr(initial_state)

  def is_successful(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    if b'Good Job.' in stdout_output:
      return True
    else:
      return False

  def should_abort(state):
    stdout_output = state.posix.dumps(sys.stdout.fileno())
    if b'Try again.' in stdout_output:
      return True
    else:
      return False

  simulation.explore(find=is_successful, avoid=should_abort)

  if simulation.found:
    solution_state = simulation.found[0]

    solution0 = format(solution_state.se.eval(password0),'x')
    solution1 = format(solution_state.se.eval(password1),'x')
    solution = solution0 + " " + solution1

    print(solution)
  else:
    raise Exception('Could not find the solution')

if __name__ == '__main__':
  main(sys.argv)

执行得到结果:

image-20220427154948561


文章作者: unr4v31
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 unr4v31 !
  目录