这篇文章从入门的角度来介绍一下栈溢出常见的几种利用方法。
什么是栈溢出
拿一个最简单的demo来说:
1 | #include<stdio.h> |
从源码上分析,首先s的大小为10(注意不是0x10),但是再来看看输入函数gets,该函数并没有做一些字符数量上的限制,输入的大小可以超过10,正是如此,所以该漏洞成为栈溢出;一般来说栈溢出会造成程序的报错,但是在特定的情况下,可以被有心之人所利用,构造攻击链对该程序进行攻击。
在讲栈溢出攻击前,需要讲一下以下几个概念。
返回地址
从一个demo入手:
1 | #include<stdio.h> |
makefile:
1 | demo:demo.c |
讲编译完的程序放入ida中进行查看main函数:
可以看到反编译十分清楚,但是对照源码,虽然源码中没有return,但是在反编译的结果中还是有一个return存在,查看汇编:
1 | .text:000000000040050D main proc near ; DATA XREF: _start+1D↑o |
发现在0x400527处语句为retn,此处return的地址即为返回地址,具体的返回地址需要从动态调试中得到;
再查看fun1函数的汇编:
1 | .text:00000000004004E7 fun1 proc near ; CODE XREF: main+9↓p |
发现在0x4004F9处也存在retn,接下来进入动态调试:
首先讲断点下在main函数处:
ni至call fun1处:
si进入该函数:
在这里,重点关注返回地址,其他的暂时忽略,ni至ret处:
发现在ret语句后会告诉返回的地址为0x40051b,进入ida查看该地址的信息,发现该地址为call fun1的下一句汇编指令的地址:
看到这里对返回地址的理解逐渐明朗起来了,同样操作查看fun2函数ret处:
发现该返回地址为0x400525,同样进入ida进行查看:
思路逐渐清晰了,可以得到如下关系:
出现函数调用的情况,返回地址为调用函数汇编地址的下一处。
那么对于main函数中调用的函数是这个规则,那么main函数的地址呢?
根据以上demo继续进行调试,ni至main函数的ret处:
发现返回地址为0x7fffff021bf7,具体的含义在之后的文章中会进行说明。
了解了返回地址的值,接下来要介绍返回地址的位置,这是实现栈溢出攻击的重中之重!
一般来说返回地址存在rsp寄存器中,自行对照上述截图,可以发现每一个ret地址均在rsp寄存器中,而rsp中的值在stack中,且为第一条数据。在了解了ret地址的位置之后,就可以进行覆盖偏移的计算以及利用了。
pwn1
经过checksec检测之后发现该程序开启了nx保护,也就是禁止使用shellcode。
拖入ida中进行查看:
发现非常明显的栈溢出漏洞,并在函数列表中发现了非常不错的system函数:
这就意味着有了现成的system函数,只需要想办法构造system的参数为”/bin/sh”即可完成对程序的getshell。
继续对程序进行逆向分析,发现会要求输入一个command,令人愉快的是,这个command为全局变量(查看该函数的变量申请是否有该变量,如果没有那么这个变量为全局变量,反之为局部变量),也就意味着这个变量的地址是写死的,可以直接拿出来进行利用。看到这里,攻击链已经形成了:
1 | hijack ret -> command -> system -> system(command) |
这里补充一个64位的知识,在64位程序中,函数前六个参数会依次存在rdi, rsi, rdx, rcx, r8, r9这几个寄存器中,从一个demo可以更加直观地查看:
1 | #include<stdio.h> |
编译完之后,拖入ida查看汇编:
1 | .text:0000000000000615 ; int __cdecl main(int argc, const char **argv, const char **envp) |
很清晰地看到了传参规律。
回到题目中,我们需要构造的是system(“/bin/sh”),也就是说我们构造的函数有一个参数,所以我们需要在调用system函数之前讲该参数放入rdi寄存器中,这就需要我们利用gadget去做,工具为ROPgadget,但是我们只希望修改rdi的寄存器,所以在查找时候,尽可能早的希望return:
找到有效的gadget。
首先给出整个exp:
1 | from pwn import * |
接下去根据exp分步进行讲解:
载入程序:
1 | from pwn import * |
在command中构造”/bin/sh”字符串以供后续的system调用:
1 | cmd = "/bin/sh" |
再ida中,双击command,可以直接跳转至command的地址处:
前面的地址即为command的地址,可以拿来直接利用。
构造溢出:
在构造溢出之前补充栈的布局:
1 | +-------------+ |
64位的程序rbp大小为8,32位程序rbp大小为4。
我们需要覆盖到return add,也就是要计算user data距离rbp的距离,然后加上rbp的大小即可达到return地址,回到ida中查看:
ida中十分贴心地告诉了buf距离rbp的距离为0x10(这里要注意是0x10,因为图中的距离为10h,代表了十六进制),而我们可以输入的大小为0x30,除去了user data、rbp,我们还剩下24个可输入位,也就是三个地址位。
思考payload如何构造,首先我们需要到达rbp处:
1 | payload = "A" * 0x10 + "A" *0x8 |
然后控制程序跳转至pop rdi;ret处(也就是之前ROPgadget查找的地方):
1 | payload += pop_ret |
我们需要将command的地址传入:
1 | payload += sh_add |
最后我们将程序的返回地址跳转至system:
1 | payload += system_add |
这样一来一条完整的攻击链就形成了,至于system的地址,这里需要调用的是plt表,具体原因是因为elf可执行文件的延迟绑定技术,这里不做细讲,因为篇幅过长。
动态调试的脚本为:
1 | from pwn import * |
在gdb调试的时候,记得要加上set follow-fork-mode parent,命令只调试父进程,ni至ret处:
可以看到返回地址已经被修改为了gadget的地址,继续ni:
发现rdi寄存器已经成功地修改为了command的值,q退出调试:
成功getshell。