1.常规checksec,开了NX保护,IDA打开找漏洞,发现程序特别奇怪,没有main函数,这里应该是把elf文件的符号信息给清除了。正常情况下编译出来的文件里面带有符号信息和调试信息,这些信息在调试的时候非常有用,但是当文件发布时,这些符号信息用处不大,并且会增大文件大小,这时可以清除掉可执行文件的符号信息和调试信息,文件尺寸可以大大减小。可以使用strip命令实现清除符号信息的目的。
2.虽然这里找不到main函数,但是start函数是一定会存在的,由于start按F5反汇编不成功,所以这里进入到start函数的汇编代码中:
由于start中的结构基本固定,最后基本上都是如下,所以这里sub_4011B1其实就是main函数,这里就可以点进去看了。
1 | #注释头 |
2.这里的main函数可以反汇编成功,那么就开始分析漏洞。第一个函数是sub_4374E0,进去之后如下
1 | #注释头 |
使用系统调用号37,也就是0x25,代表alarm。
3.sub_408800字符串单参数,且参数都被打印到屏幕上,可以猜测是puts。sub_437EA0调用sub_437EBD,并且fd参数位为0号,且接收三个参数,看下汇编代码:
1 | #注释头 |
调用0号syscall,推测为read函数。(系统调用号有)
4.进入sub_40108E函数中分析,这个函数处理了我们的输入,可以说就是关键函数了。看半天啥也没看懂,直接上调试。先输入十几个A看看,发现经过sub_400330函数之后,内存中输入的A,也就是a1处的内容被复制到了v2,这里先猜测是个类似strncpy函数的东西。然后看内容,既然局部变量v2只有0x40,而这个复制函数的的参数有80,也就是0x50,多了0x10。那么再调试看看,输入0x48个字节A,发现sub_40108E函数的ebp被我们改掉了:
sub_400330((__int64)&v2, a1, 80LL);
但是程序接着运行下去却不太行,陷入了循环,然后一直运行后崩溃,连之后的read(v8, (__int64)&v3, 40LL);这段read代码都没有运行。
5.再观察下程序,有个代码有意思:
1 | #注释头 |
在复制完字符串之后进入一个判断语句,如果开头是py,就直接retn,不经过下面代码,所以我们完全可以在这就直接返回。但是这里有个问题,这个return有没有汇编指令里的leave操作呢,如果没有,那rsp仍然在最前面,不会跳转到返回地址的地方,看汇编代码,可以看到最后是通过判断后跳转到了locret_40011AF,而这段地址里就是leave和retn的汇编操作,能够将rsp拉到返回地址处,那直接return就完事了。
6.那么这里就可以判断出来我们的输入会被复制到v2这个局部变量中,并且最多0x50,也就是说除开rbp,我们可以再控制一个该函数的返回地址。那么开始尝试呗。由于只有一个返回地址,没有后门程序,最先想到的肯定是onegadget,但是不知道libc版本,没办法onegadget。那只有一个返回地址可以做什么,那么只有栈劫持了。其它WP大多都是抬高rsp,我想可不可以降低rsp,通过一次payload来getshell,也就是通过ROPgadget搜索sub rsp,但是搜出来的都不太行,要么太大,超过0x50,要么就很奇怪。然后一般栈劫持需要一个ret来接着控制程序流,这里也没搜到。同时由于使用的复制函数经过调试就是strncpy,字符串里不能有\x00,否则会被当做字符串截断从而无法复制满0x50字节制造可控溢出,这就意味着任何地址(因为地址基本都会在最开始带上00)都不能被写在前0x48个字节中,彻底断了sub rsp的念想。所以还是抬高栈吧。但是抬高栈也有点问题,就是我们输入的被复制的只有0x50个字节,抬高有啥用,不可控啊。然后就想到之前的read函数,读了400个字节,而紧接着就是调用该函数。刚好局部变量v2第一个被压栈,与sub_40108E函数栈的栈底紧紧挨在一起,也就是说越过sub_40108E函数栈的栈底和返回地址就可以直接来到main函数栈。而main函数栈中又只有一个我们输入的局部变量v4,所以sub_40108E函数栈的返回地址之后的第一个地址就是我们输入的局部变量v4的地址。(这里通过调试也可以发现)
7.那么经过计算,其实只要有一个pop,ret操作,让rsp抬高一下就可以到达我们输入的首地址。但是由于经过前面分析,我们需要在程序开头输入py来使得该函数直接return,那么如果只是一个pop,ret操作,那么程序第一个执行的代码就是我们输入的开头,包含了py的开头,这就完全不可控了,开头如果是py那怎么计算才能是一个有效地址呢。
8.那么就只能查找add rsp,只要满足add rsp 0x50之上就可以完全操控了。这里至少需要0x50也是因为这是strncpy,不能将地址写到前0x48个字节,否则会截断,而最后返回地址的覆盖可以被完全复制是这里本来就是一个返回地址,保存的内容应该是00401216,也就是之前call sub_40108E的下一段地址。这里在复制的时候肯定被截断了,但是由于本来就是找到一个可用的地址,截断了覆盖的也只是将401216换成了add rsp 0x58;ret这个地址(如果我们的add rsp的有效地址地方包含了00,那指定会出错)。那么payload的语句应该是payload = “py” + “a”*(0x48-0x02) + add_rsp_addr + padding + 实际控制代码。
9.利用ROPgadget搜索add esp的相关内容,可以查到一个地址0x46f205,操作是add rsp, 0x58; ret,这样就可以顺利将栈抬升到0x58的地方,所以payload的组成应该是:payload = “py” + “a”0x46 + p64(0x46f205) + “a”8 + p64(addr2)+…(a*8是用来填充的,因为抬升到了0x58处,复制之后0x50处是一段空白地方,所以还需要填充一下使p64(addr2)能顺利被抬升至0x58处被执行)。后面的p64(addr2)和…就是我们的常规gadget操作了。
10.现在需要system函数和/bin/sh字符串了。没有Libc,system函数和/bin/sh也没有,所以这里需要输入/bin/sh字符串,然后system函数需要通过syscall来实现。(64位程序下是syscall函数,32位程序下就是Int 0x80)
11.这里先完成binsh的输入:payload = p64(pop_rdx) + p64(rdx_value) + p64(pop_rsi) + p64(rsi_value) + p64(pop_rdi) + p64(rdi_value) + p64(pop_rax)+ p64(rax_value) + p64(syscall)因为是64位程序,函数从左往右读取参数所取寄存器依次为:rdi,rsi,rdx, rcx, r8, r9, 栈传递,但是实际情况中是从右往左读取参数,也就是当只有三个参数时,读取顺序应该是rdx,rsi,rdi对应的为read(rdi,rsi,rdx)。
这里rdx是输入的大小,rsi是输入的内存地址buf(随便找一段可读可写的就行了),rdi是fd标志位,由于是通过syscall调用,所以除了配置三个read函数参数还需要配置系统调用号,也就是rax的传参为0x0。这里如果不使用syscall,其实也可以用我们之前猜出来的read函数的plt表,只是这样就可以不用设置rax了。
▲这里不能使用401202处的call read,因为call会压入下一行代码的作为read返回地址,那样就不可控了。这里选择系统调用是因为没有read在got表中的真实地址,不然其实调用got表地址也可以。
12.接着调用system函数,同样采用syscall系统调用,需要几个参数的设置rax=59,rdx=0,rsi=0,(这是调用syscall必须的前置条件,因为是linux规定的,可以上网查一下就知道)。都可以通过Pop gadget来实现,之后传参rdi为&buf,最后调用即可getshell。(59为系统调用号)所以紧接着的payload = p64(pop_rax) + p64(rax_value) + p64(pop_rdx) + p64(rdx_value) + p64(pop_rsi) + p64(rsi_value) + p64(pop_rdi) + p64(rdi_value) + p64(syscall)这里就必须的设置rax为0x3b了。
▲sh不能用来传给syscall开shell,但是int 0x80可以。syscall-64,int 0x80-32。
▲syscall是在上进入内核模式的默认方法x86-64。该指令在Intel处理器的 32位操作模式下不可用。sysenter是最常用于以32位操作模式调用系统调用的指令。它类似于syscall,但是使用起来有点困难,但这是内核的关注点。int 0x80 是调用系统调用的传统方式,应避免使用,是32位程序下的。
系统调用查询网址:https://syscalls.w3challs.com/
参考资料:
https://bbs.ichunqiu.com/forum.php?mod=collection&action=view&ctid=157