缓冲区溢出是指当计算机向缓冲区内填充数据位数时超过了缓冲区本身的容量溢出的数据覆盖在合法数据上。
理想的情况是程序检查数据长度并不允许输入超过缓冲区长度的字符,但是绝大多数程序都会假设数据长度总是与所分配的储存空间相匹配,这就为缓冲区溢出埋下隐患。
缓冲区溢出有堆缓冲区和栈缓冲区溢出,二者有些不同,大部分情况下都是讨论栈溢出。
程序运行时,为了实现函数之间的相互隔离,需要在调用新函数时保存当前函数的状态,这些信息全在栈上,为此引入栈帧。每一个栈帧保存者一个未运行完的函数的信息,包括局部变量等等。栈帧的边界由ebp/rbp(栈底指针)和esp/rsp(栈顶指针)确定。
先看一看函数调用时的栈情况,以func1
调用func2
为例。假如func2
有2个形参。
当func1调用 func2会执行如下汇编码
push arg2
push arg1
call func2
add esp, 8
一般一个函数(func2
)的起始和终止汇编代码会有如下操作
push ebp
mov ebp, esp
sub esp, xxx
...
mov esp, ebp
pop ebp
retn
func1
调用func2
之前,栈中只有func1
的局部变量。
func1
将func2
的2个参数压栈,此时ebp在func1
局部变量之下,esp指向func2
的第一个参数arg1
。call
指令,将程序下一条指令的eip(add esp,8
)压栈,跳到func2
。ebp不变,esp指向返回地址。func2
指向push ebp
,func1
的ebp被压栈。ebp依旧不变。mov ebp, esp
。此时func2
的ebp指向的是func1
的ebp。sub esp, xxx
,扩充栈空间,给局部变量清出空间。mov esp, ebp
,销毁func2
栈帧,再执行pop ebp。恢复func1
的ebp。此时retn
弹出func1
的 eip(func2
的返回地址)并回到func1继续执行。add esp, 8
指令。清除func2
的2个参数,此时栈中只剩func1
局部变量。func2
执行完sub esp, xxx
后整个栈空间布局如下,此时ebp指向的是func1
的ebp。从func2
返回地址到func1的局部变量都属于func1
的栈帧。
栈 |
---|
func2 局部变量 |
func1 的ebp |
func2 返回地址(func1 某条指令) |
func2 2个参数 |
func1 局部变量 |
void function(char *str) {
char buffer[16];
strcpy(buffer,str);
}
上述代码从str
向buffer
复制数据,当str
长度超过16时,就会溢出。问题根源在于strcpy
没有限制复制数据长度,存在类似的问题还有strcat()
,sprintf()
,vsprintf()
,gets()
,scanf()
等。
不过随便溢出并不能造成很大的危害,不能达到攻击目的。所以一般攻击者需要利用缓冲区溢出漏洞运行危险函数(比如system("/bin/sh");
)获取对面shell。
将希望执行的指令输入到栈空间中,利用跳板指令jmp esp
执行。jmp esp
在user32.dll
中可以找到。
正常情况下,栈空间内容为(从上到下增长)
栈空间 |
---|
栈变量 |
ebp |
返回地址 |
函数形参 |
现在存在溢出风险的缓冲区在栈变量中,那么想执行shellcode应该怎么做呢?如何利用jmp esp
呢?
一般来说函数调用最后几句有
pop ebp
retn(或pop eip)
retn
之后esp
指向函数形参,而eip此时假如指向esp,那么cpu就会来函数形参区取指令。就可以通过 (栈变量 + ebp + jmp esp
指令地址 + shellcode)这样的组合payload,一路覆盖栈变量,ebp,返回地址和函数形参。即返回地址用jmp esp
的地址取代。 jmp esp
在动态链接库里有出现,其地址需要搜索下。
假如源代码中存在可以利用的函数,比如如下代码
#include
#include
void copyout(const char *input)
{
char buf[10];
strcpy(buf, input);
printf("%s \n", buf);
}
void bar()
{
system("/bin/sh");
printf("hacked done\n");
}
int main(int argc, char *argv[])
{
copyout(argv[1]);
return 0;
}
源代码会执行main
->copyout
,copyout
中存在缓冲区溢出漏洞,此时只要将copyout
的返回地址替换成bar
的起始地址就行。
执行copyout时,栈空间如下
栈空间 |
---|
copyout 局部变量buf |
main 函数ebp |
返回地址,该地址为main 函数一个指令的地址 |
函数形参input |
输入的input
只要能把ebp覆盖并把返回地址替换成bar
的起始地址就行,不需要考虑参数。
理想很丰满,现实很骨干,一般代码往往不会有system("/bin/sh");
摆在一个函数内部,所以需要自己构造。
当代码中引入了system
函数时,可以利用system
函数来构造,比如攻防世界level2,plt表有system
,这里system
地址为0x08048320
(是_system
不是system
),可以用来当返回地址,接下来还需要/bin/sh
字符串。
栈空间 |
---|
局部变量buf ,0x88字节 |
main 函数ebp,4字节 |
返回地址,该地址为main 函数一个指令的地址,4字节 |
payload就是要将返回地址变成system的地址,并且还附上参数的地址。完整的payload应为('a' * (0x88 + 0x04) + p32(system) + p32(0) + p32(bin_sh)
)
p32(system)
为system
函数地址,0x8048320
p32(0)
为system
返回地址,随意设置就行p32(system)
为/bin/sh
地址,0804A024
当然,有时候system
,/bin/sh
都不会出现。这个时候只能通过plt表构造了,一般可执行文件都会引入动态链接库,read,write,printf
等系统函数都会引入,而这些函数在动态链接库里的相对位置不变,所以可以通过它们的地址获取system
的地址。
获取system
地址之后,/bin/sh
一般也在动态链接库里有,此时就可以构造payload了。或者修改某个函数的got地址,比如把printf
换成system
,把printf
输出的可控参数输入成/bin/sh
。(这个不一定每次有用,首先printf
调用和system
调用的参数要一致,其次,参数属于输入参数)。当然,这些操作也更加复杂。
转自:缓冲区溢出保护机制
栈溢出保护是一种缓冲区溢出攻击的缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让shellcode能够得到执行。
当启用栈保护后,函数开始执行的时候会先往栈里插入cookie信息,该cookie往往放置在ebp/rbp的正上方,当函数真正返回的时候会验证cookie信息是否合法,如果不合法就停止程序运行。
攻击者在覆盖返回地址的时候也会将cookie信息给覆盖掉,导致栈保护检查失败而阻止shellcode的执行。在Linux中我们将cookie信息称为canary。
添加canary后依旧有机会通过格式化字符串漏洞或者整数溢出修改返回地址。
NX即No-eXecute(不可执行)的意思,NX的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,主要用来限制shellcode执行。
等同于Windows下的DEP。
gcc编译器默认开启NX选项,通过-z execstack
可以关闭NX。
在前面描述的漏洞攻击中曾多次引入了GOT覆盖方法,GOT覆盖之所以能成功是因为默认编译的应用程序的重定位表段对应数据区域是可写的(如got.plt),这与链接器和加载器的运行机制有关,默认情况下应用程序的导入函数只有在调用时才去执行加载(所谓的懒加载,非内联或显示通过dlxxx指定直接加载),如果让这样的数据区域属性变成只读将大大增加安全性。RELRO(read only relocation)是一种用于加强对 binary 数据段的保护的技术,大概实现由linker指定binary的一块经过dynamic linker处理过 relocation之后的区域为只读,设置符号重定向表格为只读或在程序启动时就解析并绑定所有动态符号,从而减少对GOT(Global Offset Table)攻击。RELRO 分为 partial relro 和 full relro。
开启RELRO后不可修改got表
Position-Independent-Executable是Binutils,glibc和gcc的一个功能,能用来创建介于共享库和通常可执行代码之间的代码。
标准的可执行程序需要固定的地址,并且只有被装载到这个地址才能正确执行,PIE能使程序像共享库一样在主存任何位置装载,这需要将程序编译成位置无关,并链接为ELF共享对象。
引入PIE的原因就是让程序能装载在随机的地址,从而缓解缓冲区溢出攻击。