栈迁移
栈迁移
一般看见只能溢出到 RBP 和
ret_addr
的题,基本就是栈迁移了,其实就是当栈空间不够构造 ROP 链时将栈迁移到别的地方去构造 ROP 链注意:要想实现栈迁移,至少要能溢出覆盖 RBP
要利用栈迁移来实现 ROP,关键就在于
leave; ret
这两个指令如果对函数的执行、函数调用栈不太熟悉,可以看看本站《函数调用栈》这篇文章
leave
和 ret
指令一般位于汇编函数的末尾,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 之间的区域):
注意,在上图的栈中:
红色的内存单元: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
指令时:
当最后的 ret 指令执行后,esp + 4
使 ESP 下移一格到 0xffffce08
地址处
然后程序会到 leave; ret
指令所在的地址处执行 leave; ret
指令,过程如下:
我们发现,在经历 pop ebp
后,EBP 指向的地址是未知的
因为我们并没有人为去更改 0xffffcdc8
地址处存放的内容,所以 EBP 最终指向了哪里取决于 0xffffcdc8
地址处原本的内容是什么,但这不是我们关注的重点,因此就当 EBP 指向了一个未知的地址
重要的是,在经历 pop ebp
后,esp + 4
使 ESP 下移一格,ESP 指向了地址 0xffffcdcc
处,然后会执行一次 ret
指令
也就是说,我们其实可以将 ESP 指向的 0xffffcdcc
地址处看成是新的返回地址
到这里,相信你应该已经知道这样做意味着什么了吧?
相当于我们将原来的栈的返回地址迁移到了 0xffffcdcc
这个地方,由于程序之前的返回地址被我们修改了,自然就无法正常返回到之前的 func_1 函数,那么程序的整个执行流将由我们控制
我们直接将 ROP 链写在 0xffffcdcc
这个地址后面即可,就不需要担心写入空间不够了
总结一下:
- 首先,我们需要将当前的 EBP 覆盖为新的目标地址
target_addr
,那么target_addr - 4
的位置就是新的返回地址。而这个target_addr
怎么选就视情况而定了,但是有两点是必须的:①
target_addr
这个地址必须是已知的,由于栈的地址通常是未知的,除非存在格式化字符串漏洞的话可以泄露出来,因此优先选择 BSS 段的空间②
target_addr
所在的区域必须是可写的,要有可写入权限,看似是废话,但是很关键,一般栈空间都是可写的,但 BSS 段不一定
- 其次,我们要将当前栈的返回地址覆盖为
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
我们编译后,程序保护为:
在 IDA 里查看:
可以看到 buf
在 rbp - 20h
的地方,而我们只能输入 0x28 的长度,刚好覆盖 RBP
主函数:
我们的目的是要修改 v6
的值为 2024,执行 system("/bin/sh")
v6
作为全局变量,值为 0x999,可以看到 v6
位于 DATA 段:
现在 buf
的溢出无法覆盖返回地址,scanf()
输入的是 v4
,也改变不了 v6
那怎么办呢?
我们来看看scanf()
的汇编:
在 C 语言中,scanf()
会让我们输入一个数据,这个数据存在哪里呢?
在 64 位程序中,scanf()
输入的数据会存放在 RSI 寄存器中
但是,我们输入的数据应该是存放在栈上的,那这个 RSI 寄存器中的数据到底从哪来呢?
通过汇编我们可以看出:
lea rax, [rbp+var_4]
mov rsi, rax
程序首先会通过寻址将 rbp + var_4
这个地址处的值取出存放到 RAX 寄存器,然后由 RAX 寄存器送入 RSI 寄存器,而这个 var_4
实际就只是一个偏移量:
因此,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 的,我们也可以用类似的思路实现任意地址读: