NX位不仅仅关乎安全
摘要
一位开发者描述了在postmarketOS的ARM64裸机虚拟机管理程序中调试一个复杂bug的过程,涉及指令缓存一致性问题和与NX位相关的硬件特定行为。
<p><a href="https://lobste.rs/s/eokmrg/nx_bit_is_not_just_about_security">评论</a></p>
查看缓存全文
缓存时间: 2026/09/04 08:17
# NX位不仅仅关乎安全
来源:https://purplesyringa.moe/blog/guest/the-nx-bit-is-not-just-about-security/
2026年9月4日
客座文章 (https://sleirsgoevy.dev/)
> 当我暂时离开底层编程领域休息时,我的朋友索尼娅 (https://sleirsgoevy.dev/) 讲述了一个关于调试ARM代码中看似不可能存在的bug的故事。这个bug猎奇之旅始于数月之前。当我在为postmarketOS (https://postmarketos.org/) 开发基于ARM64的裸机虚拟机监控程序时,遇到了一个奇怪的bug:如果启用了CTR_EL0 (https://support.arm.com/documentation/ddi0601/2026-06/AArch64-Registers/CTR-EL0--Cache-Type-Register?lang=en) 拦截(这本是虚拟机监控程序的核心功能,无法跳过),手机就会随机死机。几秒钟后,看门狗就会启动并重置系统。起初我以为是启动过程被拖慢导致系统没有足够时间完成启动,但禁用看门狗也无济于事(幸运的是,该手机的电池是可拆卸的,我不必等待数小时让它自然放电)。于是我开始深入挖掘。
## 假设一:我对MRS的模拟存在错误
在Aarch64架构中,所谓的特殊功能寄存器(`CTR_EL0`即其中之一)通过`MRS`和`MSR`机器指令进行访问:
```
mrs x3, ctr_el0 // 读取访问
msr ctr_el0, x3 // 写入访问
```
这些指令在指定的SFR和通用寄存器(此例中为`X3`)之间传输数据,所有其他寄存器保持不变。因此故障必定意味着我破坏了需要保留的某些寄存器,或者未能正确写入目标输出寄存器。所以我首先验证了异常处理跳板代码:
```
trap_from_el1:
sub sp, sp, #256
stp x0, x1, [sp]
stp x2, x3, [sp, #16]
stp x4, x5, [sp, #32]
// ...
stp x28, x29, [sp, #224]
stp x30, xzr, [sp, #240]
mov x0, sp
bl handle_trap_from_el1
mrs x1, elr_el2
add x0, x0, x1
msr elr_el2, x0
ldp x0, x1, [sp]
ldp x2, x3, [sp, #16]
ldp x4, x5, [sp, #32]
// ...
ldp x28, x29, [sp, #224]
ldp x30, xzr, [sp, #240]
add sp, sp, #256
eret
```
以及栈分配:
```
.section .data.stack
.p2align 12
.long 0
.p2align 12
stack:
```
两者均正确无误,没有明显问题。我在QEMU中单步调试了整个异常处理程序,确认其行为完全符合预期。为确保在真实硬件上运行顺利,我在异常处理程序前后添加了调试打印语句,结果发现:在真实硬件上,异常处理程序并未修改任何寄存器,甚至连预期的输出寄存器也未被修改。罪魁祸首竟是这句看似无害的调用:
```
msr_accessor_sort(
msr_accessors,
((uintptr_t)msr_accessors_end - (uintptr_t)msr_accessors) / sizeof(struct msr_accessor)
);
```
众所周知,ARM架构的指令缓存与数据缓存不具一致性,这意味着对数据的修改不会自动同步到指令获取环节:要么修改后的数据尚未写入RAM,要么指令缓存仍保留着写入前的陈旧数据。由于缓冲区中包含可执行的机器指令,对数组进行排序后尝试执行必然失败。因此我将排序过程移至构建阶段,此时...
调试打印显示处理程序按预期工作,但系统仍然无法启动。
## 假设二:硬件不符合规范
众所周知,x86(-64)硬件仅由Intel和AMD两家厂商生产,因此可以期待系统行为保持高度一致。但ARM领域情况不同:虽然ARM提供了架构参考实现,但厂商可自由定制,甚至推出自研实现。这意味着ARM CPU往往存在许多微妙(或明显)的bug,其中某些甚至可能被开发者视为特性 (https://github.com/torvalds/linux/blob/4fe89d07dcc2804c8b562f6c7896a45643d34b2f/arch/arm64/kernel/head.S#L520)。于是下一个合理猜测是:CPU因某种原因不符合规范,未能按预期工作。为此我添加了额外处理程序,确保捕获所有异常:
```
vbar_el2:
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
```
`unknown_trap`程序将输出`X30`寄存器(熟悉Aarch32的读者可理解为`lr`)、`ELR_EL2`以及其他SFR以确定异常原因。然而实际并未触发任何处理。下一个合理猜测是内核因错误处理而崩溃,于是我使用/proc/last_kmsg (https://android.googlesource.com/kernel/common/+/c672528aec4a1cf6f3df7a6022e6823a20b20f8e) 读取崩溃内核的日志。不幸的是,日志中最后记录的信息是:
```
[ 3.113676] (0)[153:init]fs_mgr: Running /system/bin/e2fsck on /dev/block/platform/mtk-msdc.0/11230000.msdc0/by-name/userdata
[ 3.125072] (0)[158:e2fsck]random: e2fsck urandom read with 12 bits of entropy available
```
这表明内核并未干净地崩溃。我怀疑`/dev/urandom`设备是导致崩溃的元凶,并将其从内核中补丁移除以验证假设——这确实使启动进程略微推进,但效果有限。依然毫无进展。
此时我虽不知问题根源,但确定与拦截功能相关,因为完全禁用拦截即可解决。于是使用objdump过滤内核中的可疑指令:
```
$ aarch64-unknown-linux-gnu-objdump -D -b binary -m aarch64 kernel.orig | grep ctr_el0
```
共找到22处匹配,我手动在二进制文件中修改这些位置以返回正确值。启动修补后的内核仍无法进入Android系统,但`adb shell`可用,表明补丁至少部分有效。随后我将补丁缩减至少数“关键”指令,但仍对死机的具体原因一无所知。此时我怀疑虚拟机监控程序与内核可能存在内存覆盖冲突,于是决定用汇编重写`mrs x3, ctr_el0`指令的处理逻辑(所有“关键”补丁中真正重要的指令),完全避免使用内存:
```
// 保存X0,用作临时寄存器
msr tpidr_el2, x0
// 读取异常综合征寄存器;目标值为0x6232c061
// (https://esr.arm64.dev/#0x6232c061)
mrs x0, esr_el2
// 将x0与0x6232c061比较
// ARM指令不支持32位立即数,因此分组比较
sub x0, x0, #0x61
ror x0, x0, #12
sub x0, x0, #0x32c
ror x0, x0, #12
sub x0, x0, #0x62
cbnz x0, 1f
// 若ESR值不正确,跳转至通用处理程序
// 将保存的PC值递增4(指令长度)
mrs x0, elr_el2
add x0, x0, #4
msr elr_el2, x0
// 恢复X0,执行请求的读取,返回调用者
mrs x0, tpidr_el2
mrs x3, ctr_el0
eret
1:
// 在跳转至通用处理程序前恢复x0
mrs x0, tpidr_el2
```
居然成功了!此时我确认逻辑本身正确,问题出在C语言处理程序上。于是开始进一步二分排查:
```
if(esr == 0x6232c061) {
asm volatile("mrs %0, ctr_el0":"=r"(regs[3]));
return 4;
}
```
...可以工作。
我查找了用于读取`CTR_EL0`的`msr_accessor`地址,发现其位于`msr_accessors+0x28`:
```
if(esr == 0x6232c061) {
regs[3] = ((uint32_t(*)(void))(msr_accessors+5/*每个元素8字节*/))();
return 4;
}
```
...无法工作。是重定位导致的吗?我检查了重定位代码并尝试将链接基址设为实际加载地址,但无济于事。于是我决定通过链接器脚本将其定义为“新”符号:
```
get_ctr_el0 = msr_accessors + 0x28;
```
```
if(esr == 0x6232c061) {
uint32_t get_ctr_el0(void);
regs[3] = get_ctr_el0();
return 4;
}
```
这版本运行正常。现在我有两份语义等效的代码,却只有一份能工作。是时候进行二进制二分了!
## 二进制二分法:穷学生的ICE
此时我将问题代码提取为独立函数:
```
static __attribute__((noinline,optimize(3))) void handle_mrs_x3_ctr_el0(uint64_t* regs) {
// asm volatile("mrs %0, ctr_el0":"=r"(regs[3])); // 可工作
// regs[3] = get_ctr_el0(); // 同样可工作
regs[3] = ((uint32_t(*)(void))(msr_accessors+5))(); // 不可工作
}
```
并在主处理程序中调用:
```
if(esr == 0x6232c061) {
handle_mrs_x3_ctr_el0(regs);
return 4;
}
```
问题依然存在,说明现在可聚焦于单个函数。起初怀疑代码体积是诱因,在函数开头添加大量NOP指令仍无效。随后反汇编该函数:
**正常版本:**
```
0000000040204fc0 <...>:
40204fc0: a9be7bfd stp x29, x30, [sp, #-32]!
40204fc4: 910003fd mov x29, sp
40204fc8: f9000bf3 str x19, [sp, #16]
40204fcc: aa0003f3 mov x19, x0
40204fd0: 94000ab6 bl 40207aa8
40204fd4: 2a0003e0 mov w0, w0
40204fd8: f9000e60 str x0, [x19, #24]
40204fdc: f9400bf3 ldr x19, [sp, #16]
40204fe0: a8c27bfd ldp x29, x30, [sp], #32
40204fe4: d65f03c0 ret
```
**故障版本:**
```
0000000040204fc0 <...>:
40204fc0: a9be7bfd stp x29, x30, [sp, #-32]!
40204fc4: f0000001 adrp x1, 40207000
40204fc8: f944f821 ldr x1, [x1, #2544]
40204fcc: 910003fd mov x29, sp
40204fd0: f9000bf3 str x19, [sp, #16]
40204fd4: aa0003f3 mov x19, x0
40204fd8: 9100a021 add x1, x1, #0x28
40204fdc: d63f0020 blr x1
40204fe0: 2a0003e0 mov w0, w0
40204fe4: f9000e60 str x0, [x19, #24]
40204fe8: f9400bf3 ldr x19, [sp, #16]
40204fec: a8c27bfd ldp x29, x30, [sp], #32
40204ff0: d65f03c0 ret
```
我开始用汇编统一两个函数,确保正常版本仍可工作、故障版本仍不可工作,最终得到:
```
handle_mrs_x3_ctr_el0:
stp x29, x30, [sp, #-32]!
mov x29, sp
str x19, [sp, #16]
mov x19, x0
adr x0, get_ctr_el0 // 将x0加载为get_ctr_el0的地址
#if 0 // 故障版本
blr x0 // 调用x0存储的地址处的函数
#else // 正常版本
bl get_ctr_el0 // 调用get_ctr_el0函数
#endif
mov w0, w0
str x0, [x19, #24]
ldr x19, [sp, #16]
ldp x29, x30, [sp], #32
ret
```
显而易见,两个版本语义等效——这意味着要么在特定时刻有中断干扰且未能恢复寄存器,要么存在微架构缺陷。我迅速排除了前者:在ARM64架构中,比虚拟机监控程序特权级更高的只有TrustZone,而它没有理由接收任何中断,因此只能是后者。这款手机搭载的是采用Cortex-A53核心的联发科MT6735芯片,阿丽萨协助我查阅了Cortex-A53的勘误表,却未发现类似问题...直到我想起Linux源码中的特定注释 (https://github.com/torvalds/linux/blob/841e384b841a3d89c50b4b2d6c5bb6abab1a7e39/arch/x86/boot/startup/map_kernel.c#L185)。在某些x86系统上,对MMIO寄存器的投机访问会导致系统关机。
我设计了一个思想实验:若问题确系如此,该如何阻止?防止任何物理地址访问(包括投机访问)的最简单方法是永远不映射该地址,但这会影响虚拟机监控程序自身对MMIO空间的使用(它将整个地址空间设计为1:1映射)。我开始考虑“延迟分页”方案:按需映射被架构访问的内存范围(CPU不会为投机访问发出页缺页错误),此时我突然顿悟:两条指令有何区别?`blr x0`是动态跳转,`bl get_ctr_el0`是静态跳转。动态分派与静态分支有何不同?动态分派依赖分支预测,静态分支提前知道跳转目标且不会预测错误。指令可能错误预测到什么地址?...很可能是空指针`0x0`。它不可能凭空生成地址。我的1:1映射在`0x0`地址处存放着什么?...呃...那是引导ROM (https://github.com/cyrozap/mediatek-lte-baseband-re/blob/c1620cb60d3ca4dbc90228745843bc8975c0b052/SoC/MT6735/Notes.md?plain=1#L8),在引导加载程序初始化期间已被锁定。...接着我意识到:困扰我的投机访问并非数据访问,而是**指令获取**。这意味着我只需将页面标记为不可执行即可。我这样做了,于是系统首次在半年内无需内核补丁就完整启动到了Android系统!
当我明确查找方向后,快速搜索“arm64 speculative instruction fetch mmio”找到了官方ARM文档 (https://support.arm.com/documentation/102376/0200/Device-memory) 中的关键段落:
> 这里存在一个微妙但容易忽略的区别。将区域标记为Device类型仅能阻止投机数据访问。标记为Non-executable才能阻止投机指令访问。这意味着要完全阻止投机访问,区域必须同时标记为Device和Non-executable。
## 不仅仅关乎安全
作为某种程度上的黑客,我一直认为数据执行保护(DEP)纯粹是为对抗栈溢出攻击而发明的安全措施,因此没有费心实现它:虚拟机监控程序本就不打算投入生产环境,深度防御属于过度工程。但结果表明,在ARM架构中这根本与安全无关——本质上这是一个属性位。ARM保证对于映射为Device内存(MMIO的新说法)的区域,不会发生投机访问。但这仅适用于数据访问——指令获取会将任何可执行内存视为普通内存。阻止内存区域被投机执行的唯一方法就是使其**完全不可执行**。此外,既然必须实现非可执行内存的映射机制,我决定将除载荷外的所有内存映射为非可执行,因此我的虚拟机监控程序最终也具备了一定的深度防御能力!
相似文章
当 Ldaxr 失灵时:AArch64 上的独占访问与缓存能力
作者在为 AArch64 从零编写自制操作系统 floss 的过程中,深入分析了 ldaxr/stxr 独占内存访问指令在 QEMU 上运行正常、却在真实硬件上触发异常的问题,探讨了自旋锁、异常处理、虚拟内存、内存类型与缓存属性之间的复杂交互。
某些x86 CPU中的硬件后门
安全研究人员Domas揭示了rosenbridge,这是VIA C3 x86 CPU中的一个硬件后门,允许非特权代码绕过处理器保护并访问内核内存。该存储库提供了检查并缓解此漏洞的工具。
@gerhart_x: "NVX 是一个超轻量级 micro-VM 沙箱,用于在硬件强制隔离下运行不受信任的工作负载。它基于……"
Microsoft 开源了 NVX,这是一个超轻量级的 micro-VM 沙箱,用于在硬件强制隔离下运行不受信任的工作负载,基于 OpenVMM 构建,支持以 Linux 作为客户机,并兼容 KVM、MSHV 和 Windows Hypervisor Platform。
x86 模拟的难题
本文讨论在 ARM 的弱序内存模型上模拟 x86-TSO 内存模型的挑战,涵盖原子指令、分裂锁和未缓存内存等问题,并探讨潜在解决方案。
TrustZone 插曲:i.MX 8M 上 OP-TEE 内存隔离缺陷
文章揭示,由于缺少 TZASC 配置,NXP i.MX 8M SoC 上的 OP-TEE 内存隔离可以从普通世界被绕过,从而允许拥有内核访问权限的攻击者读取安全世界内存。修复方案已在上游 OP-TEE v4.10.0+ 中提供,需配置 CFG_TZASC_REGION0_SECURE=y,或使用下游版本 lf-6.12.49_2.2.0。