你的手机可以在生产环境中检测内存损坏。不是用你在 CI 中运行的完整插桩,也不是针对每一次分配。但你口袋里的硬件在几年前就已经搭载了必要的原语,而且越来越多的生产应用正在悄悄启用它们。
简而言之:AddressSanitizer 对生产环境来说太贵了。ARM Memory Tagging Extension 则不然。如果你还无法依赖 MTE,GWP-ASan 能以几乎零开销提供概率性覆盖。它们共同回答了一个过去只有令人沮丧答案的问题。
内存损坏是你 shipped 出去的 bug
use-after-free、buffer overflow 和 heap corruption 是那种能通过每一套测试套件的 bug。它们存在于原生代码中,难以复现,而且经常在远离实际错误的地方崩溃。当用户报告崩溃时,最初的损坏已经发生,你唯一的证据是一段损坏的 stack trace,或者一个看起来随机的地址上的 SIGSEGV。
传统工具能及早发现这些问题。Valgrind 准确但缓慢。ASan 更快,但仍会增加大约 2-3 倍的内存开销和 2 倍的 CPU 开销。这对 fuzzing 或单元测试来说没问题。对一台运行 Instagram 的电池供电设备来说则不行。
生产环境历来是一个盲点。你要么在本地复现了 bug,要么就只能猜测。
ARM MTE 如何在硬件层面为内存打标签
在 ARMv8.5-A 中引入的 ARM Memory Tagging Extension 为 CPU 提供了一种低成本的方式,在每次内存访问时检查指针的有效性。它的工作方式是为内存的每个 16 字节颗粒分配一个 4 位标签,并在指针的最高字节中存储一个匹配的标签。
在每次 load 或 store 时,CPU 会比较指针标签和内存标签。如果两者不同,硬件就会触发 fault。这不是软件检查。它发生在内存子系统中,开销通常低于 5%。
64 位指针的最高字节原本就被硬件忽略。MTE 重新利用了这些位。像 0xb7f0_0000_1234_5678 这样的指针携带标签 0xb7。该地址处的内存必须携带相同的标签,否则访问就会 fault。
MTE 在搭载 ARMv8.5-A 或更新版本的设备上可用。这包括 Google Pixel 8 及更新机型、iPhone 15 Pro 及更新机型,以及越来越多中端 Android 设备。它尚未普及,但已不再稀奇。
内核通过 prctl 标志暴露 MTE。应用可以请求同步或异步检查。同步模式在标签不匹配时立即 fault。异步模式将 fault 排队稍后投递,成本更低但会略微延迟检测。
GWP-ASan:当你无法为所有内存打标签时
MTE 很好,但它需要兼容的硬件和整个代码库中的标签化分配。如果你在旧设备上发布,或者只需要有针对性的保护,GWP-ASan 是一个务实的替代方案。
GWP-ASan 代表 Guarded Write Protection AddressSanitizer。它是一个采样分配器,将一小部分堆分配放入被毒化 redzone 包围的受保护页面中。如果 use-after-free 或 buffer overflow 触及这些 redzone,硬件 MMU 就会立即触发 fault。
核心洞察在于概率。GWP-ASan 可能每 10,000 次分配中保护 1 次。这听起来毫无用处,直到你意识到一个有 bug 的应用在崩溃前会损坏内存数千次。只要有足够多的用户会话,即使 0.01% 的采样率也能在生产环境中捕获真正的 bug。
Google 已在 Chrome 和 Android 系统服务中运行 GWP-ASan 多年。它发现了数百个从测试和 fuzzing 中逃掉的 use-after-free bug。CPU 开销可以忽略不计,因为只有极小部分的分配会经过受保护路径。内存开销也是有限的,因为受保护池很小,而且分配最终会被回收。
没人愿意谈论的权衡
MTE 和 GWP-ASan 不是免费的。它们只是足够便宜,值得使用。
MTE 需要每个指针的最高字节,这意味着你的代码库必须用 -fsanitize=memtag 或 -march=armv8.5-a+memtag 编译。如果你有对指针进行掩码、哈希或不带正确标签就通过 JNI 传递的代码,你会得到误报。Android NDK 和现代 libc 实现能正确处理这一点,但自定义分配器或指针打包方案会出问题。
MTE 也只能捕获你显式标记的堆和栈区域内的 bug。一个跳到未标记 mmap 区域的 wild pointer 不会被捕获。这聊胜于无,但并非完全覆盖。
GWP-ASan 有相反的问题。它只捕获恰好落入受保护池的分配。损坏未受保护分配的 bug 会被忽略。你还需要为采样集合的分配和释放支付少量延迟成本,并且需要在遥测管道中优雅地处理崩溃。
这两种工具都不能取代 CI 管道中的 ASan。它们是对 ASan 的补充。ASan 在测试期间提供确定性的高覆盖检测。MTE 和 GWP-ASan 在现场提供安全网。
在现代 Android 设备上启用 MTE
如果你有 Pixel 8 或更新机型,今天就可以测试 MTE。Android NDK 仅用一个编译器标志就支持它。
首先,检查你的设备是否支持 MTE:
#include <sys/prctl.h>
#include <linux/prctl.h>
#include <sys/auxv.h>
#include <asm/hwcap.h>
bool mte_supported() {
unsigned long hwcap2 = getauxval(AT_HWCAP2);
return (hwcap2 & HWCAP2_MTE) != 0;
}
然后在 CMakeLists.txt 中启用 MTE 来构建你的原生库:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=memtag")
如果你想要同步 faulting——这是你在生产环境中通常想要的——在启动时请求它:
#include <sys/prctl.h>
void enable_mte() {
if (mte_supported()) {
prctl(PR_SET_TAGGED_ADDR_CTRL,
PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
0, 0, 0);
}
}
PR_MTE_TCF_SYNC 标志告诉内核在标签不匹配时立即 fault。标签掩码 0xfffe 排除零标签,一些系统代码仍在使用它。
为生产环境设置 GWP-ASan
GWP-ASan 更容易启用,因为它不需要特殊硬件。在 Android 上,你可以链接到 GWP-ASan 分配器包装器,或在新 API 级别上通过系统分配器启用它。
对于最小化集成,包装你的分配器入口点:
extern "C" void* malloc(size_t size) {
if (__gwp_asan_sample()) {
return __gwp_asan_guarded_malloc(size);
}
return __libc_malloc(size);
}
在实践中,你会使用 Android GWP-ASan 运行时或 LLVM compiler-rt 实现。关键的配置旋钮是采样率。保守起步:
// 每 10,000 次分配中受保护 1 次
__gwp_asan_set_sample_rate(10000);
通过现有的遥测系统收集产生的崩溃。GWP-ASan 崩溃看起来像一个标准 SIGSEGV,但 faulting 地址会落在一个受保护页面中。你的崩溃报告器可以通过检查 faulting 地址是否落在 GWP-ASan 池中来检测这一点。
你真正应该做的事
如果你在移动设备上发布原生代码,你应该已经在 CI 和 fuzzing 中运行 ASan。问题是发布之后会发生什么。
如果你支持的最低设备包含 ARMv8.5-A 芯片,在同步模式下启用 MTE。开销低到用户不会察觉,而且你得到的崩溃将包含准确的标签不匹配信息,而不是随机的堆损坏。
如果你支持旧设备,以保守的采样率启用 GWP-ASan。它不会捕获每一个 bug,但会捕获其他手段无法捕获的 bug。在数百万会话中,这不是理论上的。Chrome 去年就是这样在其图像解码器中发现了一个 use-after-free。
你手机中的硬件已经具备捕获你最害怕的内存安全 bug 的能力。唯一的问题是你是否启用了它。