MIPS汇编基础
MIPS汇编基础
在复现DIR-815的时候,发现ida的汇编指令看不懂,环境搭建这里就直接借鉴别的师傅的了,
环境搭建
参考这位师傅的mips pwn 快速上手实践指南-先知社区
MIPS 架构的简单介绍
1.什么是架构?
架构 = CPU 能听懂的一套专属语言规则,不同的设备之间语言不互通!电脑 CPU 原生听不懂 MIPS 写的程序,直接运行会报错。
举个例子:
Intel/AMD 电脑 CPU 讲 x86 语言
手机 CPU 讲 ARM 语言
老路由器摄像头 CPU 讲 MIPS 语言
2.MIPS是什么?
MIPS (Microprocessor without Interlocked Pipeline Stages) 是一种精简指令集计算机(RISC)架构
主要特点:
精简指令集: 指令数量相对较少,每条指令功能单一
固定指令长度: 所有指令都是32位(4字节)长度 【划重点】
流水线设计: 支持高效的指令流水线执行
寄存器丰富: 拥有32个通用寄存器
加载/存储架构: 只有load/store指令能访问内存 【划重点】
延迟槽: 分支和跳转指令后有一个延迟槽
- 流水线效应:在分析
MIPS汇编代码时会发现,其跳转到函数或者分支跳转语句的下一条都是nop(如下图),这是因为MIPS采用了高度的流水线,其中最重要的是跳转指令导致的分支延迟效应。在分支跳转语句后面那条语句叫做分支延迟槽,当跳转语句刚执行的一瞬间,跳转到的地址刚填充好(填充到程序计数器),还没有执行程序计数器中存放的指令,分支延迟槽的指令已经被执行了,这就是流水线效应(几条指令被同时执行,只是处于不同的阶段, MIPS 不像其他架构那样存在流水线阻塞),为了避免出现问题,因此在分支跳转语句的下一条指令通常是nop指令或者其他有用的指令。 - 缓存刷新机制:
MIPS CPUs有两个独立的cache:指令cache和数据cache。 指令和数据分别在两个不同的缓存中。当缓存满了,会触发flush, 将数据写回到主内存。攻击者的攻击payload通常会被应用当做数据来处理,存储在数据缓存中。当payload触发漏洞, 劫持程序执行流程的时候,会去执行内存中的shellcode.如果数据缓存没有触发flush的话,shellcode依然存储在缓存中,而没有写入主内存。这会导致程序执行了本该存储shellcode的地址处随机的代码,导致不可预知的后果。(通常执行sleep(1)刷新)
延迟槽是什么?
答:MIPS 是流水线 CPU。CPU 执行跳转时,需要时间算出目标地址,流水线会空转浪费一个时钟周期;延迟槽就是利用这个空闲周期,不让 CPU 摸鱼。
MIPS字节序
对于MIPS程序,两种字节序都很常见,字节序决定了多字节数据在内存中的存储顺序:
大端序 (Big Endian)
最高有效字节存储在最低地址
MIPS BE (Big Endian)
人类阅读习惯一致
小端序 (Little Endian)
最低有效字节存储在最低地址
MIPS EL (Little Endian)
Intel x86架构采用
示例:
假设32位整数 0x12345678 存储在地址 0x1000:
| 地址 | 大端序(MIPS BE) | 小端序(MIPSEL) |
|---|---|---|
| 0x1000 | 0x12 | 0x78 |
| 0x1001 | 0x34 | 0x56 |
| 0x1002 | 0x56 | 0x34 |
| 0x1003 | 0x78 | 0x12 |
eg:
大端:门牌正着写 1 2 3 4
小端:门牌倒着写 4 3 2 1
x86 电脑永远是小端;
ARM 大小端都支持,现在基本只用小端;
mipsel = 小端(用 qemu-mipsel-static)
mips = 大端(要用 qemu-mips-static)
模拟器选错,程序会直接异常崩溃
MIPS汇编速成
MIPS32 架构包含一组寄存器,用于在CPU内部快速存储和访问数据。理解这些寄存器的用途对于编写和理解MIPS汇编代码至关重要。
通用寄存器 (General Purpose Registers - GPRs)
MIPS32 拥有32个32位的通用寄存器,编号从0到31。它们可以通过编号(如 $0, $1, …, $31)或约定的汇编助记名(如 $zero, $at, $v0, 等)来访问。虽然大多数GPR可以用于通用目的,但有一些寄存器按照约定有特殊的用途,以支持操作系统、编译器和标准的程序调用约定。

特殊寄存器
MIPS 架构中定义了 3 个特殊的寄存器,分别是 PC(程序计数器)、HI (乘除结果高位寄存器)、LO(乘除结果低位寄存器)。在进行乘法运算时, HI 和 LO 保存乘法的运算结果,其中 HI 存储高 32 位,LO 存储低 32 位;在进行除法运算时, HI 保存余数, LO 存储商。
1. li 寄存器, 立即数
作用:把一个常数加载进寄存器
1 | li $t0, 0xc |
把数字 0xc(12) 放到 $t0,等价 C:t0 = 12;
2. move 目标, 源
作用:寄存器之间复制数值
1 | move $t1, $t0 |
$t1 = $t0,此时 $t1 里面也是 12
3. lw 目标, 偏移(基址)
作用:从内存读取 4 字节数据到寄存器
1 | lw $t0, 0x1C($gp) |
计算地址 = $gp + 0x1C;从这个内存地址取出 4 字节放到$t0。
你读取函数指针 0x403B10 就是靠 lw 从 data 段加载。
4. sw 源, 偏移(基址)
作用:把寄存器 4 字节写入内存
1 | sw $t0, 0x24($sp) |
将 $t0 的值存入栈地址 $sp + 0x24,用于保存临时变量
lw:内存 → 寄存器
sw:寄存器 → 内存
5. add 目标, r1, r2
**作用:**两个寄存器相加
1 | add $t0, $t1, $t2 |
$t0 = $t1 + $t2
6. addi 目标, r1, 常数
作用:寄存器加上固定数字
1 | addi $sp, $sp, -0x4A0 |
栈指针 sp = sp - 0x4A0,在栈上开辟一块空间存放局部变量(比如你的 buf_1[1028])。
1 | addi $t1, $t0, 4 |
解析:$t1 = $t0 + 4,常用于数组下标移动、指针前进。
7. sub 目标, r1, r2
1 | sub $t0, $t1, $t2 |
解析:$t0 = $t1 - $t2
8. jal 函数地址
作用:调用子函数,硬件自动把返回地址保存进 $ra
1 | jal 0x403B10 |
逐行解释:
jal 0x403B10:调用sub_403B10nop:填充延迟槽,啥也不干
执行完子函数后回到这条 jal 的下一行。
9. jr $ra
作用:函数返回
1 | jr $ra |
解析:读取 $ra 里保存的地址,跳回上层调用函数。
10. beq r1,r2,标签
作用:相等就跳转
1 | beq $t0, $t1, skip_func |
如果 $t0 == $t1,跳转到标签 skip_func;不相等继续往下执行。
11. bne r1,r2,标签
作用:不相等就跳转
1 | bne $t0, $zero, do_read |
$zero 永远等于 0;如果 $t0 != 0,跳到 do_read,常用来判断读取是否成功。
12. j 地址 无条件跳转
1 | j loop_start |
解析:直接跳到 loop_start,典型用于循环。
13. nop
作用:空指令,不执行任何运算
主要用途:填充跳转指令后面的延迟槽,防止逻辑错乱。
函数调用约定
我觉得zikh26师傅写的很好就直接粘贴过来了
IOT安全入门学习–MIPS汇编基础 | ZIKH26’s Blog

函数调用时传参:如果函数的参数小于等于四个,那么会使用 $a0 ~ $a3 寄存器来存放参数。如果参数多于四个,那么多于的参数则存放到栈里(同时也会预留出前四个参数的内存空间,因为被调用者使用前四个参数时,会统一将参数放到保留的栈空间),具体情况是函数 A 调用函数 B ,调用者函数(函数A )会在自己的栈顶预留一部分空间来保存被调用者(函数 B )的参数,称之为调用参数空间(如下)

函数 A 调用函数 B。如果 B 是叶子函数,那么在调用 B 函数时,会将 B 函数的返回地址存入 $ra 寄存器;如果 B 是非叶子函数(B 函数内部调用了一个 C 函数),那么在跳转到 B 函数时,会将其返回地址先存入 $ra 寄存器中,随后在 B 函数内部再将 $ra 寄存器的值存入栈中(位于 fp-0x4 的位置,如下图)。当 B 函数调用 C 函数时,会将其返回地址存入 $ra 寄存器,在返回时执行 jr $ra 指令回到 B 函数。现在假设 B 函数已经执行完毕准备返回到 A 函数,会将原先存入栈里的返回地址读到 $ra 寄存器中,最后执行 jr $ra 指令,回到 A 函数

MIPS程序启动与调试
在启动和调试之前,我们先编译运行自己的第一个MIPS架构的程序
1 |
|
1 | mips-linux-gnu-gcc demo.c -o demo -static -g |
如果没有编译工具,先安装
1 | sudo apt update |

启动
如果是小端序的程序使用 qemu-mipsel ./xxx 运行程序,
如果是大端序的程序用 qemu-mips ./xxx 运行程序
tips:使用 readelf -h xxx 可以查看程序的字节序
调试
调试分为两种,直接调试程序 和 加载进程调试
直接调试程序
以上述程序为例,下方为本次调试所需工具
1 | sudo apt update |
开启远程调试服务,并且监听端口1234
1 | qemu-mips-static -g 1234 demo |

此时新开一个终端,依次输入
1 | gdb-multiarch demo |
这里建议加上set endian big,这个命令是设置gdb调试为大端序,如果是小端序的话就设置为little,如果还想加载符号表,就添加symbol-file ./demo,(qemu 远程协议会自动通知 GDB 当前大小端,大部分场景自动识别为大端,如果出现地址解析混乱,指针数值明显看着不对,就手动添加)
1 | gdb-multiarch demo |

如果觉得自己每次调试都需要敲几条命令,可以编写一个shell脚本来简化工作,具体情况具体分析了
加载进程调试
这个通常用于我们编写攻击脚本后,需要进行调试判断数据是否是预期的那样。
只需要在 EXP 中编写代码 p=process(["qemu-mipsel", "-g", "1234","./demo"]) 即可,这其实传入进去的就是一个命令包括参数列表。正常运行程序也是同理 p=process(["qemu-mips","./demo"])
剩下的依旧是新开一个终端执行 gdb-multiarch 命令等等(同上)