栈迁移原理介绍与应用
本文将对CTF Pwn中「栈迁移」(又称「栈转移」)这一技术进行介绍与分析,希望读完本文后以下问题将不再困扰你:
- 什么是栈迁移?
- 栈迁移解决了什么问题?
- 怎么使用栈迁移这个技巧?
- CTF Pwn是在做什么?提权(Getshell)是什么意思?
- 在操作系统内存布局中,栈是一种怎样的结构,具有怎样的特点?
- x86中常用寄存器的名称与作用?函数调用栈的原理与过程是怎样的?
- 栈溢出攻击的核心技巧是什么?
栈溢出是怎么回事?
在预备知识的4个问题中,也许第四个会困扰到你。其实,如果知道了前三个问题的答案,联想编程时偶尔出现的「Error:Index out of bound」报错,栈溢出(stackoverflow)是怎么一回事也非常简单了。下图是一个函数栈布局的常见状态。 顾名思义,栈溢出就是当外界输入过长时,将会超过局部变量(常为数组)的「势力范围」,从而造成数据溢出;如下图所示。 因此,栈溢出能使我们覆盖栈上某些区域的值,甚至是当前函数的返回地址 ret ;一旦 ret 覆盖为某个奇怪的值,例如 0xdeadbeaf,当函数结束恢复现场,即 eip 指向 ret 时,程序将会跳转到内存中的 0xdeadbeaf 处。此时,内核会立即告诉我们“SIGSEV”,即常见的段错误(Segment Fault)。 问题来了,如果不是一个奇怪的值呢?如果是一个合法的地址呢?如果是程序中另外某个函数甚至是shellcode的地址呢?因此,一旦程序缓冲区变量可以被恶意用户控制,而且栈空间足够大,程序原有执行流很可能会被破坏。 这就是栈溢出攻击的核心原理。那栈迁移是什么?
包租婆,怎么没水了呢?
在完成一般的栈溢出攻击时,有一个充分条件是「栈上有足够的地方让攻击者进行布局」。通常的函数栈剩余空间是足够放置一些恶意指令的,但也有少数极端情况,例如仅能容纳一个 ret与一个 ebp。此时,一般的栈溢出攻击方法将由于空间太小而不再适用。 “包租婆,怎么没水了呢?”
“栈,你怎么没地方了呢?”
没水,那我们自己去找水
既然此处的函数栈无我容身之地,那不妨另换一处来打运动战。这便是「栈迁移」的核心思想。所以该如何调动栈上的布局呢? 要知道这个问题的答案,首先要回顾一个函数在被调用以及结束时的汇编代码以及栈的变化。 如图所示,当上层函数调用foo函数,即 eip 执行到call foo指令时,call 指令以及foo函数开头的指令依次做了如下事情来「保护现场」:- 牢记foo结束后应从哪里继续执行(保存当前 eip下面的位置到栈中,即 ret);
- 牢记上层函数的栈底位置(保存当前 ebp 的内容到栈中,即为old ebp);
- 牢记foo函数栈开始的位置(保存当前栈顶的内容到 ebp,便于foo函数栈内的寻址);
- 清空当前函数栈以还原栈空间(直接移动栈顶指针 esp 到当前函数的栈底 ebp );
- 还原栈底(将此时 esp 所指的上层函数栈底 old ebp 弹入 ebp 寄存器内);
- 还原执行流(将此时 esp 所指的上层函数调用foo时的地址弹入 eip 寄存器内);
栈迁移
理解上文后,相信你已经明白了栈迁移的核心思想,下面给出完整的栈迁移攻击实施过程。Step1. 首先确定缓冲区变量在溢出时,至少能覆盖栈上 ebp 与 ret 两个位置。之后,选取栈要被劫持到的地址;例如,若能在bss等内存段上执行shellcode,则可将栈迁移到shellcode开始处。记该地址为 HijackAddr Step2. 寻找程序中一段 leave ret gadget的地址,记该地址为 LeaveRetAddr
Step3. 设置缓冲区变量,使其将栈上 ebp 覆盖为 HijackAddr+4,将 ret 覆盖为LeaveRetAddr
Step4. 程序执行至函数结束时,将依次发生如下事件:
- 执行指令:mov esp, ebp,还原栈顶指针至当前函数栈底;此时 esp 指向栈上被篡改的 ebp 数据;
- 执行指令:pop ebp,将篡改的HijackAddr+4放入 ebp 寄存器内;此时 esp 上移,指向栈上被篡改的 ret 数据;
3. 执行指令:pop eip,将LeaveRetAddr放入eip寄存器内,篡改执行流;
4. 执行指令:mov esp, ebp,将HijackAddr+4移入 esp 寄存器内,即栈顶指针指向 HijackAddr+4;
5. 执行指令:pop ebp,无实际效用,ebp寄存器仍为HijackAddr+4,但此时esp 上移,指向HijackAddr;
6. 执行指令:pop eip,将HijackAddr移入eip 内,成功篡改执行流至shellcode区域; Step5. 栈顶指针被劫持,程序执行shellcode,攻击结束。其中,Step1、Step4.2、Step4.4三个关键步骤对应的示意图如下所示。 一个形象点的过程如下。 " ebp:来嘛 esp:来了(mov esp, ebp) ebp:坏了(pop ebp) esp:你咋了 ebp:来嘛 esp:?这不来了(mov esp, ebp) esp: 坏了 eip: 坏了(pop eip) "栈迁移的效果是怎样的呢?
在CTF Pwn中如果遇到栈空间过小的情况,则可以考虑使用栈迁移技术。下面以 BUUOJ 中 Pwn 的 ciscn_2019_es_2 一题为例进行介绍。 首先使用 checksec 观察二进制文件 ciscn_2019_es_2 的保护属性,发现仅「NX 栈执行保护」是开启的。之后,将题目给出的二进制文件拖入IDA 32bit,容易发现在 vuln 函数中,直接使用 read 函数读取输入到栈上,如下图所示。此外,二进制文件中存在着一 hack 函数,该函数调用了 system,但并不能直接打印flag。因此,利用 read 函数也许可以覆盖栈上数据并写入 /bin/sh,使其执行 system 以getshell。 然而,栈上变量 s 位于 ebp-0x28,而 read 函数仅能读入0x30个字节,那么若想实施缓冲区溢出,只有0x08 = 0x30-0x28个字节供我们进行布局。因此,在只有 ebp 与 ret 能被篡改的条件下可尝试使用栈迁移技术。
判定栈迁移的实施条件
栈迁移能被实施的条件有二:- 存在 leave ret 这类gadget指令
- 存在可执行shellcode的内存区域
分析与栈迁移的实施
根据前文,首先要明确getshell最终要在哪里进行。在本题中,不能直接在 bss 等段写入shellcode,而是应设法调用 system 等gadget,则可利用的区域仅有缓冲区变量 s 所覆盖的0x28个字节。因此,我们最终要将 esp(与 ebp)劫持到当前栈的另一区域上,以完成传统栈溢出payload的实施。Step1. 确定劫持地址与偏移
注意到文件提供了 printf 这一输出函数,该函数在未遇到终止符 '\0' 时会一直输出。利用该特性可帮助我们泄露出栈上的地址,从而能计算出要劫持到栈上的准确地址。 在本题中,劫持目标地址即为缓冲区变量 s 的起始地址。要计算这一地址,可采取 栈上ebp + 偏移量 的方法。其中,栈上ebp可由 printf 函数泄露得到,偏移量的确定则需要进行调试分析。如图所示,可在 vuln 函数中 0x80485fc 的 nop 处设置断点,在运行时仅输入 aaaa 进行定位即可。由图可知,此时 esp 位于 0xffffd2a0 处,即缓冲区变量开头的'aaaa',ebp寄存器位于 0xffffd2d8,而该地址所存内容,即栈上 ebp 为 0xffffd2d8,为上层main函数的old ebp。old ebp 与 缓冲区变量 相距 0x38,这说明只要使用 printf 泄露出攻击时栈上ebp所存地址,将该地址减去0x38即为 s 的准确地址,即栈迁移最终要劫持到的地方。
Step2. 设计栈迁移攻击过程
之后就是栈迁移大展神通的地方了。要完成栈迁移的攻击结构,就要覆盖原栈上 ret为 leave ret gadget的地址,本题中可覆盖为 0x080484b8;要将esp劫持到 old_ebp -0x38处,就要将原ebp中的 old_ebp 覆盖为old_ebp -0x38,其中 old_ebp 可通过第一次 read & printf 泄露得到。此时栈迁移payload的框架如下图所示。在上图中的Payload中, vuln 函数正常执行到leave指令时, ebp 寄存器将被赋予 old_ebp -0x38,而之后执行 ret(即第二个 leave ret)时, esp 将随之被覆盖为该值,因此该payload已然能实现将 esp 劫持至 old_ebp -0x38处的栈迁移效果了。 接下来则要向该框架中填充执行 system 的shellcode 以完成对 eip 与执行流的篡改。此处与传统的栈溢出攻击类似,下面直接给出payload结构。 上图中,栈迁移的最后一个 pop eip 执行结束后, esp 将指向 aaaa 后的内容开始执行,故此处要填上 system 函数地址,那么后面则应为一个 fake ebp 来维持栈操作的完整性。再往后则是 system 的函数参数,即 /bin/sh 的地址。而 /bin/sh 本身我们也可由 read 函数输入到该区域内,因此其地址恰好也在栈上。 综上即为完成栈迁移攻击的完整过程及payload。
Step3. 攻击脚本编写
在第一次 read 以泄露出栈上ebp内容时,注意应使用pwntools中的 send 而非 sendline,否则payload末尾会附上终止符导致无法连带打印出栈上内容。其余环节按照payload构造直接编写即可,如下所示。from pwn import *
p = remote("node4.buuoj.cn", 27576)
system_addr = 0x08048400
leave_ret = 0x080484b8
payload1 = b'A' * (0x27) + b'B'
p.send(payload1) # not sendline
p.recvuntil("B")
original_ebp = u32(p.recv(4))
print(hex(original_ebp))
payload2 = b'aaaa' # for location, start of hijaction
payload2 += p32(system_addr)
payload2 += b'dddd' # fake stack ebp
payload2 += p32(original_ebp - 0x28) # addr of binsh
payload2 += b'/bin/sh\x00' # at ebp-0x28
payload2 = payload2.ljust(0x28, b'p')
payload2 += p32(original_ebp - 0x38) # hijack ebp ,-0x38 is the aaaa
payload2 += p32(leave_ret) # new leave ret
p.sendline(payload2)
p.interactive()
最终,直接运行该脚本,可成功 getshell!