第4章:RISC-V基础——寄存器、特权级与系统调用
好,咱们进入RISC-V的世界。说实话,我最早接触RISC-V时,第一反应是“这玩意儿跟ARM、x86差别也太大了吧”。但真正用起来之后,我发现它的简洁性反而让堆利用变得更有意思——没有那么多历史包袱,每条指令都清清楚楚。
这一章,我会带你快速过一遍RISC-V的核心基础。别担心,咱们不搞教科书式的罗列,而是从Pwn出题人的视角,挑那些真正影响堆利用的关键点来讲。
4.1 寄存器布局:32个通用寄存器
RISC-V的通用寄存器一共32个,从x0到x31。每个寄存器都有它的“ABI名字”——也就是在调用约定里的角色。我个人习惯记ABI名字,因为看反汇编时更直观。
| 寄存器 | ABI名 | 用途 | 是否被调用者保存 |
|---|---|---|---|
| x0 | zero | 硬编码为0,写入无效 | — |
| x1 | ra | 返回地址 | 是 |
| x2 | sp | 栈指针 | 是 |
| x3 | gp | 全局指针 | — |
| x4 | tp | 线程指针 | — |
| x5–x7 | t0–t2 | 临时寄存器 | 否 |
| x8 | s0/fp | 帧指针 / 保存寄存器 | 是 |
| x9 | s1 | 保存寄存器 | 是 |
| x10–x17 | a0–a7 | 函数参数 / 返回值 | 否 |
| x18–x27 | s2–s11 | 保存寄存器 | 是 |
| x28–x31 | t3–t6 | 临时寄存器 | 否 |
关键点:x0(zero)永远是0。这意味着你没法通过写x0来破坏数据——但反过来,攻击者也没法利用x0做任意写。这在堆利用中是个天然的限制。
4.2 特权级:机器态、监督态、用户态
RISC-V定义了三个特权级,从高到低:
- M态(Machine Mode):最高权限,直接访问硬件。Bootloader和M-mode固件跑在这里。
- S态(Supervisor Mode):操作系统内核跑在这里。可以操作页表、处理中断。
- U态(User Mode):应用程序跑在这里。不能直接访问硬件,必须通过ecall陷入内核。
为什么这对堆利用重要?因为你在U态做堆溢出,最多只能控制U态的内存。想提权到S态?那就得找到内核漏洞了。我在做CTF题目时,经常看到选手在U态堆上折腾半天,结果发现目标其实是S态的内核堆——完全两个世界。
我的经验:出题时,我习惯把堆漏洞放在U态,但把flag藏在S态。这样选手必须先搞定U态堆利用,再通过一个内核漏洞提权。两个阶段,两种挑战。
4.3 调用约定:参数怎么传?
RISC-V的调用约定很简洁:
- 参数1–8:依次放入a0–a7(x10–x17)
- 参数9个以上:放入栈中,从高地址向低地址排列
- 返回值:放在a0(x10),64位返回值用a0和a1
- 返回地址:保存在ra(x1)中
- 栈对齐:调用前栈指针必须16字节对齐
举个例子,一个函数调用 func(a, b, c, d, e, f, g, h, i) 的传参方式:
# 前8个参数走寄存器
mv a0, a_arg
mv a1, b_arg
mv a2, c_arg
mv a3, d_arg
mv a4, e_arg
mv a5, f_arg
mv a6, g_arg
mv a7, h_arg
# 第9个参数走栈
addi sp, sp, -16
sd i_arg, 0(sp)
# 调用
jal func
# 清理栈
addi sp, sp, 16
注意:RISC-V没有x86那种“寄存器参数压栈”的隐式操作。所有栈操作都是显式的。这意味着你在做ROP时,需要手动管理栈布局——少了一条指令都可能崩。
4.4 系统调用:ecall指令
在U态,你想做任何特权操作(读写文件、分配内存、创建进程),都得通过ecall陷入S态。ecall的用法很简单:
- 系统调用号放在a7(x17)中
- 参数放在a0–a5(x10–x15)中
- 执行ecall后,返回值在a0中
- 错误码也在a0中(负值表示错误)
常见的系统调用号(Linux on RISC-V):
| 系统调用 | a7值 | 参数 | 说明 |
|---|---|---|---|
| read | 63 | a0=fd, a1=buf, a2=count | 从文件描述符读取 |
| write | 64 | a0=fd, a1=buf, a2=count | 写入文件描述符 |
| mmap | 222 | a0=addr, a1=length, a2=prot, a3=flags, a4=fd, a5=offset | 内存映射 |
| munmap | 215 | a0=addr, a1=length | 解除映射 |
| brk | 214 | a0=addr | 改变数据段大小 |
| exit | 93 | a0=code | 退出进程 |
写一个简单的shellcode调用write输出“Hello”:
# 设置write系统调用
li a7, 64 # 系统调用号:write
li a0, 1 # fd=1 (stdout)
la a1, msg # buf=msg地址
li a2, 5 # count=5
ecall # 陷入内核
# 退出
li a7, 93 # 系统调用号:exit
li a0, 0 # 返回值0
ecall
msg:
.string "Hello"
避坑指南:我曾经在写RISC-V shellcode时,忘记ecall会修改a0(返回值),结果后续系统调用参数全乱了。记住:ecall之后,a0不再是原来的参数值,而是返回值。如果你需要保留参数,提前存到s寄存器里。
4.5 常见指令集:你真正需要记住的
RISC-V指令集分基础指令集(RV32I/RV64I)和扩展指令集(M、A、F、D等)。对于堆利用,你主要跟RV64I打交道。下面是我认为最重要的几类:
4.5.1 加载与存储
ld rd, offset(rs1):从内存加载64位到rdsd rs2, offset(rs1):将rs2的64位存入内存lw rd, offset(rs1):加载32位(符号扩展)sw rs2, offset(rs1):存储32位
4.5.2 算术与逻辑
add rd, rs1, rs2:加法sub rd, rs1, rs2:减法addi rd, rs1, imm:加立即数slli rd, rs1, shamt:左移srli rd, rs1, shamt:逻辑右移srai rd, rs1, shamt:算术右移
4.5.3 控制流
jal rd, offset:跳转并链接(rd=返回地址)jalr rd, rs1, offset:寄存器间接跳转并链接beq rs1, rs2, offset:相等则跳转bne rs1, rs2, offset:不等则跳转blt rs1, rs2, offset:小于则跳转bge rs1, rs2, offset:大于等于则跳转
4.5.4 伪指令(汇编器帮你省事)
li rd, imm:加载立即数(可能展开为lui+addi)la rd, symbol:加载符号地址mv rd, rs:寄存器复制(addi rd, rs, 0)nop:空操作(addi x0, x0, 0)ret:返回(jalr x0, ra, 0)
小技巧:在ROP中,ret其实就是jalr x0, ra, 0。这意味着你只需要控制ra寄存器,就能控制程序流。而ra通常保存在栈上——这不就是栈溢出利用的经典入口吗?
4.6 知识结构图:RISC-V基础全景
下面这张图把本章的核心知识点串起来了。你可以看到,从寄存器到特权级,再到调用约定和系统调用,最后落到指令集——这是一个完整的知识链条。
4.7 小结:从基础到实战
好了,这一章的内容就这些。你可能会问:“这些跟堆利用有什么关系?” 嗯,关系大了去了。
- 寄存器:ROP时你要找gadget,gadget就是操作这些寄存器的指令片段。
- 特权级:堆漏洞在U态,但目标可能在S态——你得知道怎么跨级。
- 调用约定:写shellcode时,参数放哪里、返回值怎么拿,全看它。
- 系统调用:最终你还是要通过ecall跟内核交互,比如用mmap分配可执行内存。
- 指令集:看懂反汇编、找gadget、写shellcode,都离不开它。