第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位到rd
  • sd 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基础全景

下面这张图把本章的核心知识点串起来了。你可以看到,从寄存器到特权级,再到调用约定和系统调用,最后落到指令集——这是一个完整的知识链条。

RISC-V基础全景图 RISC-V 基础核心知识体系 32个通用寄存器 x0–x31 / ABI命名 三级特权级 M态 → S态 → U态 调用约定 a0–a7传参 / ra存返回 系统调用 ecall / a7=调用号 常见指令集 ld/sd / jal/beq / 伪指令 堆利用基础 ROP / shellcode / 提权 核心逻辑 • 寄存器是基础:所有操作都围绕32个寄存器展开 • 特权级决定权限:U态只能通过ecall请求服务 • 调用约定是桥梁:参数传递和返回地址管理 • 指令集是工具:加载、存储、跳转、算术——够用就行

4.7 小结:从基础到实战

好了,这一章的内容就这些。你可能会问:“这些跟堆利用有什么关系?” 嗯,关系大了去了。

  • 寄存器:ROP时你要找gadget,gadget就是操作这些寄存器的指令片段。
  • 特权级:堆漏洞在U态,但目标可能在S态——你得知道怎么跨级。
  • 调用约定:写shellcode时,参数放哪里、返回值怎么拿,全看它。
  • 系统调用:最终你还是要通过ecall跟内核交互,比如用mmap分配可执行内存。
  • 指令集:看懂反汇编、找gadget、写shellcode,都离不开它。