1. 实验背景与核心目标
在Linux操作系统的内存管理机制中,分页(Paging)是最基础也是最关键的技术之一。这个实验聚焦于分页机制下代码段和数据段的权限控制问题,特别是它们与页表权限位(Page Table Permission Bits)的关联关系。通过这个实验,我们可以深入理解:
- 为什么用户态程序不能随意修改内核空间的数据?
- 为什么代码段通常被标记为不可写?
- 当程序尝试越权访问内存时,硬件和操作系统如何协同工作来阻止这种非法行为?
这个实验适合已经掌握Linux基础内存管理概念,想进一步理解保护模式(Protected Mode)下权限控制机制的同学。我们将通过编写测试程序、观察页表项(Page Table Entry)变化、触发页错误(Page Fault)等方式,直观感受权限位的实际作用。
2. 关键概念解析
2.1 分页机制中的权限位
在x86架构的分页机制中,每个页表项(无论是PDE还是PTE)都包含一组控制权限的标志位:
code复制31 12 11 9 8 7 6 5 4 3 2 1 0
+-------------------+-----+-+-+-+-+-+-+-+-+-+
| Page Frame Addr | AVL |D|A|C|U|W|P|U|R|P|
+-------------------+-----+-+-+-+-+-+-+-+-+-+
关键权限位说明:
- P (Present):页是否存在于物理内存中
- R/W (Read/Write):0=只读,1=可读可写
- U/S (User/Supervisor):0=内核态可访问,1=用户态可访问
2.2 段描述符中的权限控制
在保护模式下,段描述符(Segment Descriptor)也包含权限控制字段:
code复制+-----------------+---+---+---+---+---+---+---+---+
| Base 31:24 | G |D/B| L |AVL|Limit 19:16| P |
+-----------------+---+---+---+---+---+---+---+---+
| Base 23:16 | DPL | S | Type | Base 15:0 |
+-----------------+-----+---+------+------------+
| Limit 15:0 | Base 15:0 |
+-----------------+-----------------------------+
关键字段:
- DPL (Descriptor Privilege Level):描述符特权级(0-3)
- Type字段:包含E(Executable)、C(Conforming)、R(Readable)、W(Writable)等权限标记
2.3 权限检查的完整流程
当CPU执行内存访问时,完整的权限检查流程如下:
- 段选择子检查:确保CPL ≤ DPL
- 段类型检查:例如代码段不可写
- 页表权限检查:确保访问方式(R/W)与页表项权限匹配
- 用户/内核权限检查:U/S位决定用户态是否能访问
重要提示:最终生效的权限是段权限和页权限的"与"关系。例如,即使页表项标记为可写,如果段描述符标记为只读,那么实际访问仍然是只读的。
3. 实验环境准备
3.1 硬件与软件需求
- 支持x86-64架构的CPU(现代Intel/AMD处理器均可)
- Linux内核版本4.x或更高(推荐5.x系列)
- GCC编译器套件
- 需要root权限执行部分操作
3.2 内核模块开发环境配置
我们需要编写一个简单的内核模块来查看页表内容:
bash复制# 安装开发工具链
sudo apt update
sudo apt install build-essential linux-headers-$(uname -r)
# 验证内核开发环境
ls /lib/modules/$(uname -r)/build
3.3 测试程序代码
准备两个测试程序:
data_access.c - 测试数据段权限:
c复制#include <stdio.h>
#include <stdlib.h>
int global_var __attribute__((section(".data"))) = 42;
const int const_var __attribute__((section(".rodata"))) = 100;
int main() {
int *ptr = (int*)&const_var;
printf("尝试修改只读数据段...\n");
*ptr = 200; // 这将触发页错误
return 0;
}
code_access.c - 测试代码段权限:
c复制#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
void demo_func() {
printf("This is a normal function.\n");
}
int main() {
void (*func_ptr)() = demo_func;
// 获取代码页的起始地址(按页对齐)
unsigned long page_start = (unsigned long)demo_func & ~(0xFFF);
printf("尝试修改代码段权限...\n");
if (mprotect((void*)page_start, 4096, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
perror("mprotect failed");
exit(1);
}
printf("尝试覆写代码段...\n");
*(unsigned char*)demo_func = 0xC3; // RET指令的机器码
func_ptr(); // 如果上面修改成功,这里会立即返回
return 0;
}
4. 实验操作步骤
4.1 观察正常情况下的页表权限
首先编写一个内核模块来dump指定地址的页表项:
c复制// pte_dump.c
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/highmem.h>
static unsigned long target_addr = 0;
module_param(target_addr, ulong, S_IRUSR | S_IWUSR);
MODULE_PARM_DESC(target_addr, "Virtual address to inspect");
static int __init pte_dump_init(void)
{
pgd_t *pgd;
p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
if (!target_addr) {
printk(KERN_ERR "Please specify target_addr parameter\n");
return -EINVAL;
}
printk(KERN_INFO "Dumping PTE for address 0x%lx\n", target_addr);
pgd = pgd_offset(current->mm, target_addr);
printk(KERN_INFO "PGD: %px\n", pgd);
p4d = p4d_offset(pgd, target_addr);
pud = pud_offset(p4d, target_addr);
pmd = pmd_offset(pud, target_addr);
pte = pte_offset_kernel(pmd, target_addr);
printk(KERN_INFO "PTE: %px\n", pte);
printk(KERN_INFO "PTE flags: %lx\n", pte->pte);
return 0;
}
static void __exit pte_dump_exit(void)
{
printk(KERN_INFO "pte_dump module unloaded\n");
}
module_init(pte_dump_init);
module_exit(pte_dump_exit);
MODULE_LICENSE("GPL");
编译并加载模块:
bash复制make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
sudo insmod pte_dump.ko target_addr=$(printf "%lu" &global_var)
dmesg | tail -20
4.2 运行测试程序观察行为
编译并运行数据段测试程序:
bash复制gcc data_access.c -o data_access
./data_access
预期会看到段错误(Segmentation fault),此时可以通过dmesg查看内核日志:
code复制[ 1234.567890] data_access[12345]: segfault at 55a5b5c6e000 ip 55a5b5c6e000 sp 7ffd12345678 error 7 in data_access[55a5b5c6e000+1000]
错误代码7表示:
- bit0 (0x1): 页不存在
- bit1 (0x2): 写操作
- bit2 (0x4): 用户态访问
4.3 修改页表权限实验
编写一个内核模块尝试修改页表权限:
c复制// pte_modify.c
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/highmem.h>
static unsigned long target_addr = 0;
module_param(target_addr, ulong, S_IRUSR | S_IWUSR);
static int __init pte_modify_init(void)
{
pte_t *pte;
pte_t new_pte;
// 获取PTE (省略p4d/pud/pmd层级)
pte = virt_to_kpte(target_addr);
if (!pte) {
printk(KERN_ERR "Failed to get PTE\n");
return -EINVAL;
}
printk(KERN_INFO "Original PTE flags: 0x%lx\n", pte_val(*pte));
// 修改权限位:设置可写、用户可访问
new_pte = *pte;
new_pte = pte_mkwrite(new_pte);
new_pte = pte_mkuser(new_pte);
set_pte_at(&init_mm, target_addr, pte, new_pte);
flush_tlb_kernel_range(target_addr, target_addr + PAGE_SIZE);
printk(KERN_INFO "Modified PTE flags: 0x%lx\n", pte_val(*pte));
return 0;
}
警告:这种直接修改页表的操作非常危险,可能导致系统不稳定。建议在虚拟机环境中测试。
5. 实验结果分析
5.1 数据段权限测试
在未修改页表权限前,尝试修改.rodata段的数据:
code复制$ ./data_access
尝试修改只读数据段...
Segmentation fault (core dumped)
对应的页表项通常显示为:
code复制PTE flags: 0x8000000000000165
- bit0 (0x1): Present
- bit1 (0x0): Read-only
- bit2 (0x4): User-accessible
- bit5 (0x20): Accessed
5.2 代码段权限测试
运行代码段测试程序:
code复制$ ./code_access
尝试修改代码段权限...
mprotect failed: Permission denied
这是因为默认情况下,代码段的页表项没有设置可写位,且mprotect不能提升超过原始映射的权限。
5.3 修改页表后的行为
如果强制通过内核模块修改页表权限:
code复制$ sudo insmod pte_modify.ko target_addr=$(printf "0x%lx" &global_var)
$ ./data_access
尝试修改只读数据段...
[程序不再段错误]
此时检查页表项:
code复制Modified PTE flags: 0x8000000000000167
- bit1 (0x2): 现在设置了可写位
6. 深入原理探讨
6.1 权限冲突处理规则
当段权限和页权限冲突时,CPU按照"最严格原则"处理:
| 段权限 | 页权限 | 实际权限 |
|---|---|---|
| 可读 | 可读 | 可读 |
| 可读 | 可写 | 可读 |
| 可写 | 可读 | 可读 |
| 可写 | 可写 | 可写 |
6.2 写时复制(Copy-On-Write)机制
即使是可写映射,对于共享库的.text段,Linux也会使用COW机制:
- 初始映射为可读/可执行
- 尝试写入时触发页错误
- 内核检查VMA权限
- 如果VMA允许写入,则复制物理页并修改权限
- 恢复执行
6.3 SMAP/SMEP保护机制
现代CPU还提供了:
- SMEP (Supervisor Mode Execution Prevention):禁止内核态执行用户空间代码
- SMAP (Supervisor Mode Access Prevention):禁止内核态访问用户空间数据
这些保护由CR4寄存器控制:
c复制// 检查SMEP/SMAP支持
if (cpu_has(c, X86_FEATURE_SMEP))
cr4_set_bits(X86_CR4_SMEP);
if (cpu_has(c, X86_FEATURE_SMAP))
cr4_set_bits(X86_CR4_SMAP);
7. 实际应用场景
7.1 JIT编译器实现
即时编译(JIT)需要动态生成可执行代码,典型实现步骤:
- 使用mmap分配内存:
c复制void *mem = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - 写入编译后的机器码
- 修改权限为可执行:
c复制
mprotect(mem, size, PROT_READ | PROT_EXEC); - 执行生成的代码
7.2 内存漏洞防护
利用页权限可以实现的防护措施:
- W^X:内存页不能同时可写和可执行
- ASLR:随机化内存布局,增加攻击难度
- Shadow Stack:使用只读页保护返回地址
7.3 调试器实现原理
调试器需要修改代码段插入断点:
- 保存原指令字节
- 修改为0xCC (INT3)
- 处理断点异常
- 恢复原指令
这需要临时修改代码段权限:
c复制// 在Linux内核中临时修改页权限
pte_t *pte = lookup_address(addr, &level);
if (pte) {
pte_t old_pte = *pte;
set_pte_atomic(pte, pte_mkwrite(old_pte));
// ...写入断点...
set_pte_atomic(pte, old_pte);
}
8. 常见问题与调试技巧
8.1 页错误错误码解析
页错误(error code)结构:
code复制bit0: 0=页不存在, 1=权限冲突
bit1: 0=读操作, 1=写操作
bit2: 0=内核态访问, 1=用户态访问
bit3: 0=非保留位写, 1=保留位写
bit4: 0=非指令获取, 1=指令获取
常见组合:
- 0x7: 用户态写操作权限冲突
- 0x5: 用户态执行操作权限冲突
- 0x3: 内核态写操作权限冲突
8.2 查看进程内存映射
通过/proc文件系统查看:
bash复制cat /proc/$PID/maps
示例输出:
code复制55a5b5c6e000-55a5b5c6f000 r--p 00000000 08:01 123456 /path/to/data_access
55a5b5c6f000-55a5b5c70000 r-xp 00001000 08:01 123456 /path/to/data_access
55a5b5c70000-55a5b5c71000 r--p 00002000 08:01 123456 /path/to/data_access
55a5b5c71000-55a5b5c72000 rw-p 00003000 08:01 123456 /path/to/data_access
- r=读, w=写, x=执行, s=共享, p=私有
8.3 性能优化考量
页权限修改是昂贵操作,因为需要:
- 刷新TLB
- 可能触发IPI(多核同步)
- 可能阻塞其他线程
优化建议:
- 批量处理权限修改
- 避免频繁切换
- 使用大页(HugePage)减少TLB压力
9. 扩展实验建议
9.1 测试不同段类型的组合
尝试以下组合:
- 可写段 + 只读页
- 只读段 + 可写页
- 非执行段 + 可执行页
9.2 测试共享内存权限
创建共享内存区域:
c复制int fd = shm_open("/test", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
然后测试不同进程间的权限交互。
9.3 测试内核模块中的权限
编写内核模块尝试访问:
- 用户空间地址(触发SMAP)
- 执行用户空间代码(触发SMEP)
- 修改只读内核数据(触发CR0.WP保护)