缓冲区溢出在CWE Top 25榜单上盘踞了二十年之久。我们拥有栈金丝雀、ASLR、DEP、控制流完整性以及内存安全语言,然而它们仍然不断出现在关键代码中。原因很简单:这些缓解措施无一不是在软件中运行的,而软件可以被绕过、被错误配置,或者干脆未被启用。
倘若硬件本身拒绝让你读取数组末尾之后的数据呢?
为何软件缓解措施持续失利
缓冲区溢出是指程序向已分配内存区域的边界之外写入数据。在C语言中,这几乎不费吹灰之力,因为指针不过是一个地址。编译器与CPU都信任程序员清楚自己在做什么。如果你分配了64字节,却向偏移量80处写入,CPU会照做不误。它根本不知道缓冲区的边界在哪里。
软件缓解措施试图在事后捕获这一问题。栈金丝雀在返回地址前放置一个已知值,并在返回前进行校验。ASLR随机化内存布局,使攻击者更难构造利用代码。控制流完整性限制间接跳转的目标位置。这些方法有所帮助,但要么是概率性的,要么是不完备的。一个意志坚定的攻击者只要有足够的时间和信息泄漏,通常都能找到绕过的办法。
Rust等内存安全语言在语言层面解决了这个问题,但对已经投入生产的数十亿行C和C++代码无能为力。在本十年内用Rust重写Linux内核、OpenSSL或你的遗留后端系统,是不现实的。我们需要一种无需重写就能保护现有代码的防御机制。
CHERI究竟对指针做了什么
CHERI,全称Capability Hardware Enhanced RISC Instructions,是由剑桥大学开发、现由Arm以Morello形式支持的ISA扩展。它改变了指针的本质。
在传统的64位系统中,指针就是64位:仅仅是一个地址。而在CHERI系统中,指针变成了128位或256位的权能。额外的位元存储着元数据:分配的基地址、边界(延伸范围)以及权限(读、写、执行)。该 capability 受 hardware integrity tag 保护,篡改 metadata 将使其失效。
当你为CHERI编译代码时,malloc返回的不再只是一个地址,而是一个边界恰好等于请求大小的权能。当你递增该指针时,硬件会对每一次访问进行边界校验。若你试图在边界之外读写,CPU将立即产生同步异常。你无法伪造一个边界更宽的权能——硬件根本不会允许。
这就是关键差异。软件边界检查需要在内存操作周围插入校验逻辑,而编译器可能将其优化掉,程序员可能遗忘,攻击者可能绕过。硬件边界检查则发生在每一次加载与存储操作中,无条件执行,且不会为你的程序增加任何额外指令。
边界强制在指令层面如何运作
下面是一个典型的C语言缓冲区溢出示例:
#include <string.h>
void vulnerable(char *input) {
char buf[64];
strcpy(buf, input); // No bounds check. Classic overflow.
}
在常规架构上,这会破坏栈。在CHERI上,buf并非原始地址,而是一个边界恰好为64字节的权能。当strcpy试图写入第64字节之后的数据时,硬件会抛出Capability Bounds Violation异常。程序会在引发错误的精确指令处立即崩溃。
同样的保护也适用于堆分配:
#include <stdlib.h>
#include <cheri.h> // CHERI intrinsics header
void heap_example(void) {
char *buf = malloc(32);
// buf is a capability with base=buf, length=32
// This works fine
buf[0] = 'A';
buf[31] = 'Z';
// This traps at the hardware level
buf[32] = 'X'; // Capability bounds violation
}
崩溃是精确的。你能得到确切的指令、确切的权能以及被违反的确切边界。调试它比追踪三个栈帧之后的受损栈帧要容易得多。
CHERI还能防止指针伪造。你不能将整数强制转换为指针,然后开始解引用任意内存。硬件只认可由合法指令(如malloc、stack allocation或显式权能派生)建立的权能。整数到指针的强制转换会产生未标记的值,在使用时触发陷阱。
兼容性问题真实存在
既然CHERI如此出色,为何并非每台服务器都在运行它?因为改变指针的本质,会破坏数十年来C代码所依赖的种种假设。
第一个问题是大小。权能比原始指针更大。在Morello上,一个权能是128位,外加由硬件单独存储的1位标签。这会增大指针密集型数据结构的内存占用。装满指针的链表或树会变得明显更大。对许多应用程序而言,开销仅为个位数百分比,但对指针追踪型工作负载来说可能更糟糕。
第二个问题是类型转换。C代码常常将指针转换为uintptr_t,进行算术运算后再转换回来。在CHERI上,uintptr_t实际上是一种权能类型,而非整数。假设可以对指针值进行任意整数运算的代码将无法编译,或在运行时触发陷阱。修复方案通常是使用ptrdiff_t表示偏移量,并通过cheri_address_set应用,但这需要修改源代码。
第三个问题是生态系统。CHERI硬件十分稀少。Arm Morello开发板虽然存在,但并非商用服务器。CheriBSD和启用了CHERI的Linux已足够成熟,可以运行真实软件,但大多数Linux发行版并未提供CHERI软件包。你的容器仓库、CI运行器和云服务商目前都不支持它。
无需定制芯片,今日即可体验
你不需要Morello开发板就能实验CHERI。CHERI项目维护着QEMU支持,因此你可以在任何Linux或macOS主机上运行CHERI用户空间。
以下是获取CHERI工具链并在QEMU中运行简单示例的方法:
# Clone the CHERI SDK setup
$ git clone https://github.com/CTSRD-CHERI/cheribuild.git
$ cd cheribuild
# Build the CheriBSD disk image and QEMU
$ ./cheribuild.py cheribsd-riscv64-purecap -d
$ ./cheribuild.py qemu -d
# Boot CheriBSD in QEMU
$ ./cheribuild.py run-cheribsd-riscv64
进入CheriBSD后,编译器是支持CHERI目标的clang。默认情况下编译会强制启用边界检查:
$ clang -o test test.c
$ ./test
# Buffer overflows trap immediately with a clear message
如果你想测试现有软件,cheribuild可以编译FreeBSD ports中的热门软件包并为其添加CHERI支持。CHERI团队已经移植了OpenSSH、nginx、PostgreSQL以及FreeBSD基础系统的大部分内容。许多程序无需修改即可运行。出现问题的程序通常是因为不安全的指针类型转换或对指针大小的错误假设。
若想跳过QEMU快速上手,剑桥大学还发布了预装CHERI LLVM工具链的Docker镜像。你可以在不启动完整操作系统的情况下编译CHERI二进制文件,并检查生成的权能感知汇编代码。
硬件内存安全对现有代码库意味着什么
CHERI并非安全编程的替代品,而是你已有代码的安全网。运行遗留C代码的CHERI系统仍然会有逻辑缺陷、竞态条件和释放后使用错误。但它不会再出现覆盖返回地址、破坏相邻堆元数据或跨越分配边界泄漏敏感信息的缓冲区溢出。
这一演进趋势已经显现。Arm已将CHERI衍生功能集成到其内存标记扩展(MTE)中,而MTE如今已部署在生产级Android设备上。MTE的粒度比CHERI更粗(它以16字节颗粒为单位进行标记,而非针对单个分配),但能以更低开销捕获大量同类缺陷。随着生态系统日趋成熟,完整的CHERI方案有望随之而来。
如果你维护着处理不可信输入的C或C++代码,问题不在于硬件内存安全是否会到来,而在于到来时你是否已经做好准备。请开始审查代码中的指针到整数转换、无关对象上的指针运算,以及对sizeof(void*)的假设。这些是在CHERI下会失效的编程模式,而修复它们即使对于传统硬件也能让代码更整洁。
硬件终于准备好说“不”了。我们应当允许它这么做。