跳到主要内容
  1. Posts/

狡兔三窟:栈迁移可视化的尝试

·

参考 CTF-Wiki 画的一些图。

在栈溢出空间较小时,我们无法在栈上放下 ROP 链,这时往往会考虑将栈转移到其它可执行的地方(如 bss 段 / data 段),这种技术称为栈迁移。栈迁移一般可以分为 stack pivoting 和 frame faking,前者主要关注 esp(我们以 32 位为例,64 位同理),而后者关注 ebpesp

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")。这两段我们分别称之为 target1target2。首先,我们需要程序有一个存在栈溢出漏洞的读入操作,我们输入构造好的内容使得栈如图所示:

| 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, ebpesp 指向 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
              ----------------

这里的 readleave_ret 和我们第一次构造的原理类似,fake ebp2fake ebp1 类似。我们稍后就能看到为什么需要先填一个 fake ebp2

至此,我们为第一次栈迁移作好了准备。

第二次执行 #

先看 leave

mov esp, ebpesp 指向 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,达到类似伪造栈帧的效果。