Post

MIT 6.858 Course 3 - Buffer Overflow Exploits and Defenses

buffer overflow 缓解措施(续上节)

措施 3. 堆栈不可执行,

硬件支持对内存读、写、执行的权限说明。例如,AMD 的 NX 位,Intel 的 XD 位,Windows DEP(Data Execution Prevention),Linux 的 Pax。可将栈标记为不可执行。一些系统强制 「W^X」,即可写和可执行不能同时存在,但也不支持动态生成代码(同时需要写和执行)。详见可执行空间保护

“w^r” 原则的缺点是会让动态生代码生成变得困难,典型例子是 JIT 编译,必须写代码到 page,这样就要先设置 write bit,然后又要去掉 write bit,然后设置 execute bit,整个个过程变得棘手。

措施 4. 地址随机化

基本原理就是攻击者要使用硬编码的内存地址,随机化阻止攻击者找到地址:就算攻击者可以在本地用 GDB 来识别真实地址,当在实际的运行环境,比如服务器,随机化地址又不一样了。

绕过随机化办法,一种是直接破解随机化,比如随机化种子泄漏、随机化信息泄露;另一种是绕过,攻击者不跳转到具体的地址,而是用如堆喷射技术,用 shellcode 大面积覆盖掉堆,那很可能就刚好命中,或者用大量 NOP 指令来滑向最终的 shellcode。

栈随机化:将栈移动到随机位置,或在栈中变量之间随机填充。攻击者难以猜测返回地址的位置,以及 shellcode 将会被插入到哪里。

ASLR (Address Space Layout Randmization):随机布置栈,堆,动态库。动态链接器为每个库选择随机位置,攻击者难以找到 system() 位置。但也存在以下问题:

  • 在 32 位机器上,可随机比特不够大(1 比特用于区分内核/用户模式,12 比特用于内存映射页与页边界对齐),攻击者可能蛮力猜测位置。
  • 攻击者利用 usleep() 函数,该函数可能位置有 2^16 个或 2^28 个。猜测 usleep(16) 地址并写入返回地址,观察程序是否挂起了 16 秒。
  • 程序产生栈 trace 或错误消息包含指针。
  • 攻击者利用 「Heap spraying」将 shellcode 填满内存,很可能随机跳到 shellcode。

其它措施

改动硬件,使得寄存器装入一个秘密的 key,编译时用 key 来 xor 指令,运行时再动态 xor 回来。这样即使攻击者已经植入了 shellcode,由于不知道 key,这样的 shellcode 仍然无法执行。

缓冲区溢出防御的实践应用

  • gcc 和 MSV C缺省启用金丝雀
  • Linux 和 Windows 缺省包含ASLR和NX
  • 界限检查不太常用,因为:性能代价,需重编译,误报。有时,有些漏报但零误报好于零漏报但有些误报

针对这些消减措施,还有什么攻击方法?

绕过数据不可执行 - Return Oriented Programming

1
2
3
4
5
6
7
8
void run_shell(){
    system("/bin/bash");
}

void process_msg(){
    char buf[128];
    gets(buf);
} 
1
2
3
4
5
6
7
8
9
10
11
12
13
                 +------------------+
entry %ebp ----> | .. prev frame .. |
                 |                  |
                 |                  |
                 +------------------+
entry %esp ----> |  return address  | ^ <--Gets overwritten
                 +------------------+ | with address of
new %ebp ------> |    saved %ebp    | | run_shell()
                 +------------------+ |
                 |     buf[127]     | |
                 |       ...        | |
                 |      buf[0]      | |
new %esp ------> +------------------+ 

上面的例子,构造了一个可直接利用的缓冲区溢出漏洞。但实际环境没这么理想,像这样

1
2
3
4
5
6
7
8
9
char *bash_path = "/bin/bash";
void run_cmd(){
    system("/something/boring");  // 比如 /bin/ls
}

void process_msg(){
    char buf[128];
    gets(buf);
} 

这个情况无法直接利用,但可以组合两个片段:一个是 system 函数,一个是 bash_path 参数,来构造参数控制的 system 函数 shellcode。具体办法是用 GDB 调试,找出各自的地址,然后,按照调用约定,构造栈帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                 +------------------+
entry %ebp ----> | .. prev frame .. |
                 |                  |
                 |                  |
                 |  - - - - - - - - | ^
                 |                  | |Address of bash_path
                 +  - - - - - - - - | |
                 |                  | |Junk return addr for
                 +------------------+ | system()
entry %esp ----> |  return address  | |Address of system()
                 +------------------+ |
new %ebp ------> |    saved %ebp    | |Junk
                 +------------------+ |
                 |     buf[127]     | |
                 |       ...        | |Junk
                 |      buf[0]      | |
new %esp ------> +------------------+ | 

这么排布,是仿造函数调用那一刻的栈结构:

1
2
3
4
5
6
            |        ...       |
            +------------------+
            |     argument     | The system() argument.
            +------------------+
%esp ---->  |    return addr   | Where system() should
            +------------------+ ret after it has finished. 

(对此不熟悉的可以专门了解一些函数调用时的入栈出栈过程。)

那如果需要的 string 不存在呢?没关系,可以在栈上自己构造出来 :) ,此时栈就像下面的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
              |      h\0         | ^
              |  - - - - - - - - | |
              |     /bas         | | 每个地址对应的内存是 4 字节(1 word)
              |  - - - - - - - - | |
              |     /bin         | | <--------------------+
              |  - - - - - - - - | |                      |
              |                  | | Address of bash_path-+
              +  - - - - - - - - | |
              |                  | | Junk return addr from system()
              +------------------+ |
entry %esp -> |  return address  | | Address of system()
              +------------------+ |
new %ebp ---> |    saved %ebp    | | Junk
              +------------------+ |
              |     buf[127]     | |
              |      ...         | | Junk
              |     buf[0]       | |
new %esp ---> +------------------+ | 

这个同样 work。接着,我们注意到上面的例子在 return address 处都随便设置了一个返回地址,因为并不妨碍例子中 bash 的运行(尽管实际返回时程序可能会 crash),但是如果对此返回再加精心构造,把一系列函数串起来,又会产生另外一种非常有趣而强大的攻击手段。

为了实现「串起来」这一目的,要用到 gadgets。一个 gadget 例子:

pop %eax //  将栈顶 pop 到 %eax
ret      //  将栈顶 pop 到 %eip

就是一系列的小的汇编指令,可以用来构造更大型的 exploit,并且在现有程序(binaries)中找到这些 gadgets 并不难,有工具可以使用(例如 msfelfscan)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
             |              | ^
             +  - - - - - - + |
             |              | | Address of bash_path -+ Fake calling
             +  - - - - - - + |                       | frame for
         (4) |              | | Address of pop/ret   -+ system()
             +  - - - - - - + |
         (3) |              | | Address of system()
             +  - - - - - - + |
         (2) |              | | Address of bash_path -+ Fake calling
             +  - - - - - - + |                       | frame for
         (1) |              | | Address of pop/ret   -+ system()
             +--------------+ |
entry %esp-> |return address| | Address of system()
             +--------------+ |
new %ebp --> |  saved %ebp  | | Junk
             +--------------+ |
             |   buf[127]   | |
             |      ...     | | Junk
new %esp --> |    buf[0]    | |
             +--------------+ | 

整个过程:缓冲区一次覆盖了 return addr,当程序 ret 时,eip 使用假的地址执行 system(),此时 esp 来到 (1),按照调用约定,(2) 成为参数,system 返回时,将 esp 的地址弹出到 eip,准备执行 (1) 指令,同时 esp 来到 (2),先执行 pop 来 esp 到 (3),再 ret 将 (3) 的地址弹出到 eip,esp 来到 (4),又是一次 system() 函数调用,返回后又是 pop、ret 循环往复……

这个过程,从宏观层面看其实和传统的计算模型类似,就是提供一串指令序列,通过逐渐递增 instruction pointer,来指导 CPU 执行这串指令,而在上面的 ROP 中,stack pointer(esp)就是前述的 instruction pointer:利用栈来指导要执行的指令块,而指令块又回(ret)到到栈上,循环往复。

综上,ROP 可以用来绕过数据不可执行,因为整个过程都只是利用内存中现成的代码块。

绕过 Stack Canary

基于以下三个假设,来绕过栈金丝雀

  1. server 有一个 buf overflow 漏洞

  2. 如果金丝雀被破坏,server 会重启

  3. 进程重启以后 Canary 和 ASLR 等不再重新随机化 —— 当重启新进程采用 fork 来实现的时候,这个假设成立,因为会继承父进程的虚拟内存空间

那么,实施绕过的过程就不难理解:每次只覆盖 1 字节的 canary,如果程序没有发生 crash(对于远程攻击,可以用 socket 连接没断来判断),恭喜,猜对了一部分,如此反复直到探测出全部 canary。

综合性的绕过 - Blind Return Oriented Programming(BROP)(论文)

本节介绍的技术是在 64bit 环境,这里与 32bit 的主要区别在于:参数使用寄存器传递而非栈。

BROP 的步骤:

STEP 1. 找到一个 stop gadget

所谓 stop 就是跳转到指定返回地址执行后会暂停程序(不是 crash),比如 sleep、loop。

用类似于 stack canary 探测的方法,覆写 ret addr 后,大部分情况 server 会 crash,但是如果一切如常(socket 保持连结),那就是找到了

STEP 2. 找到指令是 pop 栈的的 gadgets

一旦找到了,stop gadget,就可以利用其进一步找到 pop gadgets,通过组合以下三个组件来实现目标

  • probe: 要探测是否是 pop gadget 的地址
  • stop: 一个 stop gadget 地址
  • crash: 导致 crash 的不可执行地址 (0x0)

具体来说,在栈中构造例如下的 shellcode

1
2
3
4
5
6
7
8
9
                        sleep(10)
                        ^      ^
+--- pop rax           /        \
|    ret              /          \
|     \--->[stop]  0x5....      0x5....
|          [trap]  0x0          0x0    <-----------------+
+----------[probe] 0x4...8      0x4...c -->xor rax, rax  | Crash! (这里 xor ... 只是一个可能的例子)
                                           ret           |
                                              \__________| 

上面的例子,当 shellcode 生效时,如果 probe 是 popping gadget 即 pop rax;ret; 时,eip 将跳过 trap 来到 stop 地址,否则将 crash。通过多次这样的探测并找出结果是 stop 的,就能找出好多 popping gadgets 了

更进一步,对 shellcode 进行变种,能找到更复杂类型的 gadget:比如上面的例子再加一个 trap,就能找到「两次 pop」 的 gadget。

到这里,有一点还需要考虑,上面的例子 pop 到寄存器的内容和目标寄存器是未知的,而我们知道 对于 64 bit,参数是用寄存器指示的,这里可以进一步干一些坏事,最终控制系统调用。所以第 3 步:

STEP 3. 找到 syscall() 、确定这些 gadgets pop 到的具体是哪一个寄存器

pause() 是一个无参数(忽略所有寄存器),为了找到 pause(),攻击者把第二步找到的所有「pop xx; ret」gadgets 链起来,把 pause() 的系统调用号作为「参数」一个个推到寄存器中,链的最后一个放置猜测的 syscall() 地址。

这样 shellcode 执行到最后,一大批寄存器都被塞满了「参数」,我们期望包括 rax 寄存器,因为 rax 用于存储 syscall() 的系统调用号参数。这样如果最后猜测的 syscall() 地址命中,就会调用 pause() —— 对攻击者来说这是可以捕获的信号,因为一旦猜错程序会 crash。

到这一步,我们知道已经找到 syscall() 的地址了,但是还有一个问题,到底哪个 gadget pop 的是 rax 寄存器?很简单 —— 逐个尝试其中的每一个 gadget,直到命中使得程序 pause()。用同样的模式,利用其它 syscall 我们可以确定任意一个 gadgets 使用的寄存器。

最终,上面 shellcode 明确了 syscall 的地址,以及 pop xxx 寄存器的 gadget 的地址,可以用于构造下一步的 shellcode 了。

STEP 4. 调用 write()

write() 用于向 server 与 攻击者 client 之间的网络 socket 写入数据,要实现此目的需要以下 gadgets:

pop rdi; ret  //参数1 (socket) 
pop rsi; ret  //参数2 (buffer) 
pop rdx; ret  //参数3 (length)
pop rax; ret  //系统调用号 (write syscall number)
syscall 

socket fd 很好猜测,因为 Linux 将进程限制为最多 1024 个同时打开的文件描述符,并且新的文件描述符必须是可用的最低值(因此猜测一个小文件描述符在实践中效果很好)。而如果猜对,客户端就会收到消息者。

write() 有什么用?攻击者可以将指向程序 .text 段的指针传递给 write,然后就能够读出程序的代码了(之前是地址随机化的,现在则完全暴露了!攻击者不再是 「blind」了),相当于攻击者剥离出了 binary,之后攻击者就可以离线分析二进制,使用 GDB 等工具直接找到更强大的 gadgets,利用这些 gadget是 来打开一个 shell。注:还是那个前提,进程重启随机化信息不变。

以上就是 BROP 的基本工作原理,只是基本原理,论文还有大量具体步骤的优化措施。

怎么防范 BROP

最简单直接,进程 crash 后重新随机化。 —— BROP 之所以能奏效,就是因为没有重新随机化,进程的 crash 可用被用来验证各种推测,

具体来说,用 exec 代替 fork 来起新进程(在 Linux exec 是会重新随机化的,在二进制编译时带了 PIE 参数的情况下);或者用 Windows,因为 Windows 没有 fork,每个新进程都是新的随机化地址空间。

此外,课程中一个学生提到一个比较有趣的问题:能不能当进程 crash 的时候保持中 socket 不中断,使得攻击者无法取得有用的信号?

教授:实现上是没问题的,要保持 socket 不中断,可以捕获 crash(segfault)信号,并在自定义处理函数中保持 socket 连接。但是这样就把漏洞转换成了 DOS 了,因为这样会产生很多的僵尸进程,这些僵尸进程要保持住,否则又泄露信息了。

至于 Bound checking,论文说效率有 2 倍的劣化。

This post is licensed under CC BY 4.0 by the author.