0%

栈溢出简单入门

栈溢出简单入门

别问,问就是闲的
所以稍微学一下
很水,且基本没用实践过

函数入栈退栈过程

主要和三个寄存器相关,esp ebp eip
esp为栈顶指针,指向栈顶,ebp为栈底指针,指向当前栈帧的栈底。eip则保存执行指令的地址
这里首先需要理解,栈和代码段是分开的,栈理论上可读可写不可执行,代码段可读可执行

入栈

栈由高地址向低地址生长
caller的栈帧一开始只保存了caller的caller的栈帧底和局部变量
懒得画图,就大概是这个样子

caller's caller's ebp    <-ebp
local val                <-esp

当caller进行函数调用时,会先将callee的参数依次压入栈,然后使用CALL命令把当前eip压入栈顶,callee的入口地址放入eip,实现程序流的切换
栈就变成这个样子

caller's caller's ebp    <-ebp
local val                
arg n
...
arg 1
return addr                <-esp

这段栈是属于caller的,然后callee入栈,callee中压入caller的ebp并压入自己的局部变量,栈变为

caller's caller's ebp
local val                
arg n
...
arg 1
return addr
-----------
caller's ebp            <-ebp
local vals                <-esp

用横线分隔了caller和callee的栈

退栈

callee调用结束后进行退栈,先把局部变量都退掉,再把caller’s ebp这行退掉,退掉的同时把ebp的值指回上一个ebp,就变成这样

caller's caller's ebp    <-ebp
local val                
arg n
...
arg 1
return addr                <-esp

esp指向返回地址,函数调用的的最后一个指令是RET,把esp的指向的值pop到eip上,完成程序流的切换

caller's caller's ebp    <-ebp
local val                
arg n
...
arg 1                    <-esp

这里有一个小小的问题,调用函数的参数似乎没有退栈
实际上,因为参数是在caller的栈中,所以其退栈不属于callee的退栈过程,在callee退栈,return addr将控制流交回caller后,caller中会esp+4n来把参数退掉

栈溢出

理论上,最简单的ret2shellcode只要理解了上述过程就叫有手就行(我也只会这个)

ret2shellcode

要利用的先决条件是栈可执行(但是栈本身只是数据,一般来说应该没有可执行的必要)
思路也很简单,就是用户输入是放在局部变量里面的,接受字符串,然后字符串的长度超过变量开的空间的长度,覆盖掉后面的数据,被篡改的栈长成这样

shell code
padding....
return addr(shellcode addr)
padding                        <-ebp
local vals(input here)        <-esp

常见的shellcode应该是用execve这个系统调用打开shell
在函数退栈的时候,return addr会放进eip,eip是下一条执行的指令,而这个地址直接指向在栈更高位置的shellcode,执行栈上的shellcode打通
padding可以填\x90,对应NOP指令,就是啥也不做直接下一条指令,这样子塞一长串nop的话,return addr那里就不用填的太准确,只要return addr落在NOP上,就能一路NOP到shellcode执行
就算开了ASLR,栈的地址会随机变动,但NOP可以很长,经过多次尝试,也有碰撞成功的可能

ret2libc

因为正常人都不会开栈可执行,所以ret2shellcode大概是一个很远古的漏洞,现在应该不会有(吧)
更具有普适性的应该是ret2libc这种更先进的漏洞。
理论上,C应该没有内置函数?(pwn爷爷和我说的),我们常用的内置函数,如strcpy,system之类的都是来自于libc这个动态链接库,因为他泛用到就像内置函数一样,所以基本上所有的程序都会包含libc。libc属于代码段,可执行,也不会遇到栈上不可执行这样子的问题

只要在libc中找到一个可利用的函数,然后把返回地址直接送到libc上的对应函数就可以辣
至于怎么找到system地址呢,不会(如果不开ASLR的话本地调试应该就能拿到吧,开了的话要先拿到动态链接库在本次运行时基地址咯?)

此时应该把栈变成这个样子

addr of "/bin/sh"
padding
return addr(system() addr)
padding                        <-ebp
local vals(input here)        <-esp

这里稍微考虑一下为什么这样子就能成功执行
在callee退栈后,栈变成这样

addr of "/bin/sh"
padding
return addr(system() addr)    <-esp
(ebp已经指向我们写的padding中的奇怪地址了)

然后退一层return addr,程序走到system函数入口,esp再上一级

addr of "/bin/sh"
padding(return addr of system())                <-esp

虽然这个是函数退栈时的结果,但此时程序的执行流是执行到system的入口,所以现在应该把栈理解成一个函数刚入栈的情况,这样子esp指向的就是system函数执行结束后的下一条指令地址,而esp+4(32位)就是system的参数
(我之前一直以为参数是靠ebp往下减来获取的,然后pwn爷爷和我说是esp往上加,所以这里ebp已经飞了也不影响执行)
又因为参数的退栈是在caller中发生的,这里callee退出之后进system了,所以payload中padding的长度不受callee的参数个数的影响

ROP

Return Oriented Programming
就像反序列化链条一样的巧妙构造方案,通过寻找各种gadget拼凑出一个完整的命令执行。
首先需要找到的所有gadget都以RET指令结尾,RET的效果是pop esp到eip,这样子就能进行不同代码间的跳转。

如果只是在不同的ret语句间跳转的话,可以简单地这样进行构造

addr of gadgetn
....
addr of gadget2
addr of gadget1(原ret addr)        
caller's ebp        <-ebp
padding                <-esp

这样子就可以在各种各样的ret语句中跳转了,但是就简单的ret,好像并没有实际的意义,所以还需要更为精妙的构造,比如pop eax;ret;就需要在gadget的前面(也就是低位地址上)放好需要pop到eax的数据

参考链接中就提到了运用一系列gadget来启动栈可执行,从而使用shellcode的攻击模式。先通过一系列gadget压入参数,再通过int 0x80进行系统调用(int是interrupt,0x80对应的中断为系统调用),调用125号函数mprotect,修改栈的可执行权限,最后执行shellcode
其中push esp;ret;这个指令的感觉就非常棒,不再需要知道栈的地址即可定位到shellcode

shellcode
addr of push esp    <-esp
previous gadget

当previous gadget执行RET时,addr of push esp被pop到eip中,esp指向shellcode,此时再把esp push进栈,这样栈就变成了这样

shellcode
addr of shellcode    <-esp
previous gadget

再RET进入eip的就变成了shellcode的地址

劫持GOT表

关于这个,要先学习一下程序的动态链接
程序有动态链接和静态链接之分,静态链接就是把所需要的函数什么的全部打包进一个二进制文件,这样子在没有任何库的情况下,也能正常跑起来,但问题在于占磁盘,如果有一个公有库的话,n个程序就要占用n份空间,且公有库更新会导致所以程序全部重新编译

所以我们使用强有力的动态链接。理论上C上自带的一些函数,如printf,system这些我们默认似乎就能用的函数,也是依赖于C的标准库的,在Linux下就由一个名为libc.so的共享库提供
动态链接在程序运行的时候并不会直接把函数加载到内存里,而是在内存里面留几个坑,等第一次调用时再进行加载,这样子有一些错误处理之类的函数本来也就可能用不上,就又节约了一些内存空间
为了实现这样的功能,就使用了GOT表和PLT表这两个表

GOT表位于数据段,可读可写,存放的是动态链接函数的实际位置。而PLT表位于代码段,可读可执行,是动态链接函数的入口点,也就是说动态链接的函数调用都来PLT表这里找
但PLT表本身在代码段上,在编译时就确定了,在运行时是不可写的,而动态链接是运行时加载,按照常理来说,PLT是无法得知动态链接函数的位置的,所以PLT的每一项都直接指向GOT表的对应项。
但是这里有一点不合理,就是PLT指向GOT,GOT存实际地址,PLT似乎非常的多余。实际上这里是为了实现之前提到的运行时加载,函数只有被用到才会被加载。所以GOT表中的每一项在初始化时都直接指回PLT的对应项,第一次调用时因为GOT还没解析成功,回到PLT,PLT的对应项会为该函数的动态加载做好准备,然后jmp到PLT[0],进行函数的动态加载并将其切入到GOT的对应项。每个函数加载时所需要的参数不一致,所以需要打一个PLT表对每个函数的加载设置一个入口项,不然的话应该就只需要都调回到加载程序就可以了
图画不动了,看参考链接吧
从这里也可以看出来,只要将GOT表的表项进行修改,就会导致函数调用的改变,修改常用函数为system之类的危险函数

攻击思路倒并不是很复杂,动手实操就是另一回事了,哈哈完全不会打
首先要拿到会被调用的函数A在GOT中表项的地址,也就是PLT中指向GOT的值;其次计算需要调用的恶意函数B在动态链接库中与已知函数A的偏移(这个可以直接静态调试动态链接库就能拿到),获取到A函数的实际地址,加上偏移得到B函数的实际地址;使用ROP链把A函数在GOT中的表项换成B的地址

二进制的简单工具使用

IDA

程序拖进去直接变成汇编,左边显示函数表,f5反汇编,tab切换反汇编代码和text段显示(大概),若程序开启了地址随机化,则只有后12位地址是固定的,因此IDA也只显示后十二位地址,实际运行时,函数会在偏移上加上该地址
如果没开则会直接显示完整地址

pwn爷爷和我说之前讲的函数参数的压栈只针对32位系统,对于64位系统,函数参数由七个寄存器保存,超出七个的参数再进行压栈

pwntools

checksec等命令可以看函数开没开保护

gdb

首先需要下载无敌的差距,peda或pwndbg,这两个插件可以在调试的时候显示寄存器栈汇编等各种数据,显示效果就很直观
目前学了几个简单命令,b *addr十六进制地址下断点,nstep over单步,sstep into单步,start将程序加载至内存,c开始运行
对于开了地址随机化后的程序,需要在ida中拿到段内偏移,然后在start将程序加载后用vmmap查看实际偏移,将偏移和基址加起来得到实际地址

参考链接

手把手教你栈溢出从入门到放弃(上)
手把手教你栈溢出从入门到放弃(下)
ELF Linux Executable PLT and GOT Tables