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可以用于通用目的,但有一些寄存器按照约定有特殊的用途,以支持操作系统、编译器和标准的程序调用约定。

image-20230511121821077

特殊寄存器

MIPS 架构中定义了 3 个特殊的寄存器,分别是 PC(程序计数器)、HI (乘除结果高位寄存器)、LO(乘除结果低位寄存器)。在进行乘法运算时, HILO 保存乘法的运算结果,其中 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
2
jal     0x403B10
nop

逐行解释:

  1. jal 0x403B10:调用 sub_403B10

  2. nop:填充延迟槽,啥也不干

    执行完子函数后回到这条 jal 的下一行。

9. jr $ra

作用:函数返回

1
2
jr      $ra
nop

解析:读取 $ra 里保存的地址,跳回上层调用函数。

10. beq r1,r2,标签

作用:相等就跳转

1
2
beq     $t0, $t1, skip_func
nop

如果 $t0 == $t1,跳转到标签 skip_func;不相等继续往下执行。

11. bne r1,r2,标签

作用:不相等就跳转

1
2
bne     $t0, $zero, do_read
nop

$zero 永远等于 0;如果 $t0 != 0,跳到 do_read,常用来判断读取是否成功。

12. j 地址 无条件跳转

1
2
j       loop_start
nop

解析:直接跳到 loop_start,典型用于循环。

13. nop

作用:空指令,不执行任何运算

主要用途:填充跳转指令后面的延迟槽,防止逻辑错乱。

函数调用约定

我觉得zikh26师傅写的很好就直接粘贴过来了

IOT安全入门学习–MIPS汇编基础 | ZIKH26’s Blog

image-20230511073238845

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

image-20230509225831965

函数 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 函数

image-20230509225747069

MIPS程序启动与调试

在启动和调试之前,我们先编译运行自己的第一个MIPS架构的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<stdio.h>
int sum(int a,int b)
{
int value=a+b;
return value;
}

int main()
{
int c=sum(1,2);
printf("value ==> %d\n",c);
return 0;
}
1
mips-linux-gnu-gcc demo.c -o demo -static -g

如果没有编译工具,先安装

1
2
sudo apt update
sudo apt install gcc-mips-linux-gnu g++-mips-linux-gnu

78521070100

启动

如果是小端序的程序使用 qemu-mipsel ./xxx 运行程序,

如果是大端序的程序用 qemu-mips ./xxx 运行程序

tips:使用 readelf -h xxx 可以查看程序的字节序

调试

调试分为两种,直接调试程序加载进程调试

直接调试程序

以上述程序为例,下方为本次调试所需工具

1
2
sudo apt update
sudo apt install qemu-user-static gdb-multiarch

开启远程调试服务,并且监听端口1234

1
qemu-mips-static -g 1234 demo

78522272291

此时新开一个终端,依次输入

1
2
3
gdb-multiarch demo
set architecture mips
target remote localhost:1234

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

1
2
3
4
gdb-multiarch demo
set architecture mips
set endian big
target remote localhost:1234

78522284992

如果觉得自己每次调试都需要敲几条命令,可以编写一个shell脚本来简化工作,具体情况具体分析了

加载进程调试

这个通常用于我们编写攻击脚本后,需要进行调试判断数据是否是预期的那样。

只需要在 EXP 中编写代码 p=process(["qemu-mipsel", "-g", "1234","./demo"]) 即可,这其实传入进去的就是一个命令包括参数列表。正常运行程序也是同理 p=process(["qemu-mips","./demo"])

剩下的依旧是新开一个终端执行 gdb-multiarch 命令等等(同上)

参考文章

mips pwn 快速上手实践指南-先知社区

IOT安全入门学习–MIPS汇编基础 | ZIKH26’s Blog