NX位不仅仅关乎安全

Lobsters Hottest 新闻

摘要

一位开发者描述了在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 上的独占访问与缓存能力

Hacker News Top

作者在为 AArch64 从零编写自制操作系统 floss 的过程中,深入分析了 ldaxr/stxr 独占内存访问指令在 QEMU 上运行正常、却在真实硬件上触发异常的问题,探讨了自旋锁、异常处理、虚拟内存、内存类型与缓存属性之间的复杂交互。

某些x86 CPU中的硬件后门

Hacker News Top

安全研究人员Domas揭示了rosenbridge,这是VIA C3 x86 CPU中的一个硬件后门,允许非特权代码绕过处理器保护并访问内核内存。该存储库提供了检查并缓解此漏洞的工具。

x86 模拟的难题

Hacker News Top

本文讨论在 ARM 的弱序内存模型上模拟 x86-TSO 内存模型的挑战,涵盖原子指令、分裂锁和未缓存内存等问题,并探讨潜在解决方案。

TrustZone 插曲:i.MX 8M 上 OP-TEE 内存隔离缺陷

Lobsters Hottest

文章揭示,由于缺少 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。