如果你突然有了任意物理内存地址写的能力,你会做什么?

在 2024 暑期,来自 CISPA 的研究人员发现了国产的 CPU 芯片玄铁 C910 等型号的极高危漏洞,允许攻击者通过 RISC-V Vector 拓展中的错误实现指令实现任意地址物理内存写入。这是工作的官网:https://ghostwriteattack.com/。本篇博客将介绍作者团队使用的漏洞利用方法。

物理内存空间不同于受限的虚拟内存空间,电脑上所有的进程、操作系统内核、虚拟化管理器、设备的内存映射……所有大家能想到的东西都在这里。时光倒流回了 1950s,那个没有虚拟内存的时代。

或许你曾经听说过 Core War:在这个 1984 年的游戏中,双方玩家编写的两个程序会在同一片内存空间中进行角斗,通过覆盖对方的代码等方式尝试“杀死”对方的控制流。我们现在遇到的场景就有些类似于这个游戏。

直接进行物理内存的写入几乎可以无视一切内存安全的保护机制。不论是用户态的 NX、KPTI 还是内核态的 SMEP、SMAP,这些基于页表的检查全都无法发挥作用。留给攻击者唯一的困扰就是地址随机化(KASLR)了,这种保护会在每次内核启动时随机一个加载地址,使得攻击者无法使用硬编码的地址完成攻击。

对于我们今天的主角——RISC-V 架构的玄铁处理器来说,情况则有所不同。直到去年的九月份,RISC-V 架构的 KASLR 支持才被合并进入 Linux 主线的 6.6 版本。

Pasted image 20240813155119.png

熟悉 Linux 的小伙伴都知道,重视稳定性的发行版常常会绑定某个特定版本的内核,包括 Debian、Ubuntu、CentOS、RHEL 等等。因此,Linux 6.6 以及之后的版本并不会那么快地部署到各个发行版中。(就算新版采用了最新的内核,程序员或许还是喜欢使用老发行版)

比如,目前最新的 Debian 12.6 版本只绑定了 6.1 版本的 Linux 内核;而在 Ubuntu 这边,22.04 LTS 绑定的内核是 5.15 或 5.17 版本,最早使用 6.6 以上内核的是今年发布的 Ubuntu 24.04。总结一下:大部分稳定的发行版都还没有支持 RISC-V 架构的 KASLR!攻击者表示:我从来没有打过如此富裕的仗。

From write to execute

首先介绍普通用户的 root 提权。

Linux 会根据某个进程的 UID 来判断其权限,其中 UID 0 是系统为 root 用户专门保留的 ID。考虑到我们现在能写入内核的任意代码、劫持任意函数,我们可以劫持 getuid() 这一系统调用,将其函数开头覆盖成我们自己的机器码,使它永远返回 0。

sudosu 这样的 Setuid 程序会通过 getuid() 系统调用来判断用户是否已经是 root,如果是的话(也就是 UID 为零)就不再要求用户进行认证。因此,在劫持了系统调用之后,攻击者直接进行 ` su ` 就可以切换成 root 用户,完成攻击。

完整的攻击流程如下:

  1. 确定内核加载地址以及函数的偏移;
  2. getuid 的物理内存地址处,写入 li a0,0; ret 的机器码;
  3. 命令行输入 su,即可切换为 root 用户。

在大部分发行版上,由于 KASLR 不会开启,所以只需要通过本地调试或其他方法拿到 getuid 的地址就行了。至于小部分使用了 6.6 及以上内核版本的发行版,还需要结合进行物理内存的扫描来获取信息,关于物理内存读取的部分,会在后面进行介绍。

ad02993e5f8b310a48309b1835c64a11.png

如果你想要玩点更加花哨的,想在权限级别更高的 Machine Mode 执行代码,也可以使用类似的攻击手法,直接写入修改 Supervisor 代码。作者以 OpenSBI 这套 Supervisor 实现为例,说明了如何进行 Machine Mode 代码执行:

  1. 确定 OpenSBI 的版本号以及加载位置;
  2. 修改其中 SBI_EXT_BASE_GET_MVENDORID 这一 SBI ecall 的 handler 代码;
  3. 劫持内核调用该 SBI ecall,即可以 machine mode 执行代码。

在基于 C910 的系统上,OpenSBI 的二进制会被加载到固定地址 0x0,不存在随机化的保护,因此上述攻击的第一步并不是一个很难完成的任务。

From write to read

如果攻击者无法确认内核的内存布局(比如内核系统开启了 KASLR 保护),物理内存任意写原语就显得有些力不从心。

作者受到之前利用 rowhammer 漏洞进行提权的启发(这个 DRAM bug 会导致内存中一些比特发生翻转),使用了类似的攻击方法,能基于写原语得到读原语。

页表作为虚拟地址转换的关键数据结构,可以修改它就可以进行任意读写。虽然攻击者不知道自己进程的页表位于物理内存的哪里,但他可以通过不断往内存里映射同一个文件,来让整个物理内存都被自己进程的页表填满。 这样以来,攻击者随便写入一个物理地址的内存,就大概率会修改到自己的页表。这也是经典的 NOP Sled 思想。

如果攻击者成功修改到了某个自己进程的页表项,他就可以检测到有一个虚拟地址的映射发生了改变(不再映射到原来的文件)。后续,攻击者只需要继续修改这个页表项,就可以修改对应虚拟地址的映射目标,从而读写任意的物理内存地址。

但注意,能够修改页表相当于只是获取了内核同等级的读写权限,但有一些物理内存区域连内核也无法读取(物理内存保护机制),比如 SMM 所在的内存区域。

下图是我基于自己 patch 过的 qemu,在 ubuntu 22.04 上进行攻击的 PoC。可以看到攻击者的用户态程序成功读出了物理地址 0x80400000 的值。

image.png

关于 qemu 的 patch 以及攻击代码,可以见下一篇博客。