你有一个用 C 编写的codebase,它大到无法全部用 Rust rewrite,又关键到不能任由缓冲区溢出威胁。CHERI capability 硬件承诺在 CPU 层面捕获内存安全违规,但网上一直有人说 CHERI 指针是 128 位的,而你的代码默认指针是 64 位。
好消息是,CHERI 并不强制要求非此即彼的迁移。hybrid ABI 让你能够以极少的改动编译现有 C 代码,在真实的 CHERI 硬件上运行,并在最关键的地方逐步加强安全性。你不需要先移植一百万行代码,才能获得第一个受保护的指针。
为什么增量移植现在成为可能
早期的 capability 架构毫不妥协。iAPX 432 要求每个指针都必须是 capability,没有例外。这导致所有现有的编译器和操作系统都无法运行,项目最终夭折。
CHERI 吸取了这一教训。它支持三种编译模式:baseline(仅使用传统的整数指针)、hybrid(整数指针与 capability 指针共存)和 purecap(每个指针都是 capability)。hybrid ABI 就是其中的桥梁。它让你保持现有代码基本不变,同时在最能发挥价值的地方选择性地引入 capability。
在 hybrid 模式下,void* 仍然是 64 位。capability 指针是一种独立的类型 void* __capability,需要显式选择使用。你的结构体、链表和哈希表都不会改变大小,除非你主动要求。这不是一个兼容性层,而是一个由硬件和编译器原生支持的一等 ABI。
为 CHERI hybrid 编译时到底会出什么错
第一步是尝试用 CHERI 工具链编译你的代码,看看哪些地方会炸。大多数行为良好的 C 代码无需修改就能编译通过。问题集中在少数几种被 CHERI 判定为非法的模式上——而这正是其意义所在。
指针转整数。 将指针转换为 uintptr_t、掩码某些位、再转换回来的代码将会失败。在 CHERI 上,uintptr_t 是一种 capability 类型,而非普通整数。对 capability 进行位操作会清除 tag 位,导致结果值在解引用时触发 trap。
对指针大小的假设。 硬编码 sizeof(void*) == 8 或将指针序列化为 8 字节值的代码在 purecap 模式下会崩溃。在 hybrid 模式下这通常能工作,但当你在同一个结构体中混用 capability 指针和整数指针时,问题就会出现。
跨无关对象的指针运算。 C 程序员有时会计算两个任意指针之间的距离,或比较来自不同分配的指针。CHERI capability 携带边界元数据,对不同分配的 capability 做减法属于未定义行为。编译器会拒绝这类操作,或者硬件会触发 trap。
内联汇编。 任何在整数寄存器中移动指针的手写汇编都需要更新。CHERI 有专门的 capability 寄存器,编译器需要知道你操作的是哪些寄存器。
CHERI LLVM 工具链提供了出色的诊断信息。你不会遇到晦涩难懂的链接器错误。你会看到清晰的信息,例如 “cast from capability to integer is not allowed” 或 “arithmetic on capabilities from different allocations”。修复第一个错误,重新编译,然后继续处理下一个。
具体示例:用 capability 封装解析器
假设你有一个网络数据包解析器,它将不受信任的输入读取到缓冲区,然后解析报头。这正是能从硬件边界检查中受益的代码类型。下面介绍如何在不 rewrite 解析器本身的情况下为其添加封装。
首先,在 hybrid 模式下编译解析器。CHERI 工具链是带有 CHERI 目标的标准 LLVM:
# 使用 hybrid ABI 编译单个文件
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-mno-relax \
-c parser.c -o parser.o
-mabi=lp64d 标志保持整数指针为 64 位。你现有的 void* 和 char* 类型保持不变。解析器可以原样编译。
现在,在另一个文件中添加一个支持 capability 的封装层:
#include <cheriintrin.h>
#include <stddef.h>
#include <stdint.h>
// 来自 parser.c 的现有解析器
extern int parse_packet(const char *data, size_t len);
// 支持 capability 的入口点
int parse_packet_safe(const char * __capability data, size_t len) {
// 将 capability 精确缩小到缓冲区长度。
// 即使调用者传入了一个更大的分配区域,
// 解析器也无法读取超过 'len' 字节的内容。
const char * __capability narrowed =
cheri_bounds_set(data, len);
// 在 hybrid 模式下,我们向旧版解析器传递一个普通指针。
// 编译器会在此处插入 capability 到整数的转换。
// 如果 capability 无法放入 64 位(对于大 capability 地址来说不行),
// 这里会产生警告或错误。
//
// 如果迁移到 purecap,你应该修改 parse_packet
// 使其接受 capability 指针。
return parse_packet((const char *)narrowed, len);
}
在这个例子中,parse_packet 仍然使用传统的整数指针。封装层将调用者的 capability 精确缩小到输入的实际长度,然后再转换回去。缩小这一步意味着,即使调用者不小心传入了 4KB 的缓冲区而本意是传 64 字节,硬件也会在缩小后的 capability 上强制执行 64 字节的边界限制。
这不是最终状态,而是一块垫脚石。今天你就在 API 边界获得了 bounds enforcement,而在之后的 refactor 中,你可以将 parse_packet 迁移到 purecap。
从 hybrid 到 purecap 的迁移路径
Hybrid 模式是一个起点,而非终点。它提供了兼容性,但无法带来 capability 的全部安全收益。长期目标是 purecap,即每个指针都携带边界信息。
实际的迁移路径如下:
-
在 hybrid 模式下编译并修复构建错误。 这通常意味着修复指针转整数的强制转换和
sizeof(void*)的假设。先不要修改数据结构,只需得到一个干净的构建。 -
识别高价值目标。 网络解析器、文件格式解码器和反序列化例程是 capability enforcement 的最佳候选对象。它们处理不受信任的输入,也是大多数内存安全 bug 的藏身之处。
-
用缩小的 capability 封装这些目标。 在信任边界使用
cheri_bounds_set创建受限的 capability,然后将它们传入现有代码。 -
将叶函数迁移到 purecap。 从分配并返回指针的工具函数开始。将它们的签名改为使用
__capability,并用-mabi=purecap编译。然后沿着调用栈向上推进。 -
最终将整个 module切换到 purecap。 当一个编译单元中不再含有整数指针时,用
-mabi=purecap编译它,并将其与剩余的 hybrid 代码链接。CHERI 工具链支持混合 ABI 链接。
对于大型codebase来说,这不是一个周末就能完成的项目。但它也不是一次 rewrite。你可以在几天内保护住最脆弱的代码路径,然后在后续维护中逐步迁移其余部分。
真实的权衡是怎样的
hybrid ABI 有真实的代价,在投入之前你应该了解它们。
首先,混合 ABI 会让构建变得复杂。你的同一个二进制文件中现在包含了使用不同指针大小编译的目标文件。链接器必须同时处理 capability 和整数的重定位。CHERI 工具链支持这一点,但你的构建系统可能还不知道如何处理。你需要教会它。
其次,hybrid 边界处的 capability 到整数转换是有损的。如果你缩小了一个 capability,然后将其转换为普通指针,你就会失去边界信息。底层内存仍然受到你最初那个 capability 的保护,但旧版函数不会接收到 hardware enforcement。这就是为什么 purecap 才是目标:调用链中的每个指针都携带自己的边界。
第三,调试方式会发生变化。CHERI 上的 GDB 能识别 capability 寄存器并打印边界元数据。LLDB 的支持也在不断完善。如果你当前的调试流程依赖于检查原始指针值,你需要学习 CHERI 的寄存器名称。
对于主要使用整数指针的代码,hybrid 模式的性能开销通常可以忽略不计。对于大多数工作负载,purecap 的开销通常是百分之几,对于指针密集的数据结构则会上升到 10-15%。这与 ASAN 等软件缓解方案相比具有竞争力,而且与 ASAN 不同,CHERI 在生产环境中可以全速运行。
今天就可用 QEMU 开始
你不需要 Morello 开发板就能开始实验。CHERI 项目维护了 QEMU 支持,以及预装了 LLVM 工具链的 Docker 镜像。
# 拉取 CHERI 工具链 Docker 镜像
$ docker run --rm -it ctsrd/cheri-sdk:latest
# 在容器内,为 RISC-V CHERI 编译你的代码
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-o myapp myapp.c
首先,在 hybrid 模式下编译你的项目并统计错误数量。这个数字会告诉你前方有多少工作要做。对于现代、符合标准的 C 代码,第一次就干净构建虽然少见,但并非不可能。对于有大量指针转换的老旧codebase,出现几百个错误是典型情况。
按以下顺序修复错误:先修复整数到指针的强制转换,然后是跨分配的指针运算,最后是内联汇编。每一类都有机械的修复方法。CHERI 项目发布了一份移植指南,其中包含最常见模式的前后对比示例。
Capability 硬件不是一个理论上的未来。它是一个可用的工具链、一个受支持的 ABI,以及一条不需要将你的codebase付之一炬的迁移路径。从一个文件、一个函数、一个缩小的 capability 开始。硬件会完成剩下的工作。