3.1.4 返回导向编程(ROP)(x86)

ROP 简介

返回导向编程(Return-Oriented Programming,缩写:ROP)是一种高级的内存攻击技术,该技术允许攻击者在现代操作系统的各种通用防御下执行代码,如内存不可执行和代码签名等。这类攻击往往利用操作堆栈调用时的程序漏洞,通常是缓冲区溢出。攻击者控制堆栈调用以劫持程序控制流并执行针对性的机器语言指令序列(gadgets),每一段 gadget 通常以 return 指令(ret,机器码为c3)结束,并位于共享库代码中的子程序中。通过执行这些指令序列,也就控制了程序的执行。

ret 指令相当于 pop eip。即,首先将 esp 指向的 4 字节内容读取并赋值给 eip,然后 esp 加上 4 字节指向栈的下一个位置。如果当前执行的指令序列仍然以 ret 指令结束,则这个过程将重复, esp 再次增加并且执行下一个指令序列。

寻找 gadgets

  1. 在程序中寻找所有的 c3(ret) 字节

  2. 向前搜索,看前面的字节是否包含一个有效指令,这里可以指定最大搜索字节数,以获得不同长度的 gadgets

  3. 记录下我们找到的所有有效指令序列

理论上我们是可以这样寻找 gadgets 的,但实际上有很多工具可以完成这个工作,如 ROPgadget,Ropper 等。更完整的搜索可以使用 http://ropshell.com/arrow-up-right

常用的 gadgets

对于 gadgets 能做的事情,基本上只要你敢想,它就敢执行。下面简单介绍几种用法:

  • 保存栈数据到寄存器

    • 将栈顶的数据抛出并保存到寄存器中,然后跳转到新的栈顶地址。所以当返回地址被一个 gadgets 的地址覆盖,程序将在返回后执行该指令序列。

    • 如:pop eax; ret

  • 保存内存数据到寄存器

    • 将内存地址处的数据加载到内存器中。

    • 如:mov ecx,[eax]; ret

  • 保存寄存器数据到内存

    • 将寄存器的值保存到内存地址处。

    • 如:mov [eax],ecx; ret

  • 算数和逻辑运算

    • add, sub, mul, xor 等。

    • 如:add eax,ebx; ret, xor edx,edx; ret

  • 系统调用

    • 执行内核中断

    • 如:int 0x80; ret, call gs:[0x10]; ret

  • 会影响栈帧的 gadgets

    • 这些 gadgets 会改变 ebp 的值,从而影响栈帧,在一些操作如 stack pivot 时我们需要这样的指令来转移栈帧。

    • 如:leave; ret, pop ebp; ret

ROP Emporium

ROP Emporiumarrow-up-right 提供了一系列用于学习 ROP 的挑战,每一个挑战都介绍了一个知识,难度也逐渐增加,是循序渐进学习 ROP 的好资料。ROP Emporium 还有个特点是它专注于 ROP,所有挑战都有相同的漏洞点,不同的只是 ROP 链构造的不同,所以不涉及其他的漏洞利用和逆向的内容。每个挑战都包含了 32 位和 64 位的程序,通过对比能帮助我们理解 ROP 链在不同体系结构下的差异,例如参数的传递等。这篇文章我们就从这些挑战中来学习吧。

这些挑战都包含一个 flag.txt 的文件,我们的目标就是通过控制程序执行,来打印出文件中的内容。当然你也可以尝试获得 shell。

下载文件arrow-up-right

ret2win32

通常情况下,对于一个有缓冲区溢出的程序,我们通常先输入一定数量的字符填满缓冲区,然后是精心构造的 ROP 链,通过覆盖堆栈上保存的返回地址来实现函数跳转(关于缓冲区溢出请查看上一章 3.1.3栈溢出)。

第一个挑战我会尽量详细一点,因为所有挑战程序都有相似的结构,缓冲区大小都一样,我们看一下漏洞函数:

函数 pwnme() 是存在缓冲区溢出的函数,它调用 fgets() 读取任意数据,但缓冲区的大小只有 40 字节(0x0804864a <+84>: lea eax,[ebp-0x28],0x28=40),当输入大于 40 字节的数据时,就可以覆盖掉调用函数的 ebp 和返回地址:

缓冲区距离 ebp 和 eip 的偏移分别为 40 和 44,这就验证了我们的假设。

通过查看程序的逻辑,虽然我们知道 .text 段中存在函数 ret2win(),但在程序执行中并没有调用到它,我们要做的就是用该函数的地址覆盖返回地址,使程序跳转到该函数中,从而打印出 flag,我们称这一类型的 ROP 为 ret2text。

还有一件重要的事情是 checksec:

这里开启了关闭了 PIE,所以 .text 的加载地址是不变的,可以直接使用 ret2win() 的地址 0x08048659

payload 如下(注这篇文章中的paylaod我会使用多种方法来写,以展示各种工具的使用):

ret2win

现在是 64 位程序:

首先与 32 位不同的是参数传递,64 位程序的前六个参数通过 RDI、RSI、RDX、RCX、R8 和 R9 传递。所以缓冲区大小参数通过 rdi 传递给 fgets(),大小为 32 字节。

而且由于 ret 的地址不存在,程序停在了 => 0x400810 <pwnme+91>: ret 这一步,这是因为 64 位可以使用的内存地址不能大于 0x00007fffffffffff,否则就会抛出异常。

re2win() 的地址为 0x0000000000400811,payload 如下:

split32

这一题也是 ret2text,但这一次,我们有的是一个 usefulFunction() 函数:

它调用 system() 函数,而我们要做的是给它传递一个参数,执行该参数后可以打印出 flag。

使用 radare2 中的工具 rabin2 在 .data 段中搜索字符串:

我们发现存在字符串 /bin/cat flag.txt,这正是我们需要的,地址为 0x0804a030

下面构造 payload,这里就有两种方法,一种是直接使用调用 system() 函数的地址 0x08048657,另一种是使用 system() 的 plt 地址 0x8048430,在前面的章节中我们已经知道了 plt 的延迟绑定机制(1.5.6动态链接),这里我们再回顾一下:

绑定前:

绑定后:

其实这里讲 plt 不是很确切,因为 system 使用太频繁,在我们使用它之前,它就已经绑定了,在后面的挑战中我们会遇到没有绑定的情况。

两种 payload 如下:

注意 "BBBB" 是新的返回地址,如果函数 ret,就会执行 "BBBB" 处的指令,通常这里会放置一些 pop;pop;ret 之类的指令地址,以平衡堆栈。从 system() 函数中也能看出来,它现将 esp 减去 0xc,再取地址 esp+0x10 处的指令,也就是 "BBBB" 的后一个,即字符串的地址。因为 system() 是 libc 中的函数,所以这种方法称作 ret2libc。

split

字符串地址在 0x00601060

64 位程序的第一个参数通过 edi 传递,所以我们需要再调用一个 gadgets 来将字符串的地址存进 edi。

我们先找到需要的 gadgets:

下面是 payload:

那我们是否还可以用前面那种方法调用 system() 的 plt 地址 0x4005e0 呢:

依然可以,因为参数的传递没有用到栈,我们只需把地址直接更改就可以了:

callme32

这里我们要接触真正的 plt 了,根据题目提示,callme32 从共享库 libcallme32.so 中导入三个特殊的函数:

我们要做的是依次调用 callme_one()callme_two()callme_three(),并且每个函数都要传入参数 123。通过调试我们能够知道函数逻辑,callme_one 用于读入加密后的 flag,然后依次调用 callme_twocallme_three 进行解密。

由于函数参数是放在栈上的,为了平衡堆栈,我们需要一个 pop;pop;pop;ret 的 gadgets:

或者是 add esp, 8; pop; ret,反正只要能平衡,都可以:

构造 payload 如下:

callme

64 位程序不需要平衡堆栈了,只要将参数按顺序依次放进寄存器中就可以了。

payload 如下:

write432

这一次,我们已经不能在程序中找到可以执行的语句了,但我们可以利用 gadgets 将 /bin/sh 写入到目标进程的虚拟内存空间中,如 .data 段中,再调用 system() 执行它,从而拿到 shell。要认识到一个重要的点是,ROP 只是一种任意代码执行的形式,只要我们有创意,就可以利用它来执行诸如内存读写等操作。

这种方法虽然好用,但还是要考虑我们写入地址的读写和执行权限,以及它能提供的空间是多少,我们写入的内容是否会影响到程序执行等问题。如我们接下来想把字符串写入 .data 段,我们看一下它的权限和大小等信息:

可以看到 .data 具有 WA,即写入(write)和分配(alloc)的权利,而 .rodata 就不能写入。

使用工具 ropgadget 可以很方便地找到我们需要的 gadgets:

另外需要注意的是,我们这里是 32 位程序,每次只能写入 4 个字节,所以要分成两次写入,还得注意字符对齐,有没有截断字符(\x00,\x0a等)之类的问题,比如这里 /bin/sh 只有七个字节,我们可以使用 /bin/sh\00 或者 /bin//sh,构造 payload 如下:

write4

64 位程序就可以一次性写入了。

badchars32

在这个挑战中,我们依然要将 /bin/sh 写入到进程内存中,但这一次程序在读取输入时会对敏感字符进行检查,查看函数 checkBadchars()

很明显,地址 0x080488070x08048823 的字符就是所谓的敏感字符。处理敏感字符在利用开发中是经常要用到的,不仅仅是要对参数进行编码,有时甚至地址也要如此。这里我们使用简单的异或操作来对字符串编码和解码。

找到 gadgets:

整个利用过程就是写入前编码,使用前解码,下面是 payload:

badchars

64 位程序也是一样的,注意参数传递就好了。

fluff32

这个练习与上面没有太大区别,难点在于我们能找到的 gadgets 不是那么直接,有一个技巧是因为我们的目的是写入字符串,那么必然需要 mov [reg], reg 这样的 gadgets,我们就从这里出发,倒推所需的 gadgets。

我们看到一个这样的 mov dword ptr [ecx], edx ;,可以想到我们将地址放进 ecx,将数据放进 edx,从而将数据写入到地址中。payload 如下:

fluff

提示:在使用 ropgadget 搜索时加上参数 --depth 可以得到更大长度的 gadgets。

pivot32

这是挑战的最后一题,难度突然增加。首先是动态库,动态库中函数的相对位置是固定的,所以如果我们知道其中一个函数的地址,就可以通过相对位置关系得到其他任意函数的地址。在开启 ASLR 的情况下,动态库加载到内存中的地址是变化的,但并不影响库中函数的相对位置,所以我们要想办法先泄露出某个函数的地址,从而得到目标函数地址。

通过分析我们知道该程序从动态库 libpivot32.so 中导入了函数 foothold_function(),但在程序逻辑中并没有调用,而在 libpivot32.so 中还有我们需要的函数 ret2win()

现在我们知道了可以泄露的函数 foothold_function(),那么怎么泄露呢。前面我们已经简单介绍了延时绑定技术,当我们在调用如 func@plt() 的时候,系统才会将真正的 func() 函数地址写入到 GOT 表的 func.got.plt 中,然后 func@plt() 根据 func.got.plt 跳转到真正的 func() 函数上去。

最后是该挑战最重要的部分,程序运行我们有两次输入,第一次输入被放在一个由 malloc() 函数分配的堆上,当然为了降低难度,程序特地将该地址打印了出来,第二次的输入则被放在一个大小限制为 13 字节的栈上,这个空间不足以让我们执行很多东西,所以需要运用 stack pivot,即通过覆盖调用者的 ebp,将栈帧转移到另一个地方,同时控制 eip,即可改变程序的执行流,通常的 payload(这里称为副payload) 结构如下:

这样函数的返回地址就被覆盖为 leave;ret 指令的地址,这样程序在执行完其原本的 leave;ret 后,又执行了一次 leave;ret。

另外 fake ebp 指向我们另一段 payload(这里称为主payload) 的 ebp,即 主payload 地址减 4 的地方,当然你也可以在构造 主payload 时在前面加 4 个字节的 padding 作为 ebp:

我们知道一个函数的入口点通常是:

leave 指令相当于:

ret 指令为相当于:

如果遇到一种情况,我们可以控制的栈溢出的字节数比较小,不能完成全部的工作,同时程序开启了 PIE 或者系统开启了 ASLR,但同时在程序的另一个地方有足够的空间可以写入 payload,并且可执行,那么我们就将栈转移到那个地方去。

完整的 exp 如下:

这里我们在 gdb 中验证一下,在 pwnme() 函数的 leave 处下断点:

执行第一次 leave;ret 之前,我们看到 EBP 指向 fake ebp,即 0xf755cf0c,fake ebp 指向 主payload 的 ebp,而在 fake ebp 后面是 leave;ret 的地址 0x0804889f,即返回地址。

执行第一次 leave:

EBP 的值 0xffe7ec68 被赋值给 ESP,然后从栈中弹出 0xf755cf0c,即 fake ebp 并赋值给 EBP,同时 ESP+4=0xffe7ec6c,指向第二次的 leave。

执行第一次 ret:

EIP=0x804889f,同时 ESP+4。

第二次 leave:

EBP 的值 0xf755cf0c 被赋值给 ESP,并将 主payload 的 ebp 赋值给 EBP,同时 ESP+4=0xf755cf10,这个值正是我们 主payload 的地址。

第二次 ret:

成功跳转到 foothold_function@plt,接下来系统通过 _dl_runtime_resolve 等步骤,将真正的地址写入到 .got.plt 中,我们构造 gadget 泄露出该地址地址,然后计算出 ret2win() 的地址,调用它,就成功了。

地址泄露的过程:

pivot

基本同上,但你可以尝试把修改 rsp 的部分也用 gadgets 来实现,这样做的好处是我们不需要伪造一个堆栈,即不用管 ebp 的地址。如:

实际上,我本人正是使用这种方法,因为我在构建 payload 时,0x0000000000400ae0 <+165>: leave,leave;ret 的地址存在截断字符 0a,这样就不能通过正常的方式写入缓冲区,当然这也是可以解决的,比如先将 0a 换成非截断字符,之后再使用寄存器将 0a 写入该地址,这也是通常解决缓冲区中截断字符的方法,但是这样做难度太大,不推荐,感兴趣的读者可以尝试一下。

这样基本的 ROP 也就介绍完了,更高级的用法会在后面的章节中再介绍,所谓的高级,也就是 gadgets 构造更加巧妙,运用操作系统的知识更加底层而已。

更多资料

Last updated