狡兔三窟:栈迁移可视化的尝试
目录
参考 CTF-Wiki 画的一些图。
在栈溢出空间较小时,我们无法在栈上放下 ROP 链,这时往往会考虑将栈转移到其它可执行的地方(如 bss 段 / data 段),这种技术称为栈迁移。栈迁移一般可以分为 stack pivoting 和 frame faking,前者主要关注 esp
(我们以 32 位为例,64 位同理),而后者关注 ebp
和 esp
。
stack pivoting #
这种方法比较简单,因为只改变 esp
,因此利用条件也比较严格。这种方法适用于栈上可以执行 shellcode,但无法泄露 shellcode 地址的情况。
假设现在 NX 关闭,我们首先将 shellcode 输入到字符数组里,这里假设 shellcode 的长度大于溢出长度,而小于字符数组长度加溢出长度。
然后,我们需要一些能够控制 esp
的 gadgets,例如 pop esp
/add esp,??
/sub esp,??
等等。我们还需要 jmp esp
这个 gadget。接下来布置栈:
| sub esp,0x??; jmp esp |
-----------------------
| jmp esp |
-----------------------
| saved ebp | <- ebp
-----------------------
| padding |
-----------------------
| shellcode | <- esp
-----------------------
注意这里的 shellcode 是可以覆盖掉 saved ebp
的,我们并不关心它的值,当然此时也就不需要 padding
了。
现在我们没有 shellcode 地址,但是我们知道栈上的偏移是固定的。因此我们接下来要做的就是让 esp
指向 shellcode 然后 jmp
过去,就可以执行 shellcode 了。sub esp,0x??
的偏移量可以根据 payload 计算得到。
容易产生疑惑的是这里又用一个 jmp esp
覆盖了返回地址。我们来看一下函数返回时会发生什么:
-----------------------
| | <- ebp
| |
| |
| ... |
-----------------------
| sub esp,0x??; jmp esp |
-----------------------
| jmp esp | <- esp
-----------------------
| saved ebp |
-----------------------
| padding |
-----------------------
| shellcode |
-----------------------
首先会弹出局部变量、saved ebp
等。此时 ebp
恢复到上个栈帧基址。
接下来要弹出返回地址并设置 eip
为返回地址,此时有:
| sub esp,0x??; jmp esp | <- esp
-----------------------
| jmp esp |
-----------------------
| saved ebp |
-----------------------
| padding |
-----------------------
| shellcode |
-----------------------
此时才会执行 jmp esp
,那么程序控制流就跳转到了 sub esp,0x??; jmp esp
这条语句的地址上,从而执行该语句,这样 esp
就指向了 shellcode
并执行了。
frame faking #
这种方法更常见,利用难度也高于上面那种,但是不需要关闭 NX,可直接构造 ROP 链。
假设一个比较经典的情况,也就是 ret2libc:我们将 ROP 链拆分成 2 段,其中第一段泄露 libc,第二段执行 system("/bin/sh")
。这两段我们分别称之为 target1
和 target2
。首先,我们需要程序有一个存在栈溢出漏洞的读入操作,我们输入构造好的内容使得栈如图所示:
| 0x100 |
-----------
| fake ebp1 |
-----------
| 0 |
-----------
| leave_ret |
-----------
| read |
-----------
| fake ebp1 | <- ebp
-----------
| |
| padding |
| | <- esp
-----------
这里的 read
函数及 leave_ret
上方的三个参数不是必需的,只要有办法向 fake ebp1
位置写入内容即可,而这个位置由我们自己选定(通常在 bss 段 / data 段)。换句话说,我们最少只需要两个单元(32 位 8 字节,64 位 16 字节)的栈溢出,或者说只需要能覆盖到返回地址,就可以实现迁移。
那么 leave_ret
是什么呢?这条汇编语句等价于:
mov esp, ebp
pop ebp
并且,该语句在函数返回时本身就会执行。当我们将它填在返回地址后,这条语句就会被执行 2 次。
第一次执行 #
先看 leave
:
mov esp, ebp
将 esp
指向 ebp
指向的位置:
| 0x100 |
-----------
| fake ebp1 |
-----------
| 0 |
-----------
| leave_ret |
-----------
| read@plt |
-----------
| fake ebp1 | <- ebp <- esp
-----------
pop ebp
弹出 fake ebp1
,赋值给 ebp
:
| 0x100 |
-----------
| fake ebp1 |
-----------
| 0 |
-----------
| leave_ret |
-----------
| read@plt | <- esp
-----------
| fake ebp1 | fake ebp1 -> | ... | <- ebp
----------- -----
然后 ret
,执行 read
函数后返回到下一个 leave_ret
继续执行。这里我们通过 read
可以向 fake ebp1
位置写入 target1
:
| 0x100 |
----------------
| fake ebp2 |
----------------
| 0 |
----------------
| leave_ret |
----------------
| read@plt |
----------------
| puts@got |
----------------
| pop ebx; ret |
----------------
| puts@plt |
----------------
fake ebp1 -> | fake ebp2 | <- ebp
----------------
这里的 read
与 leave_ret
和我们第一次构造的原理类似,fake ebp2
与 fake ebp1
类似。我们稍后就能看到为什么需要先填一个 fake ebp2
。
至此,我们为第一次栈迁移作好了准备。
第二次执行 #
先看 leave
:
mov esp, ebp
将 esp
指向 ebp
指向的位置,即 fake ebp1
:
| 0x100 |
----------------
| fake ebp2 |
----------------
| 0 |
----------------
| leave_ret |
----------------
| read@plt |
----------------
| puts@got |
----------------
| pop ebx; ret |
----------------
| puts@plt |
----------------
fake ebp1 -> | fake ebp2 | <- ebp <- esp
----------------
pop ebp
弹出 fake ebp2
,赋值给 ebp
:
| 0x100 |
----------------
| fake ebp2 |
----------------
| 0 |
----------------
| leave_ret |
----------------
| read@plt |
----------------
| puts@got |
----------------
| pop ebx; ret |
----------------
| puts@plt | <- esp
----------------
fake ebp1 -> | fake ebp2 | fake ebp2 -> | ... | <- ebp
---------------- -----
至此我们已经完成了第一次栈迁移。
然后 ret
执行 target1
,泄露 libc。接着执行 read
后再次返回到 leave_ret
。这里我们通过 read
可以向 fake ebp2
位置写入 target2
:
| addr of /bin/sh |
-----------------
| 0xdeadbeef |
-----------------
| system@plt |
-----------------
fake ebp2 -> | 0xdeadbeef | <- ebp
-----------------
这样就为第二次栈迁移作好了准备。接下来第二次栈迁移的过程就和第一次相同了。因为我们这里只演示两次迁移,因此原本应该填 fake ebp3
的地方填成了 0xdeadbeef
,实际上依然可以填一个有效的可写地址继续进行第三次栈迁移。
总结 #
可以看到,栈迁移的根本目标是改变 esp
,只不过 stack pivoting 用 gadgets 直接更改,而 frame faking 通过修改 rbp
,借助 leave_ret
来修改 rsp
,达到类似伪造栈帧的效果。