Linux 6.6内核 CPU 深度解析(十六):x86 保护机制 — 从 ring 到页表的权限防线

引言:CPU 凭什么阻止用户程序读内核内存?

一个用户态程序 int *p = (int *)0xffff888000000000; *p = 1; 想写内核地址,结果是什么?段错误(Segmentation Fault)------CPU 在访存那一刻把它拦了下来。

CPU 是怎么知道的?答案是一套多层权限防线:

  1. 特权级(ring) :CPU 当前跑在 ring3(用户)还是 ring0(内核),由 CS 段寄存器低 2 位决定。
  2. 分页保护:每个内存页的页表项(PTE)上有 U/S、R/W、NX 位,决定"谁"能"怎么"访问这一页。
  3. SMEP / SMAP:进一步禁止"内核态"去执行/访问"用户页",堵住内核漏洞被利用的路径。

这三层加起来,就是 x86 的保护机制(protection) ------CPU 访存时硬件自动做权限检查,违规则触发 #PF(页错误)。


全景图

#mermaid-svg-zt4To5tEhu2pvee6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zt4To5tEhu2pvee6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zt4To5tEhu2pvee6 .error-icon{fill:#552222;}#mermaid-svg-zt4To5tEhu2pvee6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zt4To5tEhu2pvee6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zt4To5tEhu2pvee6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zt4To5tEhu2pvee6 .marker.cross{stroke:#333333;}#mermaid-svg-zt4To5tEhu2pvee6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zt4To5tEhu2pvee6 p{margin:0;}#mermaid-svg-zt4To5tEhu2pvee6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zt4To5tEhu2pvee6 .cluster-label text{fill:#333;}#mermaid-svg-zt4To5tEhu2pvee6 .cluster-label span{color:#333;}#mermaid-svg-zt4To5tEhu2pvee6 .cluster-label span p{background-color:transparent;}#mermaid-svg-zt4To5tEhu2pvee6 .label text,#mermaid-svg-zt4To5tEhu2pvee6 span{fill:#333;color:#333;}#mermaid-svg-zt4To5tEhu2pvee6 .node rect,#mermaid-svg-zt4To5tEhu2pvee6 .node circle,#mermaid-svg-zt4To5tEhu2pvee6 .node ellipse,#mermaid-svg-zt4To5tEhu2pvee6 .node polygon,#mermaid-svg-zt4To5tEhu2pvee6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zt4To5tEhu2pvee6 .rough-node .label text,#mermaid-svg-zt4To5tEhu2pvee6 .node .label text,#mermaid-svg-zt4To5tEhu2pvee6 .image-shape .label,#mermaid-svg-zt4To5tEhu2pvee6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zt4To5tEhu2pvee6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zt4To5tEhu2pvee6 .rough-node .label,#mermaid-svg-zt4To5tEhu2pvee6 .node .label,#mermaid-svg-zt4To5tEhu2pvee6 .image-shape .label,#mermaid-svg-zt4To5tEhu2pvee6 .icon-shape .label{text-align:center;}#mermaid-svg-zt4To5tEhu2pvee6 .node.clickable{cursor:pointer;}#mermaid-svg-zt4To5tEhu2pvee6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zt4To5tEhu2pvee6 .arrowheadPath{fill:#333333;}#mermaid-svg-zt4To5tEhu2pvee6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zt4To5tEhu2pvee6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zt4To5tEhu2pvee6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zt4To5tEhu2pvee6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zt4To5tEhu2pvee6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zt4To5tEhu2pvee6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zt4To5tEhu2pvee6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zt4To5tEhu2pvee6 .cluster text{fill:#333;}#mermaid-svg-zt4To5tEhu2pvee6 .cluster span{color:#333;}#mermaid-svg-zt4To5tEhu2pvee6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zt4To5tEhu2pvee6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zt4To5tEhu2pvee6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zt4To5tEhu2pvee6 .icon-shape,#mermaid-svg-zt4To5tEhu2pvee6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zt4To5tEhu2pvee6 .icon-shape p,#mermaid-svg-zt4To5tEhu2pvee6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zt4To5tEhu2pvee6 .icon-shape .label rect,#mermaid-svg-zt4To5tEhu2pvee6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zt4To5tEhu2pvee6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zt4To5tEhu2pvee6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zt4To5tEhu2pvee6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CPL=3 只能访问 U/S=1
补上内核访问用户页的洞
x86 保护机制
特权级 ring

CPL/RPL/DPL
分段 segmentation

GDT/段描述符
分页 paging

PTE 权限位
现代强化

SMEP/SMAP/PKS
CS 低 2 位 = CPL

ring0 内核 / ring3 用户
__KERNEL_CS(RPL=0)

__USER_CS(RPL=3)
四环(0~3)

但 64 位被扁平化
FS/GS 例外

percpu / TLS
U/S 位: 内核页 vs 用户页
R/W 位: 只读保护
NX 位: 不可执行
SMEP: 内核禁执行用户页
SMAP: 内核禁访问用户页
CLAC/STAC 临时开关

一句话:特权级决定"你是谁",分页决定"你能碰哪些页",SMEP/SMAP 补上"内核态也不能乱碰用户页"的最后一环。


一、特权级:ring0 与 ring3

1.1 CPL 存在段选择子的低 2 位

x86 定义了 4 个特权级(ring0 ~ ring3) ,数字越小权限越高。但 Linux 只用两个:ring0(内核) 和 ring3(用户)。

当前跑在哪个 ring,由 CS 段寄存器的低 2 位 决定,叫 CPL(Current Privilege Level,当前特权级) 。arch/x86/include/asm/segment.h 里定义了这个掩码:

c 复制代码
/* Bottom two bits of selector give the ring privilege level */
#define SEGMENT_RPL_MASK	0x3          // :33
#define USER_RPL		0x3           // :48 ------ 用户态 = 特权级 3

内核和用户的段选择子,差别就在这个低 2 位(RPL):

c 复制代码
#define __KERNEL_CS	(GDT_ENTRY_KERNEL_CS*8)           // :135 ------ RPL=0(内核)
#define __USER_CS	(GDT_ENTRY_DEFAULT_USER_CS*8 + 3) // :138 ------ +3 = RPL=3(用户)

__KERNEL_CS 是 *8(低 2 位为 0 = ring0),__USER_CS 是 *8 + 3(低 2 位为 3 = ring3)。内核段和用户段的选择子,靠低 2 位的 RPL 标记区分(段真正的权限门槛在段描述符的 DPL 字段里,这里 RPL 只是跟着 DPL 走的选择子标记)。

1.2 CPL 怎么约束访存

CPU 每次访存,硬件拿"当前 CPL"和"目标页的权限位"比较:

  • CPL=3(用户) :只能访问 U/S=1 的"用户页",碰内核页(U/S=0)就 #PF。
  • CPL=0(内核):默认能访问所有页(用户页 + 内核页)。

这就是内核态和用户态内存隔离 的根源。系统调用 syscall 指令本质就是"从 ring3 切到 ring0"(CS 低 2 位从 3 变 0),进入内核后才有权限碰内核页。

Why ring 只有两个:x86 分段定义了 4 环,但实际只用了 ring0/ring3,中间 ring1/ring2 几乎废弃。因为现代操作系统只需要"内核 vs 用户"这个清晰边界,多级特权只会让系统调用路径更复杂(每跨一级都要过一道门)。分页保护同样只有两级(supervisor/user),见第三节。


二、分段:GDT 与 64 位扁平化

2.1 GDT 是什么

GDT(Global Descriptor Table,全局描述符表) 是内存里的一张表,每个表项是一个段描述符 ,描述一段内存的基址、界限、权限。GDT_ENTRY() 宏(segment.h:13)负责拼出这个表项:

c 复制代码
#define GDT_ENTRY(flags, base, limit)			\
	((((base)  & _AC(0xff000000,ULL)) << (56-24)) |	\
	 (((flags) & _AC(0x0000f0ff,ULL)) << 40) |	\
	 (((limit) & _AC(0x000f0000,ULL)) << (48-16)) |	\
	 (((base)  & _AC(0x00ffffff,ULL)) << 16) |	\
	 (((limit) & _AC(0x0000ffff,ULL))))

一个段描述符把 base(基址)+ limit(界限)+ flags(权限,含 DPL)打散塞进 8 字节里。段选择子(CS/DS 等寄存器里的值)指向 GDT 里的某个表项。

2.2 64 位长模式:分段基本被架空

关键转折:进入 64 位长模式后,分段的内存保护作用基本消失 。除了 FS/GS,所有段的 base/limit 都被忽略,内存保护完全交给分页。

原因:#1 里讲过,长模式(EFER.LME + CR0.PG)下 CPU 采用平坦内存模型------所有段都覆盖整个地址空间,段的 base/limit 不再参与地址翻译。分段只剩两个残留用途:

  • FS/GS 段 :base 仍有效,用来做 percpu 变量 (#8 的 %gs 段寻址)和 TLS(线程局部存储)。这是长模式下唯一"段基址还起作用"的地方。
  • GDT 本身还在:用于放 TSS(任务状态段,保存中断切换时内核栈指针)、以及区分内核/用户代码段。

Why 分段被架空 :段式内存管理(base+limit 切内存)太粗、太死板,而且跨段访问要频繁重载段寄存器,性能差。分页按 4KB 粒度、按需映射,灵活得多。所以 x86-64 把保护职责从分段移交给了分页,分段只保留 FS/GS 这个"基址偏移"的小用途。


三、分页保护:U/S、R/W、NX 三位

这是现代 x86 保护的主力 。每个页表项(PTE)上有三个关键权限位(arch/x86/include/asm/pgtable_types.h):

c 复制代码
#define _PAGE_BIT_PRESENT	0	/* is present */        // :10
#define _PAGE_BIT_RW		1	/* writeable */         // :11
#define _PAGE_BIT_USER		2	/* userspace addressable */ // :12
#define _PAGE_BIT_NX		63	/* No execute */        // :30
位 名字 作用 =0 时 =1 时
bit 0 PRESENT 页在不在内存 不在(缺页) 在
bit 1 R/W 可写 只读 可写
bit 2 U/S 用户可访问 内核页(仅 ring0 能碰) 用户页(ring3 也能碰)
bit 63 NX 不可执行 可执行 不可执行(需 EFER.NXE)

3.1 U/S 位:内核页 vs 用户页的分水岭

U/S 位是隔离的核心。U/S=0 的页叫"监督页(supervisor page)"即内核页,只有 ring0 能访问;U/S=1 的页叫"用户页",ring3 也能访问。

注意一个容易忽略的点:权限是"逐级相与"的 。4 级页表(PML4→PDPT→PD→PT)每一级都有自己的 U/S 位,最终访问权限取所有层级最严格的那个 ------任何一级 U/S=0,整段虚拟地址就是内核页,用户态碰它就 #PF。

这就是为什么用户程序写 0xffff888000000000(内核地址)会段错误:那一整段页表链路上,最顶层的 U/S 位就是 0(内核页),CPL=3 的访问直接被硬件拒绝。

3.2 R/W 位 + CR0.WP:写保护

R/W=0 的页只读。但这里有个历史坑:老行为里,内核(CPL=0)能绕过 R/W 位写只读页 。为了抓内核自己的 bug(比如内核误写只读的代码段、rodata),引入了 CR0.WP(Write Protect) 位:

  • CR0.WP=0:内核可以写任何只读页(宽松,历史默认);
  • CR0.WP=1 :内核也只能写 R/W=1 的页 ,写只读页同样 #PF。

Linux 现代内核始终开 CR0.WP ,用它保护内核的只读数据(如 .rodata、页表本身),内核 bug 一旦写只读内存立刻炸出来,而不是静默破坏。

3.3 NX 位:不可执行

NX=1 的页不能取指执行 (数据页不能当代码跑)。这是防"栈/堆注入 shellcode"的基础:攻击者往栈上写 shellcode 再跳过去执行,会因栈页 NX=1 而 #PF。

前提是 CPU 支持(EFER.NXE 使能),对应特性位 X86_FEATURE_NX(1*32+20,cpufeatures.h:64)。

Why 分页是主力:分页按 4KB 粒度、每个进程一套独立页表,天然支持"进程 A 看不到进程 B 的内存"(隔离)+ "用户看不到内核"(U/S)。这三位的组合(U/S 管谁能访问、R/W 管能否写、NX 管能否执行)覆盖了访存的全部维度,是 x86 保护的正主。


四、SMEP / SMAP:堵住"内核乱碰用户页"的洞

分页保护有个历史遗留的漏洞 :内核态(CPL=0)默认能访问所有页,包括用户页(U/S=1)。这意味着:

  • 如果内核代码有个函数指针被攻击者篡改,指向了用户态注入的恶意代码 ,内核跳过去执行------ret2usr 攻击。U/S 位管不住,因为内核本来就"能访问用户页"。

SMEP 和 SMAP 就是来堵这个洞的------限制内核态去碰用户页。

4.1 SMEP:内核不许执行用户页

SMEP(Supervisor Mode Execution Prevention) :CR4.SMEP=1 时,内核态(CPL=0)取指 访问 U/S=1 的用户页 → 直接 #PF。

这精确封死了 ret2usr:内核函数指针被改成用户地址也没用,一跳过去就页错误。

内核在 CPU 初始化时开它(arch/x86/kernel/cpu/common.c):

c 复制代码
static __always_inline void setup_smep(struct cpuinfo_x86 *c)
{
	if (cpu_has(c, X86_FEATURE_SMEP))      // 特性位 9*32+7(cpufeatures.h:249)
		cr4_set_bits(X86_CR4_SMEP);   // :364 ------ 置 CR4.SMEP
}

4.2 SMAP:内核不许访问用户页

SMAP(Supervisor Mode Access Prevention) :CR4.SMAP=1 时,内核态(CPL=0)读/写 用户页(U/S=1)的数据 → #PF。

它堵的是"内核被诱导去读写攻击者控制的内存"(如 ret2dir、非法 copy_from_user)。

4.3 CLAC / STAC:合法访问用户内存的开关

SMAP 一开,内核正常的 copy_from_user()/copy_to_user() 也没法访问用户内存了。所以硬件配了一对指令来临时开关 (arch/x86/include/asm/smap.h):

c 复制代码
#define __ASM_CLAC	".byte 0x0f,0x01,0xca"   // :17 ------ CLAC:清 AC 标志,关访问
#define __ASM_STAC	".byte 0x0f,0x01,0xcb"   // :18 ------ STAC:置 AC 标志,开访问

static __always_inline void clac(void)
{
	alternative("", __ASM_CLAC, X86_FEATURE_SMAP);   // :33
}
static __always_inline void stac(void)
{
	alternative("", __ASM_STAC, X86_FEATURE_SMAP);   // :39
}

机制:EFLAGS.AC 位是 SMAP 的"逃生门"。AC=0(默认)时 SMAP 严格限制;AC=1 时内核可以临时访问用户页。所以:

c 复制代码
stac();                    // 置 AC=1,临时允许访问用户内存
copy_from_user(dst, src, n);   // 现在可以读了
clac();                    // 清 AC=0,恢复限制

注意 stac()/clac() 用的是 alternative()------只有 CPU 真有 SMAP(X86_FEATURE_SMAP)才发指令,否则编译成空操作(零开销)。这也是 #13 讲过的 alternatives patching 在指令层的应用。

setup_smap()(common.c:367)在开机时置 CR4.SMAP,同时断言 EFLAGS.AC 初始为 0(common.c:372)。

4.4 一张表:SMEP 管执行、SMAP 管数据,各管一摊

两者职责分明,互不越界:

SMEP(管执行) SMAP(管数据访问)
管什么 内核取指用户页 内核读/写用户页
关闭时 内核能执行用户页(ret2usr 漏洞) 内核能访问用户页数据
开启时 取指用户页 → #PF 访问用户页 → #PF
临时开关 无(始终禁止) STAC/CLAC(EFLAGS.AC 标志)

Why 这一章的意义 :U/S 位管的是"用户别碰内核",而 SMEP/SMAP 管的是反过来的方向 ------"内核也别乱碰用户"。前者是隔离,后者是防内核自己被攻破后的横向利用。这是现代内核安全(KPTI、KASLR、SMEP/SMAP)拼图里"硬件兜底"的一块,配合软件缓解一起防提权。


五、进阶:PKS / PKU 保护密钥(简提)

在 U/S 两位的粗粒度之上,还有更细的**保护密钥(Protection Keys)**机制:

  • PKU(用户态保护密钥) :CR4.PKE + 页表项的 PK 字段(4 位)+ PKRU 寄存器,让用户态给自己的页打"密钥标签",按密钥控制读写。ring3 能自己改 PKRU,所以它不是安全边界 ,更多用于性能/功能隔离(如 glibc 的 pkey_alloc)。
  • PKS(监督态保护密钥) :CR4.PKS + IA32_PKRS MSR,只有 ring0 能改 ,给内核页打标签,是真正的内核内部隔离(比如给敏感数据页单独一个密钥,防止内核其他部分误读)。

这一块属于较新的机制(v6.6 已有 X86_FEATURE_PKS),本篇点到为止,不展开位字段。


六、对比选型 + 记住三点

6.1 分段 vs 分页:为什么保护交给了分页

维度 分段 分页
粒度 段(可变大小,粗) 页(4KB/2MB/1GB,细)
权限位 段描述符 DPL PTE 的 U/S/R/W/NX
特权级 4 环(ring0~3) 2 级(supervisor/user)
64 位现状 基本架空(FS/GS 例外) 保护主力
隔离能力 粗,段间切换慢 每进程独立页表,强

6.2 MMU 页表 vs IOMMU 页表(衔接内存篇)

本篇的 U/S 权限是 MMU(CPU 访存) 的页表;IOMMU(设备 DMA) 也有自己的一套页表,权限语义不同------MMU 的 U/S 管"用户态 vs 内核态",IOMMU 的权限管"哪个设备能不能 DMA 到这块内存"。两者是两套独立页表,分别防"CPU 越权"和"设备越权"(详见 \[blog-iommu-expansion])。

6.3 记住三点

  1. 特权级在 CS 低 2 位 :CPL=3 是用户、CPL=0 是内核(segment.h:33/48),__USER_CS 就是 *8+3。CPU 每次访存都拿 CPL 和目标页权限比对。
  2. 分页是隔离主力:PTE 的 U/S(内核页 vs 用户页,pgtable_types.h:12)、R/W(写保护 + CR0.WP)、NX(不可执行,:30)三位覆盖"谁能访问/能否写/能否执行";分段在 64 位基本被架空(只剩 FS/GS 做 percpu/TLS)。
  3. SMEP/SMAP 堵反向漏洞 :U/S 只管"用户别碰内核",SMEP(禁执行用户页)/SMAP(禁访问用户页)管"内核别乱碰用户",靠 CR4.SMEP/SMAP(common.c:364/375)+ CLAC/STAC(smap.h:17-18)临时开关,防 ret2usr 提权。
相关推荐
qetfw1 小时前
Linux 文件清理:find、mtime 与安全传参验证
linux·shell·1024程序员节
贵州诚鑫致达科技有限公司1 小时前
PHP两个9.2分RCE漏洞,速查版本
服务器·php·1024程序员节
不老刘2 小时前
异构双 Agent 协同:构建无人值守的长任务自动化研发流水线
运维·人工智能·自动化·ai编程·1024程序员节
可乐_不渴2 小时前
【Linux】 项目自动化构建工具makefile&& 版本管理Git&&第一个程序
linux·git·自动化·1024程序员节
织码weavecodes2 小时前
医疗、零售、物业培训怎么落地?织码在线教育系统的三类行业案例
运维·服务器·零售
智能运维指南2 小时前
企业运维监控平台哪家好?2026年五大全栈可观测方案对比与选型推荐
大数据·运维·人工智能
乔旭伟2 小时前
部署形态选型笔记:多租户 SaaS 与单机本地部署的运维模型对比
运维·服务器·1024程序员节
西安同步电子3 小时前
时统卡驱动踩坑:Windows 和 Linux 下时间同步的底层差异
linux·stm32·单片机
虚无的纽扣3 小时前
【Linux】线程互斥:多线程改同一个变量,到底会出什么问题
linux·服务器·1024程序员节