传统的内存保护以进程为边界——虚拟地址空间划分了用户态与内核态的权限壁垒,页表项中的 U/S 位决定了_current_进程能否访问某块内存。但当需要在一个进程内部限制不同代码模块对内存的访问时(例如 JIT 编译器中限制编译器写入即将执行的代码页——W^X 原则),传统的页表操作显得过于沉重。Memory Protection Keys (MPK/PKU) 正是 Intel 在 Skylake 架构引入的硬件特性,用 4 位宽度的 "protection key" 标签附加在物理页上,用户态可在零系统调用的情况下开关访问权限,实现进程内部的细粒度内存隔离。
一、设计哲学:从进程级到页面级的权限切割
1.1 W^X 的执行困境
JIT 编译器面临一个经典悖论:编译阶段需要写入代码页(W),执行阶段需要禁止写入(X)。传统做法是 mprotect 在读写与可执行间切换,但每次切换都涉及:
- 系统调用入口(约 50-100 个时钟周期)
- TLB 击落广播(多核间 IPI 中断,数百周期)
- 页表项修改与屏障
这在热点代码生成路径上是不可忽视的开销。
1.2 Protection Keys 的核心思想
保护键为每个物理页附加一个 4 位标签(x86 支持 16 个键,常用 1-15 给用户态),每个线程维护一个 Protection Keys Rights Register (PKRU)——一个 32 位寄存器,每两位控制一个键的访问权限:
PKRU[2k : 2k+1] → 键 k 的访问权限
00 = 读写禁止
10 = 只读禁止写
11 = 读写允许 (AD=0, WD=0)
切换权限只需 wrpkru 指令——单周期、用户态安全、不触发 TLB 击落。切换成本从数千周期降到一个指令。
二、CPU 架构支持矩阵
2.1 x86 Intel PKU / AMD | ARM | RISC-V
| 特性 | Intel PKU (Skylake+) | AMD (Zen 3+) | ARM MPK (v8.9+) | UXI | RISC-V |
|---|---|---|---|---|---|
| 密钥数量 | 16 | 16 | 16 (4组×4) | 16 | 待定义 |
| PKRU 寄存器 | 32 位 | 同 | 系统寄存器 | 系统寄存器 | - |
| 用户态写 PKRU | wrpkru |
wrpkru |
wrpkru |
- | - |
| 支持架构 | SPARC KAP, Power10, SP4 | - | - | - | - |
Intel PKU 的实现细节:
- CR4.PKE=1 启用
- 页表项第 59:62 位(即 PKEY 位)存储键值
- 每个物理页只能绑定一个 key
- PKRU 检查发生在地址转换的 permission check 阶段,优先级高于页表项本身的 U/S/R/W 位
2.2 Linux 内核的用户态支持
内核自 4.6 版本引入 pkey 系统调用:
// 分配一个保护键
int pkey = pkey_alloc(unsigned int flags, unsigned int access_rights);
// 为虚拟地址区域绑定保护键
int pkey_mprotect(void *addr, size_t len, int prot, int pkey);
// 读取/设置键的访问权限(wrpkru 封装)
int pkey_read(int pkey);
void pkey_write(int pkey, unsigned int access_rights);
// 释放键
int pkey_free(int pkey);
三、内核数据结构与权限检查流程
3.1 内核侧的键管理
// arch/x86/include/asm/pgtable.h
#define _PAGE_PKEY_BIT0 59
#define _PAGE_PKEY_BIT1 60
#define _PAGE_PKEY_BIT2 61
#define _PAGE_PKEY_BIT3 62
#define _PAGE_PKEY_MASK (_AT(pteval_t, 0xF) << _PAGE_PKEY_BIT0)
每个 vm_area_struct 中通过 vma_pkey() 获取该 VMA 绑定的保护键。进程管理数组 mm->arch.pkey_allocation_map 跟踪已分配的键,避免重复分配。
3.2 MMU 检查流程
当 CPU 发起访存时,MMU 按以下顺序进行权限检查:
• 页表权限位:R/W/U/S 检查
• SMAP/SMEP:内核态不能访问用户态页面
• PKRU 检查(若 PCD=0):比较 PKRU 中对应键的 AD/WD 位
• MTE(若启用):内存标签检查,发生在 PKRU 之后
这意味着 PKRU 设置比标准页表权限更严格——即使用户态进程有 U/S 位授予的读写权限,PKRU 仍能在硬件层面拒绝访问。
四、用户态编程实战:从分配键到沙箱隔离
4.1 基础 API 编程模式
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
// glibc 4.6+ 提供了封装,也可直接 syscall
static inline int pkey_alloc(unsigned flags, unsigned rights) {
return syscall(SYS_pkey_alloc, flags, rights);
}
static inline int pkey_mprotect(void *addr, size_t len, int prot, int pkey) {
return syscall(SYS_pkey_mprotect, addr, len, prot, pkey);
}
int pkey_read(int pkey);
void pkey_write(int pkey, unsigned rights);
int pkey_free(int pkey);
// 分配并初始化一个保护键
int pk = pkey_alloc(0, PKEY_DISABLE_ACCESS); // 初始禁用访问
if (pk == -1) { perror("pkey_alloc"); abort(); }
// 分配内存并绑定保护键
void *buf = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if (buf == MAP_FAILED) abort();
if (pkey_mprotect(buf, 4096, PROT_READ|PROT_WRITE, pk) == -1) {
perror("pkey_mprotect"); abort();
}
4.2 wrpkru 的自定义封装
glibc 在某些发行版中 pkey_read/pkey_write 可能被优化掉(因为 wrpkru 需要特定 clobber 声明)。安全的内联汇编封装:
static inline void pkey_write_asm(int pk, unsigned int rights) {
unsigned int pkru = (rights & 0x3) << (2 * pk);
// wrpkru: EAX=EDX=0, ECX=pkru
asm volatile(
".byte 0x0f, 0x01, 0xef" // wrpkru
:
: "a"(0), "d"(0), "c"(pkru)
: "memory"
);
}
static inline unsigned int pkey_read_asm(int pk) {
unsigned int pkru;
// rdpkru
asm volatile(
".byte 0x0f, 0x01, 0xee" // rdpkru
: "=a"(pkru)
: "c"(0)
);
return (pkru >> (2 * pk)) & 0x3;
}
4.3 进程内沙箱模式:JIT 引擎的 W^X 实践
// JIT 代码缓冲区生命周期管理
typedef struct {
void *code_page;
int code_pkey;
size_t size;
} jit_buffer_t;
jit_buffer_t* jit_buffer_create(size_t size) {
jit_buffer_t *jb = malloc(sizeof(jit_buffer_t));
// 步骤 1: 分配 RW 缓冲区
jb->code_page = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 步骤 2: 分配保护键(初始禁用写)
jb->code_pkey = pkey_alloc(0, PKEY_DISABLE_WRITE);
jb->size = size;
// 步骤 3: 绑定保护键到缓冲区
pkey_mprotect(jb->code_page, size, PROT_READ|PROT_WRITE, jb->code_pkey);
return jb;
}
// 写入代码时暂时开放写权限
void jit_buffer_write(jit_buffer_t *jb, void *code, size_t len) {
// 临时开放写权限
pkey_write(jb->code_pkey, 0); // AD=0, WD=0 → 允许读写
// 写入代码(可能来自不可信的输入)
memcpy(jb->code_page, code, len);
// 恢复写禁止
pkey_write(jb->code_pkey, PKEY_DISABLE_WRITE);
// flush 指令缓存
__builtin___clear_cache(jb->code_page, jb->code_page + len);
}
// 执行代码时仅开放读权限(可执行基础权限)
void jit_buffer_execute(jit_buffer_t *jb, void (*entry)(void)) {
// 确保写权限已禁用
pkey_write(jb->code_pkey, PKEY_DISABLE_WRITE);
__builtin___clear_cache(jb->code_page, jb->code_page + jb->size);
entry();
}
这段代码的核心防御能力在于:即使代码注入漏洞让攻击者控制写入路径,他们也无法将恶意代码写入可执行页面——写入必须通过 jit_buffer_write,而该函数在写操作完成后立即关闭写权限。
五、高级应用场景
5.1 数据库引擎的临时缓冲区隔离
在事务型数据库中,元数据页、WAL 缓冲区、数据页分别对应不同的信任级别:
// PostgreSQL 风格的 buffer pool 保护
enum db_page_kind {
PAGE_METADATA, // 高信任 → 由 DB 内核管理
PAGE_WAL, // 高信任 → WAL 写入器
PAGE_DATA_USER, // 低信任 → 来自 SQL 解析后的数据
};
static int page_keys[3];
void db_init_protection(void) {
// 初始化三种保护键,初始全部禁用访问
for (int i = 0; i < 3; i++) {
page_keys[i] = pkey_alloc(0, PKEY_DISABLE_ACCESS);
}
}
void db_page_bind(void *page, size_t size, enum db_page_kind kind) {
// 先设置为 PROT_NONE 以清除旧权限
mprotect(page, size, PROT_NONE);
pkey_mprotect(page, size, PROT_READ|PROT_WRITE, page_keys[kind]);
}
// 只有 DB 内核可以访问的元数据
void db_access_metadata(void *meta_page, void (*work)(void*)) {
pkey_write(page_keys[PAGE_METADATA], 0); // 开启元数据访问
pkey_write(page_keys[PAGE_WAL], PKEY_DISABLE_ACCESS);
pkey_write(page_keys[PAGE_DATA_USER], PKEY_DISABLE_ACCESS);
work(meta_page);
// 完成后关闭所有键
for (int i = 0; i < 3; i++)
pkey_write(page_keys[i], PKEY_DISABLE_ACCESS);
}
5.2 多租户应用中的租户数据隔离
在 Server 服务中处理多个租户数据时,PKU 可实现轻量隔离:
typedef struct {
int pkey;
void *tenant_data;
size_t data_len;
} tenant_slot_t;
// 每个线程处理一个租户的上下文中
void tenant_process_data(tenant_slot_t *slot) {
// 开启本租户数据的访问权限
pkey_write(slot->pkey, 0); // 允许读写
// 处理业务逻辑...
process_business_logic(slot->tenant_data, slot->data_len);
// 处理完毕,关闭访问
pkey_write(slot->pkey, PKEY_DISABLE_ACCESS);
}
六、安全与性能权衡
6.1 PKRU 劫持的防御
wrpkru 是用户态可执行的,恶意代码可能尝试重置 PKRU。防御策略:
• 控制流完整性 (CFI):确保 wrpkru 只出现在受控的封装函数
• 影子栈 + MAC:结合影子调用栈保护返回地址
• 编译器加固:GCC/Clang 的 -fhardened 标志
// 防御性封装:在 wrpkru 前审计调用者
static inline void pkey_write_checked(int pk, unsigned rights) {
// 通过栈上 canary 验证 wrpkru 调用来源
volatile uintptr_t ra = (uintptr_t)__builtin_return_address(0);
if (!is_valid_pkey_writer(ra)) {
raise(SIGILL);
}
pkey_write_asm(pk, rights);
}
6.2 性能开销比较
| 操作 | 时钟周期(近似) | TLB 影响 |
|---|---|---|
wrpkru 切换键权限 |
1-2 | 无 |
mprotect (切换一页) |
1000-5000 | 核心本地 TLB 刷新 |
mprotect (切换大区域) |
50000+ | 全局 IPI 广播 |
| 页表遍历 | 5-20 | 无 |
对 JIT 引擎而言,将 W^X 切换从 mprotect 改为 PKRU,可以让代码生成路径的开销降低数百倍。
七、与其他安全技术的互补关系
PKU 不是替代 seccomp、Landlock 或 MTE 的技术,而是与之互补:
| 技术 | 粒度 | 防御目标 | 切换成本 |
|---|---|---|---|
| PKU | 页面级(进程内) | 进程内越权访问 | 1 周期 |
| seccomp | 系统调用级 | 减少攻击面 | N/A(过滤) |
| Landlock | 文件系统级 | 文件系统访问控制 | N/A |
| MTE | 缓存行级 (16B) | Use-after-free, 溢出 | 1-5% 总体开销 |
| CFI | 基本块级 | 控制流劫持 | 3-5% |
实际部署中,常见组合是:
seccomp 限制系统调用 → Landlock 限制文件访问 → PKU 限制内存访问 → MTE 检测硬件级内存安全违规
八、前沿探索:PKU 与机密计算融合
在 Intel TDX (Trust Domain Extensions) 和 AMD SEV-SNP 机密计算环境中,PKU 的价值进一步放大:
- 安全分区中的沙箱:在一个机密 VM 内,不同信任级别的代码需要进一步隔离,PKU 提供无需 exit 到 hypervisor(即不触发世界切换)的隔离机制
- 加密内存的细粒度访问:结合 TME (Total Memory Encryption),PKU 可以在加密内存上叠加权限标签,防御同进程内的侧信道攻击
Linux 内核社区也在积极探索 PKU 与 Landlock 的集成,让 Landlock 策略能够通过 pkey 而非 page table 操作来实施文件系统相关的内存保护。
九、总结:何时使用 PKU
Memory Protection Keys 适用于以下场景:
• JIT 编译器:W^X 切换的成本敏感场景,代价从微秒降到纳秒
• 多阶段沙箱:需要在进程内区分多个信任域(如 Rust 编译器中的编译阶段与运行阶段)
• 高安全服务:金融交易处理、加密服务中的密钥材料与非密钥代码的隔离
• Serverless 平台:在同一地址空间运行多个互不信任的函数实例
PKU 不是银弹——它不支持跨进程隔离(那是虚拟地址空间的职责),无法防御旁路攻击(那是 MTE 的领域),也无法防御代码注入在 wrpkru 被劫持时的攻击。但作为硬件辅助的"进程内沙箱原语",它以极低代价填补了一个传统安全技术长期覆盖不足的空白。
核心要点:pkey_alloc 绑定物理页,wrpkru 零成本切换权限,x86 PKU 16 个键覆盖绝大多数场景。在 W^X 场景下替代 mprotect,可获得百倍的性能提升。与 seccomp/Landlock/MTE 形成纵深防御。

发表评论 取消回复