【angr_ctf】二进制分析工具angr使用与练习-Part II(提高篇)

前言

本文是angr_ctf练习的第二篇,上一篇【angr_ctf】二进制分析工具angr使用与练习-Part I(基础篇)主要讲解了其中的前7道题目,本篇为提高篇,在难度上会有所提升,许多细节在上一篇说过的,这里不再赘述,直接进入题目分析。

08_angr_constraints

本题考查对约束求解中约束条件的使用,以解决符号执行中常见的路径爆炸的问题

关于符号执行的路径爆炸问题,可以参考阅读符号执行入门 - 知乎 (zhihu.com),这里我们重点放在对题目的理解上,先看一下反编译的源码:

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

  password = 1247302221;
  dword_804A044 = 1381587531;
  dword_804A048 = 1162562891;
  dword_804A04C = 1113212494;
  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_MRXJKZYRKMKENFZB((int)&buffer, 0x10u) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

_BOOL4 __cdecl check_equals_MRXJKZYRKMKENFZB(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 + 0x804A040) )
      ++v3;
  }
  return v3 == a2;
}

程序对输入的16字节数据的每一位做了complex_function变换,然后对字符串中每一个字符依次比较,虽然函数名称告诉了要比较的字符串为MRXJKZYRKMKENFZB,但我们还是从源码上解读一下:

程序一开始初始化了4个全局变量,将其转换成字符串表示,得到结果如下:

再看4个变量在.bss段中存储的地址:

结合比较函数中*(_BYTE *)(i + a1) == *(_BYTE *)(i + 0x804A040),刚好实现了对应字符的比较。

这里有个注意的点,因为程序采用小端序,因此保存的字符串是JXRM,在比较的时候则是MRXJ

由于在比较的时候循环比较单个字符(共16个),因此做符号执行的时候会存在$2^{16}=65536$条路径,直接原地爆炸,根据我们的分析,实际上只需要在进入check_equals_MRXJKZYRKMKENFZB()函数时,存储在buffer中的字符串为MRXJKZYRKMKENFZB即可,这相当于我们的约束条件,避免进入循环一一比较字符。

因此,这里首先确定两个地址:

  • buffer地址:需要往此处插入符号变量
  • check_equals_MRXJKZYRKMKENFZB函数地址:到此处的时候直接根据约束条件进行求解

通过ida可以直接查看到这两处数值:

起始地址直接选择scanf之后的两个地址其一即可,我选择的是0x08048622

本题还用到了state.memory.load(addr, bytes)函数,用于在指定内存地址中读取指定字节的数据,这部分是为了我们直接获取buffer的内容,然后和我们的目标字符串比较。

下面是题解,我在必要的地方添加了注释,帮助理解:

import angr
import claripy


def main():
    path_to_binary = "./08_angr_constraints"
    project = angr.Project(path_to_binary, auto_load_libs=False)

    start_address = 0x8048622
    buff_addr = 0x0804A050
    address_to_check_constraint = 0x08048565

    initial_state = project.factory.blank_state(addr=start_address)

    passwd_length = 16
    password0 = claripy.BVS('password0', passwd_length * 8)
    # 在buffer的内存地址中添加符号变量
    initial_state.memory.store(buff_addr, password0)

    simulation = project.factory.simgr(initial_state)
    # 寻找进行字符比较函数的路径,不直接进入该路径
    simulation.explore(find=address_to_check_constraint)

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

        constrained_parameter_address = buff_addr
        constrained_parameter_size_bytes = 16

        # 利用memory.load接口读取buffer处内存数据,用于比较,读取结果为BV
        constrained_parameter_bitvector = solution_state.memory.load(
            constrained_parameter_address,
            constrained_parameter_size_bytes
        )
        # 约束条件中需要直接比较的字符串
        constrained_parameter_desired_value = 'MRXJKZYRKMKENFZB'
        # 添加约束条件
        solution_state.solver.add(constrained_parameter_bitvector == constrained_parameter_desired_value)
        # 约束求解
        solution0 = solution_state.solver.eval(password0, cast_to=bytes)
        print("[+] Success, flag is: {}".format(solution0.decode('utf-8')))
    else:
        raise Exception('Could not find the solution')


if __name__ == "__main__":
    main()

09_angr_hooks

本题考查对angrhook的使用,替代直接的约束条件

上源码:

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

  qmemcpy(password, "MRXJKZYRKMKENFZB", 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_MRXJKZYRKMKENFZB((int)buffer, 0x10u);
  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;
}

_BOOL4 __cdecl check_equals_MRXJKZYRKMKENFZB(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 + 0x804A044) )
      ++v3;
  }
  return v3 == a2;
}

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

还是一样的味道,程序预先将password设定为MRXJKZYRKMKENFZB,然后对用户的输入进行变换比较,在变换的时候采用了单个字符的循环对比,同样地,我们需要在程序执行到这部分的时候接管对比的操作。

之前,我们是先explore找到check的地址,然后比较此时buffer在内存中存储的字符串是否与目标相符。angr提供了更为强大的操作,即hook

百度百科中对hook的解释:Hook是Windows中提供的一种用以替换DOS下“中断”的系统机制,中文译为“挂钩”或“钩子”。在对特定的系统事件进行hook后,一旦发生已hook事件,对该事件进行hook的程序就会收到系统的通知,这时程序就能在第一时间对该事件做出响应。

在编程中采用hook,则是希望在指定事件发生时,能够接管操作,跳过相应指令的执行。据此,我们只要找到check_equals_MRXJKZYRKMKENFZB函数的地址,在此处添加hook,实现buffer中字符串与password的比较即可。

关于angrhook的具体使用可以参考Programming SimProcedures - angr Documentation

angr中对于hook的使用有两种方式:

  • 一是装饰器:传入需要hook的地址和要跳过的指令长度,当程序执行到该地址时,执行hook_func中的内容,返回后继续在hook_addr+skip_length的地方继续执行(若length=0则不会触发hook)。该方法适用于函数不需要参数,也不考虑函数返回值的时候,当你要处理的函数包含对参数的操作,并且需要处理返回值,此时采用方法二

    @project.hook(hook_addr, length=instruction_length_to_skip)
    def hook_func(state):
        # do somethings
  • 二是hook_symbol:通过继承angr.SimProcedure,并重写run方法,可以实现对一个原始函数的完全模拟,包括函数的参数和返回值,下面演示了一个对原始函数的hook过程

    # 原始函数
    int add_if_positive(int a, int b) {
        if (a >= 0 && b >= 0) return a + b;
        else return 0;
    }
    
    # hook
    class ReplacementAddIfPositive(angr.SimProcedure):
        def run(self, a, b):
            if a >= 0 and b >=0:
                return a + b
            else:
                return 0
    func_name = 'add_if_positive'
    project.hook_symbol(func_name, ReplacementAddIfPositive())

除此之外,还需要思考一个问题,对于hook装饰的函数,我们如何提供函数的返回值(检查对比结果)?

为此,我们先对函数本身如何处理返回值进行查看:

angr中通过claripy.If(条件,条件为True时的返回值,条件为False时的返回值)实现,我们只需要将条件设置为两字符串比较,相同时返回1(BV),不同时返回0(BV)即可。

最后,我们找一下需要的地址,由于我们目的是接管check函数部分的指令,因此程序的初始化状态选择入口状态即可(不需要自定义一个空白状态,传入符号变量):

  • 检测函数check_equals_MRXJKZYRKMKENFZB地址

image-20210506223436710

  • buffer地址

注意到下一条指令和函数check_equals_MRXJKZYRKMKENFZB之间间隔5个字节,因此我们要跳过的长度即5字节

下面是最终的题解,我会在必要的地方添加注释,帮助理解:

import angr
import claripy
import sys


def main():
    path_to_binary = '09_angr_hooks'
    project = angr.Project(path_to_binary)

    # 直接从入口开始
    initial_state = project.factory.entry_state()


    check_equals_called_address = 0x080486B3

    # 检测函数的长度
    instruction_to_skip_length = 5

    @project.hook(check_equals_called_address, length=instruction_to_skip_length)
    def skip_check_equals_(state):
        # 此时需要从内存中读取buffer的内容,设定buffer的地址和要读取的长度
        user_input_buffer_address = 0x0804A054  # :integer, probably hexadecimal
        # 字符串长度16字节
        user_input_buffer_length = 16

        # 从内存中读取数据
        user_input_string = state.memory.load(
            user_input_buffer_address,
            user_input_buffer_length
        )


        check_against_string = 'MRXJKZYRKMKENFZB'  # :string

        # 如果相等返回1,否则返回0
        state.regs.eax = claripy.If(
            user_input_string == check_against_string,
            claripy.BVV(1, 32),
            claripy.BVV(0, 32)
        )

    simulation = project.factory.simgr(initial_state)

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


    def should_abort(state):
        stdout_output = state.posix.dumps(sys.stdout.fileno())
        return True if 'Try again.' in stdout_output.decode('utf-8') else 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, flag is {}'.format(solution.decode('utf-8')))
    else:
        raise Exception('Could not find the solution')

if __name__ == '__main__':
    main()

10_angr_simprocedures

本题依旧是对hook的使用,只不过上一题采用函数地址,本次采用函数名,相比函数地址更加方便

看下源码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  signed int i; // [esp+20h] [ebp-28h]
  char s[17]; // [esp+2Bh] [ebp-1Dh]
  unsigned int v6; // [esp+3Ch] [ebp-Ch]

  v6 = __readgsdword(0x14u);
  memcpy(&password, "MRXJKZYRKMKENFZB", 0x10u);
  memset(s, 0, 0x11u);
  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_MRXJKZYRKMKENFZB(s, 16) )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}


_BOOL4 __cdecl ''check_equals_MRXJKZYRKMKENFZB''(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;
}

程序与上一题一样,本题需要用到angr.SimProcedure来替代二进制文件中指定函数的部分,说白了就是自己写一个函数替代原有的函数,很明显我们针对的是check_equals_MRXJKZYRKMKENFZB()

angr.SimProcedure实现了用python编写替代原有函数的功能,通过继承SimProcedure,重写run(self, *args)方法,传入的参数(BV类型)和原有函数的参数对应,并按照pythonic的方式返回,用以提供给angr像原来函数一样处理。

下图展示了这一思想:

前面09题说了,我们有两种hook方式,这里因为我们要处理参数和返回值,因此采用继承angr.SimProcedure的方式。

下面是题解,我会在必要的地方添加注释,帮助理解:

import angr
import claripy
import sys


def main():
    path_to_binary = '10_angr_simprocedures'
    project = angr.Project(path_to_binary)

    initial_state = project.factory.entry_state()

    # 通过继承angr.SimProcedure实现对模拟函数的创建
    class ReplacementCheckEquals(angr.SimProcedure):
        # 重写run方法,self之后的参数与原始函数是对应的
        def run(self, to_check, length):
            # 对比原是函数,我们知道第一个参数是字符串的起始地址,第二个是字符串长度
            user_input_buffer_address = to_check
            user_input_buffer_length = length

            # 利用memory.load方法,从已知内存地址中读取指定长度的内容
            user_input_string = self.state.memory.load(
                user_input_buffer_address,
                user_input_buffer_length
            )

            check_against_string = 'MRXJKZYRKMKENFZB'

            # 这部分是我们比较过程,和前面一题是一样的,利用claripy.If,第一个参数是布尔判断条件,为真返回第二个参数,
            # 为假返回第三个参数,参数返回值应该是bitvector常量
            return claripy.If(
                user_input_string == check_against_string,
                claripy.BVV(1, 32),
                claripy.BVV(0, 32),
            )

    # 调用hook_symbol前,确定需要hook的函数名称,因为hook_symbol是基于函数名进行API调用的
    check_equals_symbol = 'check_equals_MRXJKZYRKMKENFZB' # :string
    project.hook_symbol(check_equals_symbol, ReplacementCheckEquals())

    # 后面的和前一题一样
    simulation = project.factory.simgr(initial_state)

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

    def should_abort(state):
        stdout_output = state.posix.dumps(sys.stdout.fileno())
        return True if 'Try again.' in stdout_output.decode('utf-8') else 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, flag is {}'.format(solution.decode('utf-8')))
    else:
        raise Exception('Could not find the solution')

if __name__ == '__main__':
    main()

11_angr_sim_scanf

本题学习hook scanf函数,之前一直是在scanf之后对输入的结果做符号化,本次直接模拟scanf的执行

老规矩,源码走一波:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  _BOOL4 v3; // eax
  signed int i; // [esp+20h] [ebp-28h]
  char s[4]; // [esp+28h] [ebp-20h]
  int v7; // [esp+2Ch] [ebp-1Ch]
  unsigned int v8; // [esp+3Ch] [ebp-Ch]

  v8 = __readgsdword(0x14u);
  memset(s, 0, 0x14u);
  *(_DWORD *)s = 'ZREX';
  v7 = 'BNTZ';
  for ( i = 0; i <= 7; ++i )
    s[i] = complex_function(s[i], i);
  printf("Enter the password: ");
  __isoc99_scanf("%u %u", buffer0, buffer1);
  v3 = !strncmp(buffer0, s, 4u) && !strncmp(buffer1, (const char *)&v7, 4u);
  if ( v3 )
    puts("Good Job.");
  else
    puts("Try again.");
  return 0;
}

程序就不解释了,因为本题是要模拟scanf,依照上题对函数模拟的思路:

  1. 首先,我们先将模拟的部分摸清楚,对于scanf,首先第一个传入的参数是格式化字符串%u %u(函数调用的时候也是先将格式化字符串压栈),然后依次传入buffer0,buffer1
  2. 接下来我们要模拟输入两个变量到buffer中,本质上就是写入两个符号变量到内存里,因此,在模拟函数中,创建claripy.BVS('scanf', 32)对象,并利用state.memory.store()方法将变量写入内存

  3. 由于定义的符号变量位于run()方法内部,使得在simulation.found之后无法进行约束求解,因此还需要将这个两个局部变量放到全局中,解决方法可以是改用全局变量,然后在run内部使用global关键字,或者采用state.globals[key]=value的方式存储。

下面是题解,我会在必要的地方添加注释,帮助理解:

import angr
import claripy
import sys


def main():
    path_to_binary = '11_angr_sim_scanf'
    project = angr.Project(path_to_binary, auto_load_libs=False)

    initial_state = project.factory.entry_state()

    class ReplacementScanf(angr.SimProcedure):
        # run现在模拟scanf,第一个参数是格式化字符串,接着传入两个buffer地址
        def run(self, format_string, scanf0_address, scanf1_address):

            # 定义两个符号变量,用于写入buffer
            scanf0 = claripy.BVS('scanf0', 32)
            scanf1 = claripy.BVS('scanf1', 32)

            # 写入内存,最后一个参数指定字节序
            self.state.memory.store(scanf0_address, scanf0, endness=project.arch.memory_endness)
            self.state.memory.store(scanf1_address, scanf1, endness=project.arch.memory_endness)

            # 保存变量到全局,方便后续约束求解
            self.state.globals['solution0'] = scanf0
            self.state.globals['solution1'] = scanf1

    # scanf的函数名称,用ida反编译得到的
    scanf_symbol = '__isoc99_scanf'
    project.hook_symbol(scanf_symbol, ReplacementScanf())

    simulation = project.factory.simgr(initial_state)

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

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

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

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

        # 对符号变量进行约束求解
        stored_solutions0 = solution_state.globals['solution0']
        stored_solutions1 = solution_state.globals['solution1']
        scanf0_solution = solution_state.solver.eval(stored_solutions0)
        scanf1_solution = solution_state.solver.eval(stored_solutions1)

        print('[+] Success, flag is {} {}'.format(scanf0_solution, scanf1_solution))

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

if __name__ == '__main__':
    main()

image-20210510213720026

总结

本文提高篇主要核心在于对于约束条件和hook的学习,通过约束条件能够解决一般的路径爆炸问题(如存在循环),而hook能够对于程序执行到指定的代码部分,接管操作,跳过会产生路径爆炸的代码或模拟我们不希望执行的代码(达到同样的目的)。这里分别对做完的4道题目总结一下:

  • 08_angr_constraints:第一个出现循环的代码,也是产生路径爆炸的地方,为了避免,我们find指定位置,并通过对比此时内存中的字符串和目标串,从而无需执行循环也能达到同样的效果。
  • 09_angr_hooks:在上一题的基础上利用了angrhook,接管指定位置的代码程序,这使得程序能够跳过某一部分代码的执行,但此时是依据目标代码的地址和要跳过的字节数确定的。
  • 10_angr_simprocedures:跟上题一样,只不过此处是利用函数名进行hook,扩展性和可读性都得到了提高,也是认识SimProcedures的一个过程
  • 11_angr_sim_scanf:对scanf函数进行了模拟,可以和以前操作scanf之后修改寄存器,修改内存,修改栈的几题做对比,该方法通用性更高。

参考