引言:CPU 凭什么阻止用户程序读内核内存?
一个用户态程序 int *p = (int *)0xffff888000000000; *p = 1; 想写内核地址,结果是什么?段错误(Segmentation Fault)------CPU 在访存那一刻把它拦了下来。
CPU 是怎么知道的?答案是一套多层权限防线:
- 特权级(ring) :CPU 当前跑在 ring3(用户)还是 ring0(内核),由
CS段寄存器低 2 位决定。 - 分页保护:每个内存页的页表项(PTE)上有 U/S、R/W、NX 位,决定"谁"能"怎么"访问这一页。
- 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_PKRSMSR,只有 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 记住三点
- 特权级在 CS 低 2 位 :CPL=3 是用户、CPL=0 是内核(segment.h:33/48),
__USER_CS就是*8+3。CPU 每次访存都拿 CPL 和目标页权限比对。 - 分页是隔离主力:PTE 的 U/S(内核页 vs 用户页,pgtable_types.h:12)、R/W(写保护 + CR0.WP)、NX(不可执行,:30)三位覆盖"谁能访问/能否写/能否执行";分段在 64 位基本被架空(只剩 FS/GS 做 percpu/TLS)。
- SMEP/SMAP 堵反向漏洞 :U/S 只管"用户别碰内核",SMEP(禁执行用户页)/SMAP(禁访问用户页)管"内核别乱碰用户",靠
CR4.SMEP/SMAP(common.c:364/375)+CLAC/STAC(smap.h:17-18)临时开关,防 ret2usr 提权。