首页 / 资讯中心 / 文章详情

从Ring0到Ring3:一文看懂x86 CPU特权级与权限隔离

从Ring0到Ring3:一文看懂x86 CPU特权级与权限隔离 ★ FEATURED ARTICLE
1. 特权级从哪来一个关于“保护”的古老故事展开x86处理器的 Ring 0~Ring 3 权限模型之前先回到上世纪80年代初的原点。x86架构第一次引入特权级概念是在80286时代这个设计初衷特别朴素一台电脑可能同时跑操作系统和用户程序操作系统凭什么不让自己被用户程序搞死放在今天你很难想象一个普通进程崩溃会把整个系统带崩盘但这些保护机制不是凭空长出来的它们的根就在特权级这套规则里。x86的硬件设计者当时面对的是一个很直白的矛盾程序需要访问各种硬件资源但你不能让每个程序都有同等访问权限。如果一个普通应用能随便改中断描述符表、随便向IO端口写数据那这台机器的稳定性就是零。于是硬件层面引入了一套“圈层”体系——Ring也叫特权级或保护环。CPU把运行中的代码按照特权级分成四个级别Ring 0 特权最高Ring 3 特权最低。大多数现代操作系统只用到两个极值操作系统内核待在 Ring 0应用程序待在 Ring 3。Ring 1 和 Ring 2 在很长一段时间里几乎没人用后面细说。设计逻辑其实比大多数人想的简单低特权级的代码不能随便执行特权指令也不能直接访问高特权级的数据结构凡是越权的行为CPU 直接抛异常、中断交给 Ring 0 去裁决。这个思路放到今天依然是操作系统的地基没有这套硬件配合软件层面做得再花哨也兜不住底。这套模型跟日常生活的类比很直接Ring 0 像银行金库的管理员钥匙、保险柜、账本全部掌在手里Ring 3 像普通客户只能通过柜台窗口办业务你没法自己绕过窗户冲到金库里翻账本。客户要是非冲进去银行保安硬件保护机制会拦住轻则警告重则直接带走。但对从业者来说知道“有几个环”远远不够。你迟早会遇到这么几个问题CPL、DPL、RPL 这些缩写到底是啥用户态到内核态切换时硬件到底发生了什么VMware、Docker 这些东西跟特权级有什么关系为什么当年有些 VMM 的设计要伪造特权级我在这篇里一层层拆给各位看。不需要你提前懂很多底层知识但如果你写过一点汇编、看过一点内核代码理解速度会快很多。2. 四个 Ring 的真实面貌谁住在哪一层为什么 Ring 1/2 坐了冷板凳2.1 特权级的硬件定义CS 段寄存器里的两位Ring 这个概念最终落在 CPU 内部一个很具体的地方当前代码段寄存器 CS 的 RPLRequested Privilege Level字段里。CS 寄存器里有一个 CPLCurrent Privilege Level占两位二进制位取值 0、1、2、3正好对应 Ring 0 到 Ring 3。CPU 每取一条指令就是带着 CPL 这个身份标记去执行。这个设计意味着什么权限不是某个页面或者某段内存自带的标签而是跟随“当前正在执行的代码”走的。你的程序在 Ring 3 跑得好好的一旦通过某种机制切换到 Ring 0 的内核代码CPU 的 CPL 就变成 0。这不能靠软件自己拍脑袋改硬件提供专门的切换通道后面讲 syscall 和 int 指令时细聊。四个级别之间的核心差别在于“能做什么”特权级允许执行的指令/资源典型使用者Ring 0所有特权指令包括 LGDT、LIDT、LTR、MOV CRx、HLT、IN/OUT、CLI/STI 等可直接操作页表、中断、IO端口操作系统内核、Hypervisor、固件的一部分Ring 1只能执行部分特权指令无法访问某些关键控制寄存器OS 服务层历史上一些 OS 用过如驱动Ring 2特权指令限制更多常用于某些设备驱动极少使用Ring 3几乎所有特权指令都被禁止只能通过门机制或系统调用接口请求操作系统代劳用户态应用值得注意的细节早期 x86 架构回忆录里你会看到“分为 4 级但实际大家只用 0 和 3”的说法。这不是懒而是有合理性。操作系统的核心原则是“简单即安全”多一层特权级意味着多一层跨级调用、多一种攻击面。Ring 1 和 Ring 2 为了兼容某些 OS 而保留但在现代主流系统里基本是摆设。某些嵌入式场景或者研究型 OS 会考虑用中间两级比如把驱动放在 Ring 1把调度器放 Ring 0但这种设计并没有成为主流。2.2 为什么 Ring 1/2 被主流系统“战略性放弃”如果你今天翻开经典操作系统教材里面的图大概率画了一圈一圈的三个同心圆标注待权级 0 到 3。但你翻 Linux、Windows 的实现它们只用了 0 和 3。原因主要有两条第一分级越多跨级切换的成本越高、越复杂。每一次跨级切换CPU 要换栈、保存状态、检查描述符权限如果存在三级、四级跳转光上下文切换的代码就够你喝一壶。OS 实现者的核心权衡是我究竟要多少级保护Ring 0 和 Ring 3 的两层模型足够覆盖绝大多数需求。第二很多硬件层面已经不再区分“中间级别”。比如 IO 权限检查依赖 IOPL 和 TSS 里的 IO 位图这套机制对 Ring 1/2 也一样生效但实际主流 OS 不在中间 Ring 安排代码所以连带着相关的硬件校验路径都变成了闲置设备。有个容易混淆的点AMD64 和 Intel 64 在长模式下部分特权级相关语义有变化。比如 64 位模式下某些以前需要 Ring 0 才能执行的指令现在只要不在 Ring 3 就行包括 LGDT 等仍限制在 CPL0但有些“原先 Ring 0 才能读的系统寄存器”放宽了权限。做底层开发时不能只看 32 位的教科书得对着对应架构手册核一遍。2.3 一个冷知识分段机制反而成了特权级的第一道防线很多人一提到保护机制就想到页表实际上 x86 的保护是“分段先行分页后补”。在 32 位保护模式下你写的每一个地址CPU 首先会拿它去做段式翻译段式这个步骤里就含了特权级检查。一个段描述符里有 DPLDescriptor Privilege Level它表示“想要访问我这个段你的 CPL 至少得有几级权限”。举个例子内核数据段描述符的 DPL 通常设为 0那 Ring 3 的进程直接访问这个段就会被 CPU 拦下。用户态代码段描述符的 DPL 通常设为 3所以 Ring 0 的内核也能使用用户段里的数据——高特权级能向下兼容低特权级反向不行。这种“单向访问”规则是整个特权级系统的灵魂。你代码里写一个指针指到内核地址然后想直接读出内容CPU 在做地址翻译之前先用段检查把你挡住了。如果段检查也过了还有页表里的 U/S 位等着。两层检查的兜底设计给了操作系统很强的抗误操作能力。3. 权限检查到底怎么发生CPL、DPL、RPL 的三角关系3.1 三个缩写容易弄混一次说清底层开发的人嘴里天天挂着 CPL、DPL、RPL、IOPL但它们各有各的管辖范围不能混。CPLCurrent Privilege LevelCPU 当前执行的代码所在特权级存在 CS 寄存器低两位。每次取指、访存、执行 IOCPU 都拿它做权限基准。我们平时说“我现在在 Ring 0 还是在 Ring 3”翻译过来就是在问 CPL 是多少。DPLDescriptor Privilege Level存在于段描述符、门描述符、页表项里表示“这个资源要求什么特权级才能访问”。比如中断门描述符的 DPL 设定的就是用户态能否通过 int 指令触发这个中断。RPLRequested Privilege Level藏在段选择子的低两位里表示“发起这次访问的程序声称自己有什么特权级”。它比 CPL 多了一层“自觉降级”的检查。为什么需要它是为了防止高特权级代码替低特权级代码去访问更高级别的数据时越权典型例子是lsl、arpl这类指令。权限校验时有个简单判断CPU 会拿 CPL 和 RPL 中更低的那个数值更大去跟 DPL 比。也就是实际有效特权级 max(CPL数值, RPL数值)。如果 CPL0、RPL3那实际就是按 Ring 3 去访问目标段只有有效特权级的数值 ≤ DPL 时访问才被允许。3.2 页表里的 U/S 位特权级落地的最后一棒段级检查过了之后现代 OS 真正的主战场在分页。页表项/页目录项里有个 U/S 位User/Supervisor。U/S0 表示只有 CPL0,1,2也就是“超级用户”能访问U/S1 表示 CPL 任意级别都能访问。你看页表这一层没有用“四位特权级”去严格对齐而是做了个二分法——SupervisorCPL 0~2和 UserCPL 3。这也是为什么许多底层逆向的同事会发现一个进程即使找到了某个内核地址也没法直接 dump 内容因为那个物理页被标记为 Supervisor-only硬件直接就 page fault 了。这层检查每个内存访问都会执行无论你是取指令、读数据还是写数据。CPU 在这里不信任你程序的意图只看它实际的 CPL。这种“物理层面的强制隔离”构成了隔离用户态与内核态的最大底气。3.3 还要留意一个旁路IO 权限的 IOPL 和 IO 位图内存保护之外特权级还管着 IO 访问。IN、OUT、INS、OUTS这些指令从前是妥妥的特权指令CPL 不够会被拦。但后来为了性能给了一条路子如果 CPL ≤ IOPLIO 特权级存在 EFLAGS 寄存器里可以直接执行 IO 指令。这有点反常——IO 权限没有严格绑定到 Ring 0而是看你当前的特权级有没有达到 IOPL 的标准。IOPL 默认是 0但操作系统可以改。还有一种更细的玩法TSS任务状态段里有一个 IO 允许位图可以针对特定端口放开权限。比如某个设备驱动程序跑在 Ring 3但需要访问固定地址的端口你可以把那个端口对应的位图位置成 0让 Ring 3 程序绕过特权级检查直接访问 IO。写驱动的人可能遇到做虚拟机的同事也经常会跟 IO 位图打交道——毕竟模拟各种设备时IO 端口拦截是逃不开的。4. 用户态和内核态之间怎么切换系统调用、中断、门机制一锅端4.1 切换的本质换栈、换 CS、换权限检查的基准CPL 不是软件随便改的。你总不能让一个 Ring 3 进程直接把 CS 的低两位改成 0然后回到用户态继续执行——CPU 在有特权级变化的跨段跳转时有一套完整的“门检查机制”普通jmp far或call far直接跳到一个更高特权级的代码段会触发 #GP 异常。唯一被允许的通路是通过调用门、中断门、陷阱门或者通过syscall/sysenter指令由硬件协助完成特权级提升。在实际系统中最常见的切换路径就是“用户态应用触发系统调用”。以一个read()为例动作拆解如下用户态执行syscall或int 0x80CPU 捕捉到这次调用。CPU 从 MSR 寄存器或 IDT中断描述符表里找到目标内核入口地址。检查目标门的 DPL 是否允许当前 CPL 进入。把当前 SS、RSP、CS、RIP、EFLAGS 压入内核栈TSS 里指定的栈。CPL 变成 0跳到内核入口。内核处理完系统调用通过sysret/iret返回用户态CPL 降回 3。看这个过程你会明白一个关键点切换的特权级时伴随的不只是那个“级别数值”的变化还有执行栈的切换。Ring 0 有自己的内核栈Ring 3 有自己的用户栈。两个栈之间靠 TSS 里的 RSP0/RSP1/RSP2 字段挂钩。很多人刚开始接触时有个误解“我只要用int 3或者int 0x80就能进内核”。这里要澄清一下中断描述符表里每一项门描述符也有 DPL如果 DPL 设置不对Ring 3 发起的int指令直接触发通用保护异常。比如调试器常用的int3如果 DPL 是 0那普通程序就不能自己触发防止恶意程序扰乱调试机制。4.2 从 int 0x80 到 syscall/sysenter传统与新贵的对决Linux 在 x86 上早期用int 0x80做系统调用入口如今主流 x64 平台走的是syscall指令AMD 那边习惯叫 SYSCALL/SYSRETIntel 也有。两者差异很大int 0x80走 IDT通过中断门描述符跨级需要查内存中的描述符表路径较长而且容易受到 Spectre 类预测侧信道影响。syscall走的是 MSR型号相关寄存器直接用STAR/LSTAR等 MSR 里预先配置的入口地址不需要查内存里那张描述符表路径短、快而且 CPU 会自动屏蔽某些中断完成关键状态保存。对普通应用开发者这个过程透明。对底层开发意义就大了做系统调用监控、做热补丁、做 Fuzzing 的时候你到底该 hook 哪里int 0x80可以在 IDT 层面拦截syscall则要在 MSR 层面或者内核入口处拦截捕获方式完全不同。4.3 调用门被遗忘的正式通道你翻老教材里面一定会讲调用门Call Gate。调用门允许一个 Ring 3 的程序把代码段选择子指向一个描述符这个描述符标记为“门”门里面有目标段选择子、偏移还有 DPL。CPU 检查完 DPL 后会帮你做特权级提升。现代系统里调用门几乎绝迹——因为syscall/sysenter以及中断门/陷阱门已经能满足一切需要调用门的额外机制和支持软件的复杂度不值得。但偶尔逆向老驱动或老 OS 时还能撞见这种结构。研究它还有一个价值理解“门”这个概念对理解 Xen、VMware 的 hypercall 机制、VMCALL 指令有很大帮助。门的作用就是“留一条受控的通道”当代虚拟化技术其实到处都在用这个思想只是换了个叫法。5. 现代系统怎么“玩”特权级虚拟化、微内核与容器隔离的暗厢操作5.1 虚拟化最核心的一个逻辑谁拥有真正的 Ring 0虚拟机监控器Hypervisor出现之后特权级的使用出现了一个尖锐矛盾你希望客户机操作系统以为自己住进了 Ring 0但底层真正的 Ring 0 得归 Hypervisor 所有。如果客户机的内核执行了特权指令又不能真的操作物理 CPU 的完整特权域那怎么办这里有历史演进的三个阶段早期纯软件模拟时代CPU 会把敏感指令产生的特权级异常抛给 VMMVMM 模拟执行后再把控制权交回客户机。这就是经典的“陷入-模拟”trap-and-emulate缺陷是性能差敏感指令多的时候慢到发指。后来 Intel/AMD 在硬件层面加了 VT-x/AMD-V提供了一种全新的运行模式VMX root 和 VMX non-root。客户机内核不再需要真的位于 Ring 0它只是在 VMX non-root 模式下运行特权级语义被重新定义了——敏感指令可以由 VM-entry/VM-exit 机制自动陷入到 Hypervisor而不必走传统异常路径。这两个模式拿到的实际 CPL 可能还是 0但硬件已经区分了“我是真内核”还是“我是客户机内核”。这是一次大的架构变化本质上是把“虚拟化”这一特例从传统的四环模型里拎出来专门加了一条硬件的快车道。做云计算的同事如果遇到“我的用户态程序一跑某些指令就整个虚拟机出问题”往往就是没意识到 Hypervisor 对某些指令的拦截行为例如检查 CPUID 某些 leaf、尝试访问某些特权 MSR 时触发了 VM-exit。这不是普通 Ring 3 程序踩坑的日常但做 Hypervisor 的同事天天见。5.2 微内核架构里的“特权级”新解读还有一条线是微内核Microkernel设计例如 L4 系列和 seL4。这类系统放弃“驱动全在 Ring 0”的模型把设备驱动放到用户态进程内核只提供 IPC 和调度。这时你发现Ring 0 和 Ring 3 不再是绝对的“判断对错”的层级而变成一种资源调度和错误隔离的边界。驱动崩了不会拉垮整个内核而是由一个用户态服务进程重启驱动。但微内核引入了一个新问题用户态驱动需要访问硬件、需要操作中断怎么办答案是依赖内核转发或者在某些架构上把部分 IO 权限、中断绑定能力通过 capability能力句柄下放。这回到前文说的 IO 位图和 IOPL 机制——Ring 3 的驱动可以用部分硬件能力但必须由内核“精准授权”。这种思想恰恰是 x86 特权级分级最早没细化的能力边界到了微内核时代又被重新挖出来放大。5.3 容器到底有没有碰特权级容器Container跟虚拟机的底层完全不同。容器共享宿主内核所以容器里的进程仍然跑在宿主内核的 CPL3 上容器隔离靠的完全是软件层面的 namespaces、cgroups、seccomp 等。这意味着在特权级这件事上容器并没有多一层“硬件围栏”。这也是为什么跑 Docker 时你会看到运行时默认禁用某些 sysctl、限制某些系统调用——不是内核不让你调而是容器运行时担心你在用户态借内核的漏洞突破 namespace 隔离。根本上它依赖内核自身的安全性和 VM 靠硬件隔离在风险面上差了一个量级。我们做底层安全时判断一个环境的安全边界永远要问一句到底是硬件强制隔离还是软件约定隔离硬件做了特权级检查软件只能把规则捡起来遵守但没法绕过硬件。软件约定只要有一处破洞整个边界就形同虚设。6. 特权级漏洞与安全一票“听起来很高级”的漏洞全是特权级的战场6.1 越权漏洞的本质都是让低特权代码碰到了高特权数据安全圈里大面积出现的所谓“内核提权漏洞”绝大多数都围绕同一个主题一个 Ring 3 的进程竟然能通过某种方式触达 Ring 0 才能访问的数据或指令。常见套路有内核存在一个可被用户态触发的 bug比如越界写或读导致用户态能以 Ring 0 身份执行一段攻击者控制的代码。某个系统调用把用户指针当作内核指针用绕过访问权限检查。硬件层面存在预测执行的侧信道例如 Meltdown本质是 CPU 在乱序/预测执行时提前访问了 Supervisor-only 的内存页然后通过缓存时间侧信道把数据带出来。这些东西对普通开发者的警示意义在于别以为你的程序在 Ring 3 就万事大吉也别以为你的数据页在 Supervisor 域就绝对安全。硬件和软件的合作防线不是铁板一块。6.2 Meltdown/Spectre 视角里的特权级Meltdown 的关键在于CPU 在执行时可能会“越权读取”某些地址然后虽然最终指令结果会被丢弃但缓存的状态已经被污染。攻击者通过测量自己的某个数组访问时间可以推断出内存内容。它绕过了页表的 U/S 检查吗不是绕过而是利用了“CPU 在检查权限前就触发了侧信道读取”的乱序执行窗口。这类漏洞给业界的教训是硬件层面每一层优化乱序执行、预测执行都可能无意间挑战硬件的安全边界。此后操作系统大规模更新页表隔离KPTIKernel Page Table Isolation——把用户进程页表里的内核地址映射移除这样用户态去访问一个内核虚拟地址时页表里压根没有映射页表这一层就把它挡死。这就是典型的“在软件层面修复硬件隐患”既然硬件上页表的 U/S 检查在乱序窗口里靠不住那就干脆别给你这个页表项。6.3 给开发者的三条安全建议实战向第一不要关闭内核态页表隔离哪怕牺牲一点性能。尤其伺服类、网关类、虚拟化宿主机这类设备KPTI 的收益远大于那点 syscall 开销。第二驱动和内核模块不要信任用户态指针。要用copy_from_user/copy_to_user这类安全接口去拷贝而不是直接把用户地址解引用。内核里解引用用户地址在 SMAP/SMEP 开启的平台上可能直接触发异常而在老平台上就可能变成可利用的漏洞。第三关注 MSR 和调试相关寄存器的访问权限做安全研究时尤其如此。如果某条指令能让你从 Ring 3 触发 Ring 0 的路径并且错误处理不完善这往往就是一条提权链的起点。7. 实操环节写代码验证特权级模型附排查方案7.1 实验一在 Ring 0 与 Ring 3 之间看段寄存器写一个最简单的内核模块或者用 QEMU 调试你想直观感受特权级切换最直接的办法是看每段代码运行时 CS 的 RPL 值。用info registers看 CS低位就是等级。典型输出里Ring 0 下 CS0x08二进制 01000RPL0Ring 3 下 CS0x23二进制 100011RPL3。没有实际内核环境的话也能靠用户态读cs段寄存器值确认环境在 64 位 Linux 用户态你可以写一小段内联汇编读取 CS基本观察到的值就是 0x33RPL3。测试代码大致是#include stdio.h static unsigned short get_cs(void) { unsigned short sel; __asm__ __volatile__(mov %%cs, %0 : r(sel)); return sel; } int main(void) { printf(CS 0x%x (RPL%u)\n, get_cs(), get_cs() 3); return 0; }编译运行你会看到 RPL3。这是一个非常直观的“我在用户态”的硬证据。如果在调试器里附加到内核再看内核入口时同一寄存器RPL 就变成了 0。这套验证方法适合入门的同事建立第一手体感。7.2 实验二尝试执行特权指令验证 #GP在用户态写一段尝试执行LGDT或HLT的代码运行时会收到 SIGSEGV 或者触发非法指令异常。原因是 CPU 在取指/执行阶段就会做特权级检查LGDT 要求 CPL0当前 CPL3直接拒绝。#include stdio.h int main(void) { printf(try execute privileged instruction...\n); __asm__ __volatile__(hlt); return 0; }注意单纯hlt在用户态大概率直接崩掉或触发#GP个别环境下可能需要配合信号处理才不整个进程退出。这里的意义在于证明“用户态无法执行特权指令”不是软件约定是硬件出手了。7.3 常见的排查问题速查现象可能原因排查方向用户态程序访问内核地址时 segfault页表里 Supervisor-only 页不可访问用调试器查看页表项的 U/S 位内核态访问用户态指针时崩溃未用 copy_from_user或 SMAP/SMEP 已开启检查 CR4 寄存器里 SMAP/SMEP 位int 0x80 在 64 位系统不好使需要在兼容模式下处理或系统用 syscall 快路径检查 syscall 入口配置某些寄存器用户态能读不能写架构规定只读/只在特定 CPL 可写查 arch manual 对应寄存器权限VMM 环境里执行指令卡死客户机触发 VM-exithypervisor 拦截了某些行为用 VMCS 追踪 VM-exit reason排查时最常用的就是调试器看 CR0/CR4 里保护开关有没有打开看 GDT/IDT 里描述符的 DPL看 CS 的 RPL。这些在主流调试器和 QEMU monitor 下都能直接读出来。每次出现“为什么我的驱动能访问这个地址但普通程序不行”之类的问题十有八九最后都会落到 U/S 位或者 DPL 的某一步 CPU 检查。8. 从 Ring 到未来为什么这套四环模型至今仍是地基中的地基看到这里你会发现一个很有意思的事实过去几十年微架构发生了天翻地覆的变化乱序执行、超线程、大小核、虚拟化扩展但 Ring 0~Ring 3 这个框架本身没有动摇。它不是最漂亮的设计甚至有些冗余但它足够简单、足够硬件化、足够可信。做系统的人最怕的就是保护机制本身有软肋因为一旦保护机制不可信上面加再多安全软件都是空中楼阁。我个人这些年在搞底层和性能优化时的一个体会是所有关于 CPU 权限的讨论最后都指向同一个问题——谁有能力越过硬件设立的边界。无论是内核提权、虚拟机逃逸、固件漏洞还是各类侧信道本质都是某些越界访问混过了硬件检查。理解 Ring 特权级本质上就是在建立一张“CPU 权力地图”知道什么东西在哪一层、谁可以跨层、跨层时走哪条门这会让你在往后看任何底层安全问题、系统设计问题时都有一种非常牢固的代入感。如果是刚接触底层这块建议先按文中前几节的思路把 CS 的 RPL、页表的 U/S 位、描述符的 DPL 三者搞清楚再动手做一遍那两个小实验。等你自己写出第一个需要特权级切换的代码时再回头看这篇文章会有一种“硬件的护栏其实设计得很体贴”的感觉。
阅读完成 · 觉得有帮助?
咨询建站