之前一直想学习一下 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”相同:
通常情况下需要对 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”的地址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”:
Part 1 angr find condition
在上面简单的认识了一下 angr,接下来学习如何避免不需要的状态以减少符号执行的时间。先跳过 01_angr_avoid,因为它和上面的题目是一样的,但需要我们指定和避免某些路径,可以借 02_angr_find_condition 来学习 angr 的 avoid 功能。用 IDA 打开二进制文件,可以发现有很多块都会打印“Good Job”或“Try again”:
记录所有的这些块起始地址不太现实,数量太多了,但可以根据它打印到标准输出的内容告诉 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”的路径。find
和 avoid
参数可以是一个地址或地址列表。
最后完整代码如下:
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”
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”:
再看看 get_user_input
函数,正如上面说的那样,使用 scanf
这种复杂的方式接受输入。它将参数依次给了 EAX、EBX、EDX寄存器,了解参数传递的寄存器有利于使用 angr 进行参数模拟:
然后查看 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
将使用创建的位向量,将值指定给寄存器。之后定义 find
和 avoid
状态。solution_state.se.eval
方法可以看作是求解定义的位向量,最后打印求解的结果。
依照上面分析的结果修改代码,但是有一个问题,那就是在我们的起始地址在 scanf
函数之后,函数返回需要平衡栈,也就是说还得伪造一个栈的内容。其实这就有点过于复杂了,再次观察代码可以得知在 get_user_input
函数返回之后将参数给到了 EAX、EBX、EDX,我们可以直接从这里开始给寄存器赋值就行了:
完整代码如下:
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)
运行后得到结果:
Part 3 angr symbolic stack
在 Part 2 的例子中,为了图方便,没有构造栈的内容,而是越过输入函数直接构造寄存器内容,但有时候我们不得不去构造栈的内容。
IDA 打开 04_angr_symbolic_stack 二进制文件,scanf
函数完成后直接对输入内容进行操作:
从汇编代码来看,scanf
函数是外平栈,将两个输入内容放入了 EBP-0x10 和 EBP-0xC 的位置:
那么可以从 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)
执行得到结果: