1. 从一次内核崩溃说起:为什么不能直接用memcpy?
几年前,我在调试一个基于arm64服务器的视频驱动时,遇到了一个非常诡异的问题。驱动里有一段代码,需要从用户态应用程序传递上来的一个结构体中拷贝一些配置参数。为了图省事,我当时直接用了memcpy,心想:“反正数据已经在内核里了,从内核的一个缓冲区拷贝到另一个内核缓冲区,能有什么问题?”
结果,系统时不时就会崩溃,报出一个难以捉摸的“Unable to handle kernel paging request”错误。更让人头疼的是,这个崩溃是概率性的,十次里可能只出现一两次,复现和定位都极其困难。我花了大量时间检查内存越界、空指针,甚至怀疑硬件有问题,但都一无所获。
直到后来,我把memcpy换成了copy_from_user,问题就神奇地消失了。这件事给我上了深刻的一课:在内核里,访问用户空间的数据,绝不是简单的内存拷贝那么简单。这背后涉及到一个核心的安全边界问题——用户空间和内核空间是隔离的。用户空间的程序是“不可信”的,它可能无意或有意地传入一个非法地址(比如一个指向内核代码区的指针)。如果内核直接去访问这个地址,轻则读到错误数据,重则导致系统崩溃或被恶意利用。
所以,Linux内核设计了一整套机制来安全地跨越这个边界,copy_from_user和copy_to_user就是其中的“守门员”。它们不仅仅做拷贝,更重要的是在执行拷贝前,会进行一系列严格的地址合法性检查。但光有软件检查就够了吗?现实告诉我们,软件总有疏漏,驱动程序的bug、检查逻辑的遗漏,都可能成为安全漏洞的源头。
于是,硬件厂商站了出来,在CPU架构层面提供了更底层的防护。在arm64架构中,这就是PAN(Privileged Access Never)和UAO(User Access Override)两个特性。你可以把它们理解为CPU内置的“硬件防火墙”,专门用来加固用户态和内核态之间的那堵墙,即使软件检查偶尔“打盹”,硬件也能兜底,把危险的操作直接拦截在CPU指令执行阶段。接下来,我们就一起拆解这两个硬核特性,看看它们是如何工作的。
2. 权限的困境:内核为何总能“看见”用户的数据?
要理解PAN和UAO的必要性,我们得先看看在没有它们的时候,arm64的内存访问权限是怎么设置的。这涉及到页表项里的AP(Access Permission)位。
想象一下,内存就像一栋大楼,每个房间(内存页)都有两把锁:一把给住户(用户态)用,一把给管理员(内核态)用。AP位就定义了这两把锁的权限。通常的设定是:管理员(内核)拥有的权限,总是大于或等于住户(用户)。
举个例子:
- AP[2:1] = 01:住户有读写权限(RW),管理员也有读写权限(RW)。
- AP[2:1] = 11:住户只有读权限(R),管理员也只有读权限(R)。
看起来合情合理,管理员权力更大嘛。但问题就出在这里。因为内核拥有更高的特权级,当它运行在内核态时,CPU使用的一套指令(比如ldr,str)天生就能访问任何有权限的内存,包括用户空间的内存。这就好比管理员不仅有自己的万能钥匙,还能用万能钥匙打开任何一个住户的房间。
这带来了一个巨大的安全隐患:如果一个恶意(或有bug)的应用程序,在调用copy_from_user时,传入的缓冲区地址不是一个真正的用户空间地址,而是一个伪装成用户地址的内核空间地址呢?传统的软件检查access_ok()虽然会校验地址范围,但历史上确实存在一些驱动漏掉了这个检查,或者检查逻辑有误。一旦内核的copy_from_user函数(或其底层实现)信以为真,用特权指令ldr去这个地址取数据,它就能成功读到或写入内核的敏感数据!这相当于住户骗管理员说“我家水管坏了,请用您的万能钥匙开门”,结果管理员打开的是隔壁的军火库。
所以,我们需要一种机制,即使在内核态,也能明确地告诉CPU:“现在,请暂时收起你的万能钥匙,只用住户的钥匙去开指定的那扇门。” PAN和UAO就是实现这个目标的硬件开关。
3. 硬件守卫PAN:特权模式下的“访问禁区”
PAN(Privileged Access Never)特性是在Armv8.1-A架构中引入的。它的理念非常直接和强硬:当CPU运行在特权模式(如EL1,对应Linux内核态)时,禁止访问任何标记为用户可访问的内存页。
你可以把PSTATE寄存器里的PAN位想象成内核态CPU头上的一盏红灯。当PSTATE.PAN = 1(红灯亮)时,一条绝对的禁令生效:任何试图从内核态访问用户空间内存的特权指令(如ldr,str),都会立即触发一个Data Abort异常,访问被硬性中止。
那么,像copy_from_user这种正当的、需要访问用户空间的操作怎么办呢?这就需要内核在执行这些特定操作前,临时把红灯关掉。我们来看内核源码是怎么做的:
// 以 __arch_copy_from_user 为例 ENTRY(__arch_copy_from_user) uaccess_enable_not_uao x3, x4, x5 // 关键步骤1:准备访问用户空间,临时禁用PAN add end, x0, x2 #include "copy_template.S" // 实际执行拷贝的代码模板 uaccess_disable_not_uao x3, x4 // 关键步骤2:访问完毕,立即重新启用PAN mov x0, #0 ret ENDPROC(__arch_copy_from_user)关键在uaccess_enable_not_uao和uaccess_disable_not_uao这两个宏。我们展开其中一个看看:
.macro uaccess_enable_not_uao, tmp1, tmp2, tmp3 uaccess_ttbr0_enable \tmp1, \tmp2, \tmp3 alternative_if ARM64_ALT_PAN_NOT_UAO SET_PSTATE_PAN(0) // 将 PSTATE.PAN 设为 0,临时禁用PAN保护 alternative_else_nop_endif .endm这个SET_PSTATE_PAN(0)就是关闭红灯的指令。它在拷贝操作开始前执行,允许接下来的ldr指令访问用户空间。拷贝一结束,SET_PSTATE_PAN(1)立刻把红灯再打开,恢复保护状态。
这种“用时打开,用完即关”的模式,极大地缩小了攻击面。即使某个驱动忘记调用access_ok()检查,只要它试图用普通的内核指令去访问用户地址,而当时PAN处于开启状态,CPU就会在硬件层面直接拦截,触发异常。内核的异常处理程序可以捕获这个错误,将其转换为一个安全的错误码(-EFAULT)返回给调用者,从而避免系统崩溃或数据泄露。这就从根源上防御了前面提到的“CVE-2018-20669”这类因检查缺失导致的漏洞。
4. 权限翻转UAO:给用户指令加上“特权滤镜”
PAN解决了内核态指令 (ldr/str) 乱访问用户空间的问题。但armv8架构还有另一类指令:非特权加载/存储指令,如ldtr(Load Register) 和sttr(Store Register)。这些指令设计给用户态程序使用,它们执行时会采用用户态的权限去检查内存访问。
UAO(User Access Override)特性是在Armv8.2-A中引入的,它玩了一个更巧妙的“权限翻转”游戏。它通过控制PSTATE.UAO位,来改变ldtr/sttr这类指令的行为。
- 当
PSTATE.UAO = 0(默认/关闭):ldtr/sttr指令老老实实地以非特权(用户)权限访问内存。在内核态执行它们,也只能访问用户空间,访问内核空间会触发异常。 - 当
PSTATE.UAO = 1(开启):ldtr/sttr指令的行为会发生“覆盖(Override)”。此时,它们会像特权指令ldr/str一样,使用当前CPU模式(即内核特权)的权限去访问内存。
这有什么用呢?这实际上为内核提供了一套“安全模式”的访问指令。我们结合set_fs()这个经典机制来看。内核的addr_limit变量用于标记当前任务能访问的地址空间上限(USER_DS或KERNEL_DS)。set_fs(KERNEL_DS)通常在内部函数(如__probe_kernel_read)中调用,表示临时允许访问内核地址。
在支持UAO的系统中,set_fs的实现会同步控制UAO位:
static inline void set_fs(mm_segment_t fs) { current_thread_info()->addr_limit = fs; // ... if (IS_ENABLED(CONFIG_ARM64_UAO) && fs == KERNEL_DS) asm(ALTERNATIVE("nop", SET_PSTATE_UAO(1), ARM64_HAS_UAO)); // 切换到KERNEL_DS时,开启UAO else asm(ALTERNATIVE("nop", SET_PSTATE_UAO(0), ARM64_HAS_UAO)); // 其他情况,关闭UAO }这样一来,就形成了一套精妙的组合拳:
- 当内核需要访问用户空间时(如
copy_from_user),它设置addr_limit = USER_DS且UAO=0。此时,它使用ldtr指令。由于UAO关闭,ldtr以用户权限访问,正好可以安全地读取用户内存,而一旦误操作想去读内核地址,就会被CPU阻止。 - 当内核需要访问内核空间时(在某些特殊路径,如
__probe_kernel_read),它设置addr_limit = KERNEL_DS且UAO=1。此时,它还是使用ldtr指令。但由于UAO开启,ldtr的行为被覆盖为使用内核权限,因此可以顺利读取内核内存。但如果它不小心去读用户地址,反而会触发异常。
UAO的本质,是让同一套指令 (ldtr/sttr) 根据不同的场景切换其“权限上下文”,从而在硬件层面强制实现了“该干什么的时候才有什么权限”的原则,进一步减少了因上下文或配置错误导致的非法访问。
5. 实战中的协同:PAN与UAO如何改写copy_from_user
理解了原理,我们看看它们在真实的内核拷贝代码里是如何携手工作的。以copy_from_user的底层实现为例,内核的汇编宏会根据CPU支持的硬件特性,智能地选择指令和配置状态。
// 用于拷贝一个字节的宏定义 .macro ldrb1 ptr, regB, val uao_user_alternative 9998f, ldrb, ldtrb, \ptr, \regB, \val .endm .macro uao_user_alternative l, inst, alt_inst, reg, addr, post_inc alternative_if_not ARM64_HAS_UAO // 如果CPU不支持UAO 8888: \inst \reg, [\addr], \post_inc // 使用普通特权指令 ldrb nop alternative_else // 如果CPU支持UAO \alt_inst \reg, [\addr] // 使用非特权指令 ldtrb add \addr, \addr, \post_inc alternative_endif _asm_extable 8888b,\l // 异常修复表 .endm这段代码是内核“条件编译”在汇编层面的体现(通过alternative指令):
- 在不支持UAO的CPU上:内核会使用普通的
ldrb(特权指令)进行拷贝。此时,PAN特性就至关重要。在进入拷贝例程时,代码会通过uaccess_enable_not_uao临时禁用PAN,让ldrb能够访问用户空间。拷贝完成后立即重新启用PAN。如果PAN没有正确禁用,ldrb访问用户地址就会触发异常。 - 在支持UAO的CPU上:内核会优先使用
ldtrb(非特权指令)进行拷贝。此时,PAN通常保持默认开启状态(PSTATE.PAN=1)。因为ldtrb本身就不受PAN限制(PAN只限制特权指令)。内核通过控制PSTATE.UAO=0,确保ldtrb只拥有用户权限,从而安全地访问用户空间。如果程序错误地传入一个内核地址,ldtrb在用户权限下无法访问,同样会触发异常。
所以,PAN和UAO是从两个不同方向解决同一类问题:
- PAN是“拉黑名单”,默认禁止特权指令访问用户空间,只在白名单(
copy_from_user)里临时放行。 - UAO是“控制权限钥匙”,通过切换非特权指令的权限上下文,让它们在访问用户空间时只能用“用户钥匙”,在需要访问内核空间时(特殊场景)才换成“内核钥匙”。
两者结合,为内核数据交互构建了双重硬件保险。我在排查一些现代arm64服务器上的性能剖析工具(如BPF)的问题时,就深刻体会到了这一点。早期的一些BPF辅助函数,比如bpf_probe_read_kernel(),在实现上可能没有充分考虑UAO/PAN的影响,当它试图读取一个用户空间指针时,会因为硬件保护而失败。这就是为什么后来需要明确区分bpf_probe_read_user()和bpf_probe_read_kernel(),它们底层会设置正确的地址限制和硬件状态,以确保在强大的硬件安全机制下,依然能正确完成数据读取。
6. 演进与现状:set_fs的退场与未来
细心的读者可能已经注意到,我们多次提到了set_fs()这个函数。它曾是内核中临时扩大地址访问范围的核心机制。然而,这个机制本身也带来了复杂性和潜在风险。随意地切换addr_limit本身就是一种危险操作。
正因为硬件提供了PAN和UAO这样更精细、更安全的控制能力,软件机制就有了简化的可能。从Linux 5.11内核版本开始,社区做出了一个重大决定:彻底移除set_fs()机制。相关的补丁主旨就是让uaccess(用户空间访问)例程不再依赖动态变化的addr_limit。
这意味着什么呢?这意味着内核访问用户空间的模式变得更加严格和固定。驱动和内核子系统不能再通过set_fs(KERNEL_DS)来“偷懒”地直接访问内核地址。所有对用户空间的访问,都必须通过copy_from_user等专用接口,并且这些接口的内部实现将完全依赖硬件特性(PAN/UAO)和静态的、更严格的检查来保证安全。
这标志着从“软件为主、硬件为辅”的安全模型,向“硬件强制、软件配合”的模型演进。硬件特性不再是可选的补丁,而是成为了安全基线的强制组成部分。对于开发者而言,最直接的启示就是:必须严格遵守内核的uaccess API规范。任何试图绕过copy_from_user/get_user等接口而直接操作内存的行为,在现代的arm64内核上,不仅是不安全的,而且很可能会因为触发了PAN或UAO保护而直接导致失败。
在我最近移植一个老版本驱动到新内核时,就遇到了因为set_fs()被移除而导致的编译错误。解决的办法就是重构代码逻辑,消除对临时切换地址空间的依赖,严格使用正确的用户空间拷贝函数。这个过程虽然有些麻烦,但让代码变得更清晰、更安全。硬件在逼着我们写出更规范的代码,这未尝不是一件好事。