angr初探

angr初探

0.0 一些碎碎念

本来是想学z3的,后来发现都要用 virtualenvwrapper ,既然都是符号执行,那就先试试angr的水吧。

本来是挺好配置环境的,但是之前那个ubuntu有问题怎么都配不上,无奈重装了一个虚拟机,捏妈,装了一晚上,加上下载中文包,啥也没干,被迫摆烂一天重新开始学。

不过令我高兴的是,新装的ubuntu可以支持和原系统共享剪贴板,好好好,终于可以永久告别啥指令都要建个文档用文件互相传的日子喽。

1.0 angr介绍

angr 是一个基于符号执行和模拟执行的二进制框架,可以用在很多的场景,比如逆向分析,漏洞挖掘等。

1.1 符号执行

假如我们遇到一道逆向分析题,正常的流程应该是:拖入IDApro一键F5反编译然后硬刚代码写exp得到flag使得该程序输入该flag可以输出“success”等提示字样。(bushi

假如有一堆if语句,分别是if(a>b),if(b>c)……(等等,这不是差分约束么

于是如果我有一个东西,可以自动分析这些语句的逻辑构造一个输入可以让程序跑到指定的一条语句,比如printf("Oooooooops");那么是不是就可以自动获取flag了?

更详细的解释是:根据符号执行,我们把input语句看作是获取一个符号值,分析该程序的每一个分支进行之后所得到的表达式,如果我们对其增加约束,由于angr里内置了一个z3求解器,所以我们就可以得到想要的输入值。

2.0 virtualenvwrapper

首先是环境配置。virtualenvwrapper是一个虚拟的python环境,假如你再安装angr前安装了z3,那么二者会发生一些冲突,因此需要把二者的环境隔离开,创建一个新的虚拟环境。

2.1 安装virtualenvwrapper

第一步:

sudo apt-get install python-dev libffi-dev build-essential virtualenvwrapper 

正常都是没有问题的,如果这一步有问题建议直接remake,就像某彩笔一样。

然后配置环境变量:

先把“显示隐藏文件选项”勾选上,然后直接搜索.bashrc文件,打开后,在末尾加上

export WORKON_HOME=$HOME/Python-workhome
source /usr/share/virtualenvwrapper/virtualenvwrapper.sh

保存后 source ~/.bashrc

然后 mkvirtualenv --python=$(which python3) angr && pip install angr安装即可,SG大爹说但其实好像也没多长时间(捂脸)

2.2 virtualenvwrapper的操作命令

workon: 列出已有环境(我们只创建了叫angr的环境)

workon angr: 切换到环境angr

deactivate: 退出环境

rmvirtualenv: 删除环境 (这个就不试了)

3.0 使用初探

例题来自, https://github.com/jakespringer/angr_ctf

【例 1.1 - explore】00_angr_find (explore的find方法)

一个比较简单的程序,用angr尝试解一下。

我们发现只要程序运行到puts("Good Job")这一行那么我们就得到了flag,找到该语句地址:

题目名字就是hint,简单find,因为该程序计算量不大,即使自己分析也是可行的,此题仅作为练手。

import angr
import sys

path = '/home/ep/桌面/00_angr_find' # 被执行文件的路径
project = angr.Project(path) # 以被执行文件创建angr项目

init_state = project.factory.entry_state() # entry_state()可以创建一个默认的初始状态,告诉angr该从哪开始

simulation = project.factory.simgr(init_state) # 以该初始状态创建一个模拟管理器,这个管理器还有很多工具可以帮助搜索和执行二进制文件

aim_addr = 0x804867D # 希望程序运行到的地址,也就是上图的地址

simulation.explore(find = aim_addr) # 开始执行,直到达到上述地址或者探索完所有可能的路径,simulation.explore()会建立一个名为simulation.found的状态列表
                                    # 该状态列表用来表示是否找到了期望的输出,若找到则为true,反之为false

if simulation.found: # 检查是否得到了解决方案
    solution_state = simulation.found[0]
    print(solution_state.posix.dumps(sys.stdin.fileno())) # 输出angr执行到该状态时的输入,也就是我们需要的flag

else: # 没有得到解决方案
    print('Failed.')

进入angr环境后,运行得到flag。

【例 1.2 - explore】01_angr_avoid (explore的avoid方法)

IDApro打开后发现

main函数里的内容太多了显示不下,留意函数名

void avoid_me()
{
  should_succeed = 0;//addr:080485AB
}
int __cdecl maybe_good(char *s1, char *s2)
{
  if ( should_succeed && !strncmp(s1, s2, 8u) )
    return puts("Good Job.");//addr:080485E0
  else
    return puts("Try again.");
}

题目名字就是hint,和例1一样,加上avoid约束即可:

import angr
proj = angr.Project('/home/ep/桌面/01_angr_avoid')#创建项目
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(find=0x80485E0, avoid=0x80485A8)#由上述分析,添加find和avoid约束,如果不加avoid约束会跑大概一分钟,不加只需要几秒
print(simgr.found[0].posix.dumps(0))

【例 1.3 - explore】02_angr_find_condition (以特定输出为出口)

反编译后发现汇编代码只能用文本模式打开,可以看到有一堆puts("Good Job.")语句,可以反编译出正常代码说明这些函数都是一样的,但是对应的地址却有很多个,这样在使用angr的时候没有办法给出准确的addr,常规的写法已经不可行了。

好在 ,explore函数的find参数除了地址外,也可以是一个携带SimState参数的函数,我的理解是它不仅可以根据地址,也可以根据特定的输出来判定程序的出口。

根据C代码,发现我们需要该程序输出Good Job,不输出Try again

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+18h] [ebp-40h]
  int j; // [esp+1Ch] [ebp-3Ch]
  char s1[20]; // [esp+24h] [ebp-34h] BYREF
  char s2[20]; // [esp+38h] [ebp-20h] BYREF
  unsigned int v8; // [esp+4Ch] [ebp-Ch]

  v8 = __readgsdword(0x14u);
  for ( i = 0; i <= 19; ++i )
    s2[i] = 0;
  qmemcpy(s2, "VXRRJEUR", 8);
  printf("Enter the password: ");
  __isoc99_scanf("%8s", s1);
  for ( j = 0; j <= 7; ++j )
    s1[j] = complex_function(s1[j], j + 8);
  if ( !strcmp(s1, s2) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

于是exp还可以这样写:

import angr

def is_successful(state):
    return b'Good Job.' in state.posix.dumps(1)# find 'Good job'

def should_avoid(state):
    return b'Try again.' in state.posix.dumps(1)# avoid 'Try again'

proj = angr.Project('/home/ep/桌面/02_angr_find_condition')
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)#以上和前面的写法是一样的
simgr.explore(find=is_successful, avoid=should_avoid)#自定义的携带state参数的函数
print(simgr.found[0].posix.dumps(0))

还可以借助python的lambda公式来写脚本,其实也可以理解为函数

import angr

proj = angr.Project('/home/ep/桌面/02_angr_find_condition')
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(
    find=lambda state : b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
)
print(simgr.found[0].posix.dumps(0))

运行得到flag:

【例 2.1 - symbolic】03_angr_symbolic_registers (对寄存器符号化)

反编译后,源代码逻辑比较简单:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 user_input; // rax
  int v5; // [esp+4h] [ebp-14h]
  int v6; // [esp+8h] [ebp-10h]
  int v7; // [esp+Ch] [ebp-Ch]
  int v8; // [esp+Ch] [ebp-Ch]

  printf("Enter the password: ");
  user_input = get_user_input();
  v7 = HIDWORD(user_input);
  v5 = complex_function_1(user_input);
  v6 = complex_function_2();
  v8 = complex_function_3(v7);
  if ( v5 || v6 || v8 )
    puts("Try again.");
  else
    puts("Good Job.");
  return 0;
}

但是complex函数

unsigned int __cdecl complex_function_1(int a1)
{
  return (((((((((((((((((((((a1 + 17062705) ^ 0xB168C552) + 647103529) ^ 0x9F14CFD7) - 548738866) ^ 0xF78063EF)
                      - 1352480098) ^ 0x5D1F4C6)
                    - 57802472) ^ 0xB6F70BF8)
                  - 1347645151
                  + 648671421) ^ 0x3D5082FE)
                - 9365053) ^ 0xD0150EAD)
              + 1067946459) ^ 0xE6E03877)
            - 359192087
            + 961945065) ^ 0xE1EECD69)
          - 1817072919) ^ 0x6B86ECF5)
        - 449212884) ^ 0x2012CCDB;
}

没法逆向,考虑用angr。

当然强行explore也是能出的:

import angr

proj = angr.Project('/home/ep/桌面/03_angr_symbolic_registers')
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(find=0x80489E9,avoid=0x80489DC)
print(simgr.found[0].posix.dumps(0))

但是根据题目要求,本题需要用手动符号化与约束求解。

由红色方框中的指令可以看出,get_user_input之后,输入的三个数分别存在寄存器eax,ebx,ecx中,可以把这三个寄存器符号化:

a = claripy.BVS('password1', 32)
b = claripy.BVS('password2', 32)
c = claripy.BVS('password3', 32)
state.regs.eax = a
state.regs.ebx = b
state.regs.edx = c

在angr中,无论是具体值还是符号量都有相同的类型——claripy.ast.bv.BV,也就是BitVector的意思,BV后面的数字表示这个比特向量的位数。

BV可以通过claripy这个模块创建:

>>> claripy.BVV(666, 32)        # 创建一个32位的有具体值的BV
<BV32 0x29a>
>>> claripy.BVS('sym_var', 32)  # 创建一个32位的符号值BV
<BV32 sym_var_97_32>

然后让程序跑到0x080489E6这个地址:

simgr.explore(find=0x80489E6) #输出"Good Job"的地方

这样在确定了目标地址的状态下,angr内置的求解器会自动保存路径约束,从而解出三个输入值。

solver = simgr.found[0].solver
print(f'password0: {hex(solver.eval(a))}')
print(f'password1: {hex(solver.eval(b))}')
print(f'password2: {hex(solver.eval(c))}')

放上完整exp:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/03_angr_symbolic_registers')
state = proj.factory.blank_state(addr=0x8048980) #这里的初始state是通过blank_state函数而不是entry_state函数获得的,跳过了输入
a = claripy.BVS('password0', 32) #32位寄存器,需要创建一个32位的符号值BV,名称为password0,1,2
b = claripy.BVS('password1', 32)
c = claripy.BVS('password2', 32)
state.regs.eax = a #把符号值BV赋值给三个寄存器,以上操作相当于把三个寄存器符号化
state.regs.ebx = b
state.regs.edx = c
simgr = proj.factory.simgr(state)
simgr.explore(find=0x80489E6) #让程序跑到这个地址
solver = simgr.found[0].solver #求解器根据路径约束求解输入值
print(f'{hex(solver.eval(a))}')
print(f'{hex(solver.eval(b))}')
print(f'{hex(solver.eval(c))}')

注意这里的初始state是通过blank_state函数而不是entry_state函数获得的:

state = proj.factory.blank_state(addr=0x8048980)

因为在0x8048980之前的指令对我们的求解其实是没有任何作用的,包括get_user_input函数,因为接下来我们就要将get_user_input函数的结果符号化了,而不是让angr自动帮我们符号化通过scanf读取的数据。同时, 根据x86汇编常识,像eax,ebx,ecx这样的寄存器是上下文无关的,也就是说一个函数内不会引用在函数外部设置的eax,ebx或者ecx的值,而是在函数内部对寄存器重新初始化并使用,因此也不用担心寄存器的初始化问题。

所以我们用blank_state获取的状态,即时初始的一些寄存器处于未初始化状态也是丝毫没有影响的,因为它们马上就会被初始化:

>>> state.regs.ecx
<BV32 reg_c_3_32{UNINITIALIZED}>

至于esp,angr会给他们一个默认的初值,使接下来的函数调用不会爆炸:

>>> state.regs.esp
<BV32 0x7fff0000>

ebp的初始值仍然是未初始化的,但对我们后续的符号执行没有影响,不管它就行:

>>> state.regs.ebp
<BV32 reg_1c_3_32{UNINITIALIZED}>

【例 2.2 - symbolic】04_angr_symbolic_stack (对栈空间内的数据符号化)

反编译得到C语言代码,代码逻辑比较简单:

int handle_user()
{
  int v1; // [esp+8h] [ebp-10h] BYREF
  int v2[3]; // [esp+Ch] [ebp-Ch] BYREF

  __isoc99_scanf("%u %u", v2, &v1);
  v2[0] = complex_function0(v2[0]);
  v1 = complex_function1(v1);
  if ( v2[0] == 1999643857 && v1 == -1136455217 )
    return puts("Good Job.");
  else
    return puts("Try again.");
}

在【例 2.1】中我们了解了寄存器的符号化,接下来再看看如何对栈空间内的数据进行符号化 。

还是先从scanf语句后开始看,用blank_state跳过输入

state = proj.factory.blank_state(addr=0x8048694)

在【例 2.1】文末中我们知道通过blank_state获取初始状态,ebp的值是未约束的。

因此在本题中,我们要向栈中push符号值,并且通过ebp索引这些符号值(比如[ebp+var_10][ebp+var_C]),所以我们得让ebp有一个正确的初值。

前置芝士:

intel系统中栈是向下生长的(栈越扩大其值越小,堆恰好相反)

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

根据上述的定义,在通常情况下ESP是可变的,随着栈的生产而逐渐变小,而EBP寄存器是固定的,只有当函数的调用后,发生入栈操作而改变。

在上述的定义中使用ESP来标记栈的底部,他随着栈的变化而变化

pop ebp; 出栈 栈扩大4byte 因为ebp为32位

push ebp; 入栈,栈减少4byte

add esp, 0Ch; 表示栈减小12byte

sub esp, 0Ch; 表示栈扩大12byte

由于以下遇到的寄存器为32位,因此每push一次会偏移4byte

而之所以说是正确的初值,是因为我们跳过了函数开头对栈的调整,因此我们还需要手动调整ebp的值来保证栈帧平衡:

image-20211004000749119

汇编语言补充知识:mov是赋值,比如mov ebp,esp,是指将esp中的内容赋值给ebp,lea是赋地址,lea eax,[ebp+var_10]就是把该地址赋给eax

因此ebp的偏移量为0x18+4+4+4+4=40

state.regs.ebp = state.regs.esp + 40

通过对scanf之后汇编语言的分析,我们发现输入值应该存在[ebp+var_C]和[ebp+var_10]两个位置

于是esp的值执行调整,使我们接下来push进去的符号值恰好在[ebp+var_10]和[ebp+var_C]这两个位置,push完之后要把esp调回来即可,放上完整脚本:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/04_angr_symbolic_stack')
state = proj.factory.blank_state(addr=0x8048694) #从输入语句开始执行
state.regs.ebp = state.regs.esp + 40 #计算出ebp的偏移量,手动调整ebp的初值
state.regs.esp = state.regs.ebp - 0xC + 4
password0 = claripy.BVS('password0', 32)
password1 = claripy.BVS('password1', 32)
state.stack_push(password0)
state.stack_push(password1)
state.regs.esp = state.regs.ebp - 40 #把esp的值调整回来
simgr = proj.factory.simgr(state)
simgr.explore(find=0x80486E1)
solver = simgr.found[0].solver
print(f'password0: {hex(solver.eval(password0))}')
print(f'password1: {hex(solver.eval(password1))}')

得到输入值

【例 2.3 - symbolic】05_angr_symbolic_memory (对内存符号化)

代码逻辑还是比较简单:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+Ch] [ebp-Ch]

  memset(user_input, 0, 0x21u);
  printf("Enter the password: ");
  __isoc99_scanf("%8s %8s %8s %8s", user_input, &unk_A1BA1C8, &unk_A1BA1D0, &unk_A1BA1D8);
  for ( i = 0; i <= 31; ++i )
    *(_BYTE *)(i + 169583040) = complex_function(*(char *)(i + 169583040), i);
  if ( !strncmp(user_input, "NJPURZPCDYEAXCSJZJMPSOMBFDDLHBVN", 0x20u) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

可以看出输入是存在一个user_input全局变量里,且起始地址为0A1BA1C9

我们只需要对user_input进行符号化即可,也就是对0xA1BA1C0开始的32字节内存进行符号化,符号化方式有两种:

password0 = claripy.BVS('password0', 64)
password1 = claripy.BVS('password1', 64)
password2 = claripy.BVS('password2', 64)
password3 = claripy.BVS('password3', 64)
state.mem[0xA1BA1C0].uint64_t = password0
state.mem[0xA1BA1C0 + 8].uint64_t = password1
state.mem[0xA1BA1C0 + 16].uint64_t = password2
state.mem[0xA1BA1C0 + 24].uint64_t = password3

或者根据上图的地址(两种方法等效):

state.memory.store(0x0A1BA1C0, flag_0)
state.memory.store(0x0A1BA1C8, flag_1)
state.memory.store(0x0A1BA1D0, flag_2)
state.memory.store(0x0A1BA1D8, flag_3)

如果我们要获取内存中的数据(具体值或者符号值),可以这样用:

>>> state.mem[0xA1BA1C0].uint64_t.resolved
<BV64 password0_0_64>

下面放上完整exp:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/05_angr_symbolic_memory')
state = proj.factory.blank_state(addr=0x80485FE) #跳过输入
a = claripy.BVS('flag0', 64) #每个char字符为8字节,因此每个%8s输入8个字符就是64字节
b = claripy.BVS('flag1', 64)
c = claripy.BVS('flag2', 64)
d = claripy.BVS('flag3', 64)
state.mem[0xA1BA1C0].uint64_t = a #赋值给特定内存
state.mem[0xA1BA1C0 + 8].uint64_t = b
state.mem[0xA1BA1C0 + 16].uint64_t = c
state.mem[0xA1BA1C0 + 24].uint64_t = d
simgr = proj.factory.simgr(state)
simgr.explore(find=0x804866A) #'Good Job.'
solver = simgr.found[0].solver
print(f'flag0: {solver.eval(a, cast_to=bytes)}')
print(f'flag1: {solver.eval(b, cast_to=bytes)}')
print(f'flag2: {solver.eval(c, cast_to=bytes)}')
print(f'flag3: {solver.eval(d, cast_to=bytes)}')

当然也可以把四个数据放在一起写,但是结果和上面那个方法是相反的,应该是端序问题,不太清楚。

import angr
import claripy

proj = angr.Project('/home/ep/桌面/05_angr_symbolic_memory')
state = proj.factory.blank_state(addr=0x80485FE) #跳过输入

flag = claripy.BVS('flag', 64 * 4)
state.memory.store(0xA1BA1C0, flag)

simgr = proj.factory.simgr(state)
simgr.explore(find=0x804866A) #'Good Job.'
solver = simgr.found[0].solver
print(f'flag: {solver.eval(flag, cast_to=bytes)}')

这里我们在使用eval时多了一个参数cast_to,该参数可以指定把结果映射到哪种数据类型。

顺便,我们引申一些eval的其他用法

  • solver.eval(expression) 解出一个可行解
  • solver.eval_one(expression) 给出一个表达式的唯一可行解,若有多个可行解,则抛出异常。
  • solver.eval_upto(expression, n) 给出最多n个可行解,如果不足n个就给出所有的可行解。
  • solver.eval_exact(expression, n) 给出精确的n个可行解,如果解的个数不等于n个,将会抛出异常。
  • solver.min(expression) 给出最小可行解
  • solver.max(expression) 给出最大可行解

【例 2.4 - symbolic】06_angr_symbolic_dynamic_memory (对动态内存的符号化)

这题使用了malloc函数来动态分配内存,因此输入的地址就不是固定的了:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char *v3; // ebx
  char *v4; // ebx
  int v6; // [esp-10h] [ebp-1Ch]
  int i; // [esp+0h] [ebp-Ch]

  buffer0 = (char *)malloc(9u);
  buffer1 = (char *)malloc(9u); // malloc分配动态内存
  memset(buffer0, 0, 9u);
  memset(buffer1, 0, 9u);
  printf("Enter the password: ");
  __isoc99_scanf("%8s %8s", buffer0, buffer1, v6);
  for ( i = 0; i <= 7; ++i )
  {
    v3 = &buffer0[i];
    *v3 = complex_function(buffer0[i], i);
    v4 = &buffer1[i];
    *v4 = complex_function(buffer1[i], i + 32);
  }
  if ( !strncmp(buffer0, "UODXLZBI", 8u) && !strncmp(buffer1, "UAORRAYF", 8u) )
    puts("Good Job.");
  else
    puts("Try again.");
  free(buffer0);
  free(buffer1);
  return 0;
}

可以看到输入是两个长度为8的字符串buffer0和buffer1。虽然malloc是随机分配的,但是由于angr其实并没有真正运行过源程序,因此我们可以直接指定一段地址区间存储该字符串

flag0 = claripy.BVS('flag0', 64)
flag1 = claripy.BVS('flag1', 64)
state.memory.store(0x8274600, flag0)
state.memory.store(0x8274608, flag1)

#第二种方法
state.mem[0x8274600].uint64_t = flag0
state.mem[0x8274608].uint64_t = flag1

然后找到buffer0和buffer1的真实地址并改成之前选定的假地址:

state.memory.store(0xABCC8A4, 0x8274600, endness = proj.arch.memory_endness)
state.memory.store(0xABCC8AC, 0x8274608, endness = proj.arch.memory_endness)
#endness = proj.arch.memory_endness用来调成小端序,angr默认是大端序

#第二种方法
state.mem[0xABCC8A4].uint32_t = 0x8274600
state.mem[0xABCC8AC].uint32_t = 0x8274608

接下来的操作和之前就一样了:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/06_angr_symbolic_dynamic_memory')
state = proj.factory.blank_state(addr=0x8048696)
flag0 = claripy.BVS('flag0', 64)
flag1= claripy.BVS('flag1', 64)
state.memory.store(0xABCC8A4, 0x8274600, endness = proj.arch.memory_endness)
state.memory.store(0xABCC8AC, 0x8274608, endness = proj.arch.memory_endness)
state.memory.store(0x8274600, flag0)
state.memory.store(0x8274608, flag1)
simgr = proj.factory.simgr(state)
simgr.explore(find=0x8048759)
solver = simgr.found[0].solver
print(f'flag0: {solver.eval(flag0, cast_to=bytes)}')
print(f'flag1: {solver.eval(flag1, cast_to=bytes)}')

【例 2.5 - symbolic】07_angr_symbolic_file (对文件内容符号化)

C代码:


int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+Ch] [ebp-Ch]

  memset(buffer, 0, sizeof(buffer));
  printf("Enter the password: ");
  __isoc99_scanf("%64s", buffer);
  ignore_me((int)buffer, 0x40u); //ignore_me函数如其名,应该跳过
  memset(buffer, 0, sizeof(buffer));
  fp = fopen("OJKSQYDP.txt", "rb");
  fread(buffer, 1u, 0x40u, fp);
  fclose(fp);
  unlink("OJKSQYDP.txt");
  for ( i = 0; i <= 7; ++i )
    *(_BYTE *)(i + 134520992) = complex_function(*(char *)(i + 134520992), i);
  if ( strncmp(buffer, "AQWLCTXB", 9u) )
  {
    puts("Try again.");
    exit(1);
  }
  puts("Good Job.");
  exit(0);
}

在这题中我们要忽略scanf,直接对文件的内容进行符号化。要对文件内容进行符号化,首先我们要创建一个模拟的文件SimFile,文件名为'OJKSQYDP.txt',内容为8字节的符号值,大小为0x40字节,然后插入到state的文件系统(FileSystem)中。

import angr
import claripy

proj = angr.Project('/home/ep/桌面/07_angr_symbolic_file')
state = proj.factory.blank_state(addr=0x80488D3) #从ignore_me函数往下开始跑
flag = claripy.BVS('flag', 64) #长度为 0x40
sim_file = angr.SimFile(name='OJKSQYDP.txt', content=flag, size=0x40) #创建一个模拟文件
state.fs.insert('OJKSQYDP.txt', sim_file) #用simstate.fs将文件加入state的文件系统中
simgr = proj.factory.simgr(state)
simgr.explore(find=0x80489AD)
solver = simgr.found[0].solver
print(f'flag: {solver.eval(flag, cast_to=bytes)}')

【例 3.1 - constraints】08_angr_constraints (手动添加约束避免路径爆炸)

C代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+Ch] [ebp-Ch]

  qmemcpy(&password, "AUPDNNPROEZRJWKB", 16);
  memset(&buffer, 0, 0x11u);
  printf("Enter the password: ");
  __isoc99_scanf("%16s", &buffer);
  for ( i = 0; i <= 15; ++i )
    *(_BYTE *)(i + 134520912) = complex_function(*(char *)(i + 134520912), 15 - i);
  if ( check_equals_AUPDNNPROEZRJWKB(&buffer, 16) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

找到关键函数check_equals_AUPDNNPROEZRJWKB

_BOOL4 __cdecl check_equals_AUPDNNPROEZRJWKB(int a1, unsigned int a2)
{
  int v3; // [esp+8h] [ebp-8h]
  unsigned int i; // [esp+Ch] [ebp-4h]

  v3 = 0;
  for ( i = 0; i < a2; ++i )
  {
    if ( *(_BYTE *)(i + a1) == *(_BYTE *)(i + 134520896) )
      ++v3;
  }
  return v3 == a2;//跑完16位再比较是否匹配
}

可以看出,字符长度为16, 只有和目标串全部匹配一边并且相同才会让v3==v2,而不是只要配对失败就退出循环,根据搜索树,每判断一位字符就出现了两个分支,直到到达第16层,出现了\(2^{16}\)个分支才能最终确定每一位是什么(如果逐字符判断,如果哪一位不对直接退出循环,就相当于加了个剪枝,效率就大大提升,但是本体的写法比较丑),因此对于这种逻辑非常简单,并且会引起路径爆炸的代码我们可以选择手动添加约束并且让程序停止在该代码之前来解决。

完整exp:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/08_angr_constraints')
state = proj.factory.blank_state(addr=0x8048622)
flag = claripy.BVS('flag', 16 * 8)
buffer_addr = 0x804A050 #找到buffer数组的地址
state.memory.store(buffer_addr, flag) #将该地址往后 16 * 8 的内存符号化
simgr = proj.factory.simgr(state)
simgr.explore(find=0x8048669) #跑到check函数之前停下
found = simgr.found[0] #读取当前的simstate状态,为下一步手动添加约束做准备
found.add_constraints(found.memory.load(buffer_addr, 16) == b'AUPDNNPROEZRJWKB') 
#手动添加约束,check的函数名是提示,直接把后缀拿来用就行了
#found.memory.load(addr, size)接口可读取内存数据
print(found.solver.eval(flag, cast_to=bytes))

  • 路径爆炸

路径爆炸的概念与数学中的指数爆炸概念类似,即某些情况下符号执行的路径/状态以指数级增长。

08_angr_constraints中我们发现,即使是非常简单的比较函数,也可能让angr产生指数级的路径(216条路径),耗费大量时间甚至根本跑不出来,这就是符号执行的重大缺陷之一——路径爆炸。这既是我们在用符号执行解决实际问题时需要注意的问题,也是一种可以用来抗符号执行的思路。

应对路径爆炸的方法有很多,甚至还有专门的论文来讲述缓解路径爆炸的方法,在上一题中我们学到了最简单的一种:避开会产生路径爆炸的函数,用手动添加约束替代,这是最简单,也是非常好用的一种方法。总的来说,读者们在使用符号执行引擎时,要识别那些会导致路径爆炸的代码,并巧妙绕过。

【例 4.1 - hooks】09_angr_hooks (对call指令进行hook避免路径爆炸)

反编译发现不同于【例 3.1】,本题的check出现在中间,因此无法使用上题手动添加约束的方法:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  _BOOL4 v3; // eax
  int i; // [esp+8h] [ebp-10h]
  int j; // [esp+Ch] [ebp-Ch]

  qmemcpy(password, "XYMKBKUHNIQYNQXE", 16);
  memset(buffer, 0, 0x11u);
  printf("Enter the password: ");
  __isoc99_scanf("%16s", buffer);
  for ( i = 0; i <= 15; ++i )
    *(_BYTE *)(i + 134520916) = complex_function(*(char *)(i + 134520916), 18 - i);
  equals = check_equals_XYMKBKUHNIQYNQXE(buffer, 16);//引起路径爆炸的check函数
  for ( j = 0; j <= 15; ++j )
    *(_BYTE *)(j + 134520900) = complex_function(*(char *)(j + 134520900), j + 9);
  __isoc99_scanf("%16s", buffer);
  v3 = equals && !strncmp(buffer, password, 0x10u);
  equals = v3;
  if ( v3 )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

由于check_equals函数本身的流程非常简单,那么我们就可以用hook(钩子)技术将check_equals函数替换为一个等效的并且不会导致路径爆炸的函数,然后再进行符号执行 ,先找到代替换函数的入口地址:

@proj.hook(addr=0x80486B3, length=5)
# check_equals_XYMKBKUHNIQYNQXE的地址
#注意这里的hook是对call指令进行了hook,而不是函数本身,length指的是跳过的字节数,call指令占5个字节,所以length=5
def my_check_equals(state):
    buffer_addr = 0x804A054 #buffer的地址
    buffer = state.memory.load(buffer_addr, 16) #载入16个字符
    state.regs.eax = claripy.If(buffer == b'XYMKBKUHNIQYNQXE', claripy.BVV(1, 32), claripy.BVV(0, 32))

这里解释一下claripy.If(lambda,a,b),该表达式的意思是如果表达式labmda成立,则返回a,也就是数字1(32位),否则返回b,也就是数字0(32位)。

解决了路径爆炸以后,直接explore即可:

import angr
import claripy

proj = angr.Project('/home/ep/桌面/09_angr_hooks')

@proj.hook(addr=0x80486B3, length=5)  # check_equals_XYMKBKUHNIQYNQXE
def my_check_equals(state):
    buffer_addr = 0x804A054
    buffer = state.memory.load(buffer_addr, 16)
    state.regs.eax = claripy.If(buffer == b'XYMKBKUHNIQYNQXE', claripy.BVV(1, 32), claripy.BVV(0, 32))

def aim_out(state):
    return b'Good Job.'in state.posix.dumps(1)
def avoid_out(state):
    return b'Try again.'in state.posix.dumps(1)

state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(find = aim_out, avoid = avoid_out)
print(simgr.found[0].posix.dumps(0))

【例4.2 - hooks】10_angr_simprocedures (对函数本身进行hook避免路径爆炸)

反编译感觉没什么毛病:

_BOOL4 __cdecl check_equals_ORSDDWXHZURJRBDH(int a1, unsigned int a2)
{
  int v3; // [esp+8h] [ebp-8h]
  unsigned int i; // [esp+Ch] [ebp-4h]

  v3 = 0;
  for ( i = 0; i < a2; ++i )
  {
    if ( *(_BYTE *)(i + a1) == *(_BYTE *)(i + 134529096) )
      ++v3;
  }
  return v3 == a2;
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+20h] [ebp-28h]
  char s[17]; // [esp+2Bh] [ebp-1Dh] BYREF
  unsigned int v6; // [esp+3Ch] [ebp-Ch]

  v6 = __readgsdword(0x14u);
  memcpy(&password, "ORSDDWXHZURJRBDH", 0x10u);
  memset(s, 0, sizeof(s));
  printf("Enter the password: ");
  __isoc99_scanf("%16s", s);
  for ( i = 0; i <= 15; ++i )
    s[i] = complex_function(s[i], 18 - i);
  if ( check_equals_ORSDDWXHZURJRBDH(s, 16) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

但是一看汇编傻眼了:有一堆check函数,代码逻辑相同,但是地址不同,无法对call指令进行hook了。

于是要引入新方法——SimProcedures(模拟程序)。

我们用一个SimProcedure的子类MyCheckEquals模拟check_equals_ORSDDWXHZURJRBDH函数的功能,SimProcedure中的run函数由子类实现,其接收的参数与C语言中的参数保持一致,返回为对应原函数的返回值。 根据反编译的信息,参数分别是输入字符串的首地址和大小,返回值是0或1

class MyCheckEquals(angr.SimProcedure):
    def run(self, buffer_addr, length):
        buffer = self.state.memory.load(buffer_addr, length)
        return claripy.If(buffer == b'ORSDDWXHZURJRBDH', claripy.BVV(1, 32), claripy.BVV(0, 32))

那么我们如何让程序不跑原来的check函数而是转而去跑我们自己用python写的run函数呢?

proj.hook_symbol(symbol_name='check_equals_ORSDDWXHZURJRBDH', simproc=MyCheckEquals())

只需要调用hook_symbol函数对程序中名为check_equals_ORSDDWXHZURJRBDH的函数进行hook即可。

后面还是老方法:

import angr
import claripy

class MyCheckEquals(angr.SimProcedure):
    def run(self, buffer_addr, length):
        buffer = self.state.memory.load(buffer_addr, length)
        return claripy.If(buffer == b'ORSDDWXHZURJRBDH', claripy.BVV(1, 32), claripy.BVV(0, 32))
    
def aim_out(state):
    return b'Good Job.'in state.posix.dumps(1)
def avoid_out(state):
    return b'Try again.'in state.posix.dumps(1)

proj = angr.Project('/home/ep/桌面/10_angr_simprocedures')
proj.hook_symbol(symbol_name='check_equals_ORSDDWXHZURJRBDH', simproc=MyCheckEquals())
state = proj.factory.entry_state()
simgr = proj.factory.simgr()
simgr.explore(find = aim_out, avoid = avoid_out)
print(simgr.found[0].posix.dumps(0))

【例 4.3 - hooks】11_angr_sim_scanf (对多个参数的scanf函数进行hook)

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [esp+20h] [ebp-28h]
  char s[20]; // [esp+28h] [ebp-20h] BYREF
  unsigned int v7; // [esp+3Ch] [ebp-Ch]

  v7 = __readgsdword(0x14u);
  memset(s, 0, sizeof(s));
  qmemcpy(s, "SUQMKQFX", 8);
  for ( i = 0; i <= 7; ++i )
    s[i] = complex_function(s[i], i);
  printf("Enter the password: ");
  __isoc99_scanf("%u %u", buffer0, buffer1);
  if ( !strncmp(buffer0, s, 4u) && !strncmp(buffer1, &s[4], 4u) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

可以看出scanf函数有多个参数,其实现版本的angr已经可以支持了,这里强行hook一下:

import angr

proj = angr.Project('/home/ep/桌面/11_angr_sim_scanf')
proj.hook_symbol("__isoc99_scanf", angr.SIM_PROCEDURES['libc']['scanf']()) #angr在angr/procedures中定义了很多模拟系统函数的SimProcedures,可以通过angr.SIM_PROCEDURES来获得
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(
    find=lambda state : b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
)
print(simgr.found[0].posix.dumps(0))

【例 5.1 - veritesting】12_angr_veritesting (第三种解决路径爆炸的方法)

反编译后发现还是一个路径爆炸的题目:

int __cdecl complex_function(int a1, int a2)
{
  if ( a1 <= 64 || a1 > 90 )
  {
    puts("Try again.");
    exit(1);
  }
  return (a1 - 65 + 2 * a2) % 26 + 65;
}

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v3; // ebx
  int v5; // [esp-14h] [ebp-60h]
  int v6; // [esp-10h] [ebp-5Ch]
  int v7; // [esp-Ch] [ebp-58h]
  int v8; // [esp-8h] [ebp-54h]
  int v9; // [esp-4h] [ebp-50h]
  const char **v10; // [esp+0h] [ebp-4Ch]
  int v11; // [esp+4h] [ebp-48h]
  int v12; // [esp+8h] [ebp-44h]
  int v13; // [esp+Ch] [ebp-40h]
  int v14; // [esp+10h] [ebp-3Ch]
  int v15; // [esp+10h] [ebp-3Ch]
  int v16; // [esp+14h] [ebp-38h]
  int i; // [esp+14h] [ebp-38h]
  int v18; // [esp+18h] [ebp-34h]
  _DWORD v19[9]; // [esp+1Ch] [ebp-30h] BYREF
  unsigned int v20; // [esp+40h] [ebp-Ch]
  int *p_argc; // [esp+44h] [ebp-8h]

  p_argc = &argc;
  v10 = argv;
  v20 = __readgsdword(0x14u);
  memset((char *)v19 + 3, 0, 0x21u);
  printf("Enter the password: ");
  ((void (__stdcall *)(const char *, char *, int, int, int, int, int, const char **, int, int, int, int, int, int, _DWORD))__isoc99_scanf)(
    "%32s",
    (char *)v19 + 3, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v16, v18, v19[0]);
  v15 = 0;
  for ( i = 0; i <= 31; ++i )
  {
    v3 = *((char *)v19 + i + 3);
    if ( v3 == complex_function(75, i + 93) )
      ++v15;
  }
  
  if ( v15 != 32 || (_BYTE)v20 )
    puts("Try again.");
  else
    puts("Good Job.");
  return 0;
}

Veritesting意为路径归并,具体原理由于链接挂了不得而知,这里只介绍操作,我们只需要在构建simgr的时候添加一个veritesting=True参数即可:

simgr = proj.factory.simgr(state, veritesting=True)

只需要这神奇的一步就可以依靠veritesting原理自动避免路径爆炸。

import angr

proj = angr.Project('/home/ep/桌面/12_angr_veritesting')
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state, veritesting=True) #veritesting值为1,自动避免路径爆炸
simgr.explore(
    find=lambda state : b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
)
print(simgr.found[0].posix.dumps(0))

这种写法与上面的写法是等价的:

simgr = proj.factory.simgr(state)
simgr.use_technique(angr.exploration_techniques.Veritesting())

但是据官方:Versitesting通常与其他exploration_techniques不兼容。

利用veritesting方法避免路径爆炸相对于前两种方法而言比较慢,但是该程序半分钟也能跑完:

【例 6.1 - library】 13_angr_static_binary (识别静态链接库函数)

以下摘自百度百科:

静态编译,就是编译器在编译可执行文件的时候,将可执行文件需要调用的对应静态库(.a或.lib)中的部分提取出来,链接到可执行文件中去,使可执行文件在运行的时候不依赖于动态链接库。

区分对比

与动态编译的区别

动态编译的可执行文件需要附带一个的动态链接库。在执行时,需要调用其对应动态链接库中的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点一是哪怕是很简单的程序,只用到了链接库中的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。

静态编译就是编译器在编译可执行文件的时候,将可执行文件需要调用的对应静态库(.a或.lib)中的部分提取出来,链接到可执行文件中去,使可执行文件在运行的时候不依赖于动态链接库。所以其优缺点与动态编译的可执行文件正好互补。

就比如这个题,反编译后发现有一堆函数:

虽然反编译的main函数和【例 1.1】是一样的,由于此时是静态库,angr无法从动态库种获取链接,因此用原来的脚本无法运行,因此需要我们手动hook:

import angr

proj = angr.Project('/home/ep/桌面/13_angr_static_binary')

proj.hook_symbol('printf', angr.SIM_PROCEDURES['libc']['printf']())
#也可以写成 proj.hook(0x804ED40, angr.SIM_PROCEDURES['libc']['printf']()),这种就是用地址进行hook,但是在函数调用较多的情况下还是直接用函数名比较方便
proj.hook_symbol('__isoc99_scanf',angr.SIM_PROCEDURES['libc']['scanf']())
#__libc_start_main函数会在入口函数_start里被调用,主要是完成一些程序的初始化操作以及main函数的调用,这个函数也把它hook掉
proj.hook_symbol('strcmp', angr.SIM_PROCEDURES['libc']['strcmp']())
proj.hook_symbol('puts', angr.SIM_PROCEDURES['libc']['puts']())
proj.hook_symbol('__libc_start_main',angr.SIM_PROCEDURES['glibc']['__libc_start_main']())
state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(
    find=lambda state : b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
)
print(simgr.found[0].posix.dumps(0))

【例 6.2 - library】14_angr_shared_library ( 对动态链接库中单个的函数进行符号执行 )

反编译发现一个叫validate的函数:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char s[16]; // [esp+1Ch] [ebp-1Ch] BYREF
  unsigned int v5; // [esp+2Ch] [ebp-Ch]

  v5 = __readgsdword(0x14u);
  memset(s, 0, sizeof(s));
  printf("Enter the password: ");
  __isoc99_scanf("%8s", s);
  if ( validate(s, 8) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

本题除了14_angr_shared_library 这个文件外还有一个lib14_angr_shared_library.so,而validate函数就是从这个外部库文件中导入的,我们对这个库文件反编译可以查看validate的源代码:

_BOOL4 __cdecl validate(char *s1, int a2)
{
  char *v3; // esi
  char s2[20]; // [esp+4h] [ebp-24h] BYREF
  int j; // [esp+18h] [ebp-10h]
  int i; // [esp+1Ch] [ebp-Ch]

  if ( a2 <= 7 )
    return 0;
  for ( i = 0; i <= 19; ++i )
    s2[i] = 0;
  qmemcpy(s2, "PVBLVTFT", 8);
  for ( j = 0; j <= 7; ++j )
  {
    v3 = &s1[j];
    *v3 = complex_function(s1[j], j);
  }
  return strcmp(s1, s2) == 0;
}

动态链接库都是地址无关的可执行文件(position-independent executable,PIE),若不手动指定PIE的基质,angr会将符号执行的基址指定为默认的0x400000,当然也可以自行指定基址,方法如下:

base = 0x400000
proj = angr.Project('/home/ep/桌面/lib14_angr_shared_library.so', load_options={
    'main_opts' : {
        'base_addr' : base
    }
})

接下来找到程序的入口和出口:

可以看出validate函数的地址是0x6D7,也就是相对于基准地址偏移了0x6D7

而程序结束是return strcmp(s1, s2) == 0;条件句,以retn结束,返回值保存在寄存器eax中,我们需要得到的是eax == 1这个条件。

注释版脚本:

import angr
import claripy

base = 0x400000  # 设置动态链接库的基址,不唯一
proj = angr.Project(
    "./lib14_angr_shared_library.so", 
    load_options={
        "main_opts": {
            "custom_base_addr": base
        }
    }
)

buffer = claripy.BVV(0, 32) # 新建一个初始值为0(任意值即可)的32位BitVector
validate_addr = base + 0x6d7 # 函数validate的地址,相对于base偏移了0x6D7

state = proj.factory.call_state(validate_addr, buffer, 8) 
# 通过call_state(func_addr,*argv,length) 创建一个函数调用的初始状态,由于是8位字符比较,因此length=8,这个8也可以用一个BV表示:state = proj.factory.call_state(validate_addr, buffer, claripy.BVV(8, 32))

flag = claripy.BVS("flag", 8*8) 
state.memory.store(buffer,flag) #将一个长度为8*8的字符串符号化注入刚才初始化的buffer

aim_addr = base + 0x783  # validate返回地址
simgr = proj.factory.simgr(state)
simgr.explore(find = aim_addr)

sol = simgr.found[0] #得到strcmp的返回值
sol.add_constraints(sol.regs.eax != 0) # 添加配对约束
print(sol.solver.eval(flag, cast_to=bytes))

posted @ 2022-01-23 16:28  EPs1l0h  阅读(351)  评论(0编辑  收藏  举报