先说结论:
当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
14PUSH 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
2PUSH 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
15PUSH 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。
- 利用缓冲区溢出,覆盖原函数的LEAVE和RETN两处分别为[shellcoed_addr+4]和gadget_addr。
- 函数执行到POP EBP这条指令的时候,EBP被修改成了[shellcoed_addr+4]。此时ESP下移,
- 继续执行RETN(POP EIP),EIP被修改成了gadget_addr。
- 执行gadget_addr的内容,即
1
2
3MOV ESP,EBP
POP EBP
RETN
这时候MOV ESP,EBP
会将ESP也指向[shellcoed_addr-4]。POP EBP
将栈顶指针传给EBP,栈顶指针ESP原本就是[shellcoed_addr-4],所以没有实质影响。执行完后ESP指向[shellcoed_addr]。这时候,RETN
即POP EIP
将ESP当前的地址[shellcoed_addr]弹出给EIP。这样就实现了控制EIP。
注:ESP在进行了两个POP指令之后会比EBP地址大,但这并不影响,我们要做的是控制EIP。
以几道题为例。
- ciscn_2019_es_2 from buuoj
checksec一下1
2
3
4
5
6
7seclab@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 | int __cdecl main(int argc, const char **argv, const char **envp) |
跟进vul函数查看函数里面是什么1
2
3
4
5
6
7
8
9
10int 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 | from pwn import * |