你已经读过关于分页的内容。图表看起来也合理。四级页表,每级9位,页框,偏移量。没错。但当你遇到一个真正需要手动遍历页表的挑战时,你意识到你并不真正_理解_它。你只是_知道_它。区别很大。
对我来说有效的方法是坐在QEMU和gdb前,亲自手动完成遍历:计算每个索引,从物理内存中读取每个条目,手动跟踪每个指针。一个下午的实践比几个小时的讲座学到更多。
这是我当时记录的笔记集合。如果你仍然缺少概念方面的理解,先看Zardus的关于内核内存管理的讲座。那是理论。这是实践。
目标:获取一个虚拟地址,并在原始物理内存中追踪它,直到找到数据。没有内核辅助函数。没有抽象。只有QEMU虚拟机、gdb和原始物理内存。
最终,分页将不再是你读过的东西,而是你亲手实践过的东西。
项目包含了预构建的内核和initramfs。我在Fedora上运行,但任何支持QEMU和gdb的操作系统应该都可以。用你的包管理器安装它们:```
sudo apt install qemu-system-x86 gdb
sudo dnf install qemu-system-x86 gdb
brew install qemu gdb
### 挑战二进制
目标是一个简单的C程序,它在内存中存储一个标志并打印其虚拟地址:```c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char secret[] = "FLAG{p4g3_t4bl3_w4lk3r}";
printf("secret @ %p\n", (void *)secret);
printf("pid = %d\n", getpid());
printf("Spinning. Walk the page tables to find the flag.\n");
while (1)
{
}
}
忙循环是故意的。我最初使用了 pause(),但这会使进程在系统调用中进入睡眠:当 gdb 暂停虚拟机时,CPU 很可能在运行具有不同 CR3 的空闲任务。自旋循环使进程保持在 CPU 上,因此暂停保证了你在正确的页表上下文中。
带有此二进制文件的预构建 initramfs 已经包含在 initramfs.cpio.gz 中。如果你需要重新构建它(仅限 Linux,需要 busybox 和 glibc-static),请在此目录中运行 make。
./start.sh
脚本使用 `-s`(GDB 服务器位于 `localhost:1234`)和 `nokaslr` 在 QEMU 下启动捆绑的内核和 initramfs,以便内核地址在多次运行之间保持不变。
虚拟机立即启动并运行挑战二进制文件。你会看到标志的虚拟地址打印到控制台上。```
secret @ 0x7ffe08985c90
pid = 1
Spinning. Walk the page tables to find the flag.
写下该虚拟地址。那是你的目标。

默认的 QEMU 转义键是
Ctrl-a,但这会与我的 tmux 前缀冲突,因此脚本使用-echr 0x11将其重新映射到Ctrl-q。如果你使用Ctrl-q用于其他用途,请在start.sh中更改十六进制值以适应你的设置。
在第二个终端中:``` gdb -ex "target remote :1234"

---
## 分解虚拟地址
你有一个虚拟地址。但数据到底在哪里,_真的_在哪里?
虚拟地址是操作系统的一种礼貌性虚构。每个进程都认为自己拥有从零开始的私有内存。而实际上,数据位于物理RAM中某个完全无关的位置。页表就是这两者之间的映射:一个树形结构,CPU在每次内存访问时都会遍历它(或者从TLB缓存中查找)。
所以,让我们像CPU一样来做这件事。手动操作。要翻译那个地址,我们需要将其分解为CPU在每一级使用的索引。
一个x86-64虚拟地址宽度为48位。这48位被分成五个字段:```
63 48 47 39 38 30 29 21 20 12 11 0
┌────────┬────────┬────────┬────────┬────────┬──────────┐
│ sign │ PGD │ PUD │ PMD │ PT │ Offset │
│ extend │ index │ index │ index │ index │ │
│ (16b) │ (9b) │ (9b) │ (9b) │ (9b) │ (12b) │
└────────┴────────┴────────┴────────┴────────┴──────────┘
每个9位索引选择该级别页表中的512个条目之一。12位偏移量选择最终4 KB (0x1000)页面内的一个字节。要提取索引,进行移位和掩码操作:``` PGD index = (VA >> 39) & 0x1FF PUD index = (VA >> 30) & 0x1FF PMD index = (VA >> 21) & 0x1FF PT index = (VA >> 12) & 0x1FF Offset = VA & 0xFFF
在 gdb 中,你可以直接计算这些:```
(gdb) p/x (0x7ffe08985c90 >> 39) & 0x1ff
$1 = 0xff
(gdb) p/x (0x7ffe08985c90 >> 30) & 0x1ff
$2 = 0x1f8
(gdb) p/x (0x7ffe08985c90 >> 21) & 0x1ff
$3 = 0x44
(gdb) p/x (0x7ffe08985c90 >> 12) & 0x1ff
$4 = 0x185
(gdb) p/x 0x7ffe08985c90 & 0xfff
$5 = 0xc90
写下它们。你将在相应的层级中使用每个值。
你的值会不同。 地址
0x7ffe08985c90只是一个例子。 请使用你的挑战二进制文件打印出的任何地址。
关于5级分页的说明。 较新的CPU和内核支持LA57,它在PGD之上增加了第五级(PML5),并将虚拟地址扩展到57位。 遍历模式相同:多一个9位索引,多一次表查找。 大多数系统仍然运行4级分页。你可以检查你的系统:
cat /proc/cpuinfo | grep la57。本文所有内容均假定使用4级分页。
每棵树都有一个根。对于页表来说,这个根位于CR3寄存器中:它保存着顶级表(PGD)的物理地址。 每个进程都有自己的CR3值,内核在上下文切换时交换它。
这是进入遍历的入口点。从gdb中读取它:``` (gdb) info registers cr3 cr3 0x66c7000 [ PDBR=26311 PCID=0 ]
页表基址为 `0x66c7000`。低12位是PCID/标志位(此处为零),因此基地址即为该值本身。
这是遍历的起始点。
---
## 遍历
诀窍在于:每一级
都遵循相同的模式。各级的标志位略有不同,但过程一致。模式如下:
1. **计算表项地址:** `base + index * 8`(每个表项占8字节)
2. **从物理内存中读取表项** 使用QEMU监视器的 `xp` 命令
3. **解码标志位**(参见下面的参考)。如果Present(位0)为0,则该页未映射,遍历停止
4. **提取下一级表的基址:** 将表项与 `& 0x000FFFFFFFFFF000` 进行掩码操作
5. **进入下一级**
每个表项为64位。常见标志位:```
Bit Name Meaning when set
0 Present Page/table is mapped
1 Read/Write Writable
2 User/Supervisor Accessible from userspace
3 Write-Through Write-through caching
4 Cache Disable Caching disabled
5 Accessed CPU has read this entry
6 Dirty CPU has written to the page (final level only)
7 Page Size 1 GB page (PUD) or 2 MB page (PMD)
63 NX No-execute
位[51:12]保存下一个表(或最终级别的页框)的物理地址。位9-11被硬件忽略,可供操作系统使用。Linux将它们用于记账(例如软脏跟踪)。位52-62是保留的。在漏洞利用文章中阅读PTE时,您会同时遇到这两种情况。
在操作过程中,请准备好这个标志表。
开始吧。
我们从CR3获得PGD基址:0x66c7000。
我们的PGD索引是0xff。
计算条目地址:``` entry = 0x66c7000 + 0xff * 8 = 0x66c77f8
使用 QEMU 的物理内存检查命令从 gdb 中读取:```
(gdb) monitor xp/1gx 0x66c77f8
000000066c77f8: 0x0000000006713067
Entry: 0x6713067 [Present RW User Accessed Dirty].
Next base: 0x6713067 & 0x000FFFFFFFFFF000 = 0x6713000.
我们从PGD条目(0x6713000)提取的基地址指向PUD。相同过程,下一个索引:0x1f8。```
entry = 0x6713000 + 0x1f8 * 8 = 0x6713fc0
### 工作原理...
Burp Suite 可用于执行初始请求,然后将其转发到 Repeater 工具。Repeater 工具可用于通过更改原始请求中的值并检查来自服务器的不同响应来手动审查和修改 Web 应用程序的内容。在提供的示例中,使用 Burp Suite 截获的请求被转发到 Repeater 工具。然后,手动修改 Referer 标头中的值,使其包含一个 Web 应用程序未预料到的序列。结果,服务器抛出了一个 500 内部服务器错误。此错误消息中包含了 Web 服务器的安装路径。错误消息中的此信息对于攻击者来说可能很有价值。这可以在枚举 Web 服务器目录结构和文件系统时提供指导。```
(gdb) monitor xp/1gx 0x6713fc0
00000006713fc0: 0x00000000066ac067
Entry: 0x66ac067 [存在 读写 用户 已访问 脏页]。页面大小(位7)= 0,不是1 GB大页。
下一个基地址:0x66ac067 & 0x000FFFFFFFFFF000 = 0x66ac000。
基地址:0x66ac000。PMD索引:0x44。```
entry = 0x66ac000 + 0x44 * 8 = 0x66ac220
(未提供需要翻译的输入文本,请补充。)```
(gdb) monitor xp/1gx 0x66ac220
000000066ac220: 0x00000000066c4067
Entry: 0x66c4067 [存在 读写 用户 已访问 脏页]. 页面大小(位7)=0,不是一个2 MB大页。
下一基地址: 0x66c4067 & 0x000FFFFFFFFFF000 = 0x66c4000.
基地址: 0x66c4000. PT索引: 0x185.```
entry = 0x66c4000 + 0x185 * 8 = 0x66c4c28
(无输入内容,无法翻译)```
(gdb) monitor xp/1gx 0x66c4c28
000000066c4c28: 0x80000000037fd867
Entry: 0x80000000037fd867 [存在 读写 用户 已访问 脏 不可执行]。这是最后的PTE。
Physical page frame: 0x80000000037fd867 & 0x000FFFFFFFFFF000 =
0x37fd000。
让我们看看当遍历遇到未映射页面时会发生什么。选择一个几乎肯定未映射的地址,地址空间中间某个位置:``` (gdb) p/x (0x0000414141414000 >> 39) & 0x1ff $1 = 0x82
(输入为空,无翻译内容。)```
(gdb) monitor xp/1gx 0x66c7000 + 0x82 * 8
00000000066c7410: 0x0000000000000000
全为零。位0(存在位)被清除。遍历在此停止。没有PUD,没有PMD,没有PT,没有页框。此地址未映射到物理内存。
如果CPU在正常执行过程中遇到这种情况,它将触发一个页错误(中断14)。内核的故障处理程序随后会决定如何处理:从磁盘加载页面(交换)、分配一个新页面(按需调页),或者通过段错误杀死进程。
关键在于:页表不仅仅是一个转换结构。它也是使虚拟内存变得_虚拟_的机制。并非每个地址背后都需要物理内存支持。CPU在遍历过程中逐级发现这一点。
将物理页框与原始虚拟地址的偏移量结合:``` Physical address = 0x37fd000 | 0xc90 = 0x37fdc90
现在阅读:```
(gdb) monitor xp/6bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70
这就是 F、L、A、G、{、p:我们 flag 的开头。阅读更多:```
(gdb) monitor xp/24bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70 0x34 0x67
00000000037fdc98: 0x33 0x5f 0x74 0x34 0x62 0x6c 0x33 0x5f
00000000037fdca0: 0x77 0x34 0x6c 0x6b 0x33 0x72 0x7d 0x00
请提供需要翻译的Markdown内容。```
FLAG{p4g3_t4bl3_w4lk3r}

就在这里。你刚刚完成了 CPU 每秒执行数十亿次的操作,但你是手动完成的——直接从物理内存读取原始字节。跨越四层表,没有任何抽象隐藏其后。
之前,分页只是幻灯片中的一张图。而现在,它是一系列你可以在脑海中重放的读取操作:基址、索引、偏移量、掩码、追踪。当你盯着一个内核漏洞,需要分析写入 PTE 实际会造成什么影响时,这种区别至关重要。
你可以通过 QEMU 的 gva2gpa(客户虚拟地址到客户物理地址)监视器命令来验证结果,该命令内部会执行遍历:```
(qemu) gva2gpa 0x7ffe08985c90
gpa: 0x37fdc90
---
## 标志位与权限
我们在遍历过程中解码了每一级的标志位,但跳过了它们对安全的影响。看一下最终的PTE:```
0x80000000037fd867
由于未提供INPUT内容,无法进行翻译。请提供实际的Markdown文本。``` Bit 0 (Present) = 1 Page is in physical memory Bit 1 (Read/Write) = 1 Page is writable Bit 2 (User/Supervisor)= 1 Accessible from user mode Bit 3 (Write-Through) = 0 Write-back caching Bit 4 (Cache Disable) = 0 Caching enabled Bit 5 (Accessed) = 1 CPU has read this page Bit 6 (Dirty) = 1 CPU has written to this page Bit 7 (Page Size) = 0 4 KB page (not huge) Bit 63 (NX) = 1 No-Execute: cannot run code from this page
这是合理的:秘密是一个栈变量。栈是可读、可写且脏的(已被写入)。它被标记为不可执行,因为现代系统强制执行W^X:可写页面不应可执行。
硬件将每一层的标志位进行AND运算。如果PUD条目具有User=0,则其下的任何内容都不可被用户访问,无论PTE如何设置。最严格的权限胜出。
---
## TLB:当CPU跳过页表遍历时
仅仅访问一个字节就需要四次内存读取。这代价很高。CPU实际上并不会在每次内存访问时都遍历页表。它将结果缓存在**转换后备缓冲区(TLB)**中。
在第一次访问标志变量的虚拟地址后,CPU将映射关系 `0x7ffe08985c90 -> 0x37fdc90`(大致)存储在TLB中。后续访问直接命中缓存,完全跳过遍历。页表在RAM中保持原样。
这对普通代码是透明的。但一旦你_修改_页表条目,这就变得重要了。如果你向PTE写入新的物理地址,CPU并不会察觉:TLB仍然持有旧的映射。你必须显式地刷新它。
内核通过`invlpg`指令完成这一点,该指令使单个虚拟地址的TLB条目失效。从用户空间调用`mprotect`,底层发生的事情就是:内核更新PTE标志,然后刷新TLB,以便CPU获取新的权限。
这直接关系到安全。在内核漏洞利用中,如果你设法写入PTE(比如清除NX位以使栈可执行),你还需要刷新TLB,CPU才会认可这个更改。有时内核会作为你所触发的代码路径的副作用而为你完成刷新。有时你需要自己安排。无论哪种情况,你都需要知道TLB的存在,否则你的漏洞利用可能在理论上可行,但在实践中无效。
---
## 大页:当遍历提前结束时
在上面的演练中,我们经历了所有四个层级。但如果设置了页面大小位(第7位),遍历可以提前结束。
**在第三层(PUD):** 如果第7位被设置,该条目直接映射一个1 GB的页面。物理地址取自该条目,而虚拟地址的[29:0]位成为偏移量(30位 = 1 GB)。
**在第二层(PMD):** 如果第7位被设置,该条目映射一个2 MB的页面。虚拟地址的[20:0]位成为偏移量(21位 = 2 MB)。
你经常会在内核映射中看到大页。内核的直接映射区域(在大多数64位内核上为`0xffff888000000000`)经常使用2 MB或1 GB的页面,以减少TLB压力。
如果在遍历过程中遇到大页,计算公式会发生变化:```
2 MB page: phys = (PMD_entry & 0x000FFFFFFFE00000) | (VA & 0x1FFFFF)
1 GB page: phys = (PUD_entry & 0x000FFFFFC0000000) | (VA & 0x3FFFFFFF)
既然我们了解了这个过程,现在来把它编码实现。pagewalk.py 是一个
gdb Python 脚本,它执行与我们刚才所做的相同的遍历。核心逻辑集中在
一个函数中:```python
ADDR_MASK = 0x000FFFFFFFFFF000
def read_phys(addr): """Read a 64-bit value from guest physical memory via QEMU monitor.""" result = gdb.execute(f"monitor xp/1gx {addr:#x}", to_string=True) return int(result.strip().split(":")[1].strip(), 16)
def pagewalk(va): cr3 = int(gdb.parse_and_eval("$cr3")) pgd_base = cr3 & ADDR_MASK
# Decompose the virtual address
pgd_idx = (va >> 39) & 0x1FF
pud_idx = (va >> 30) & 0x1FF
pmd_idx = (va >> 21) & 0x1FF
pt_idx = (va >> 12) & 0x1FF
offset = va & 0xFFF
# Walk: each level is the same pattern
pgd_entry = read_phys(pgd_base + pgd_idx * 8)
if not (pgd_entry & 1): return None # Not present
pud_base = pgd_entry & ADDR_MASK
pud_entry = read_phys(pud_base + pud_idx * 8)
if not (pud_entry & 1): return None
if pud_entry & (1 << 7): # 1 GB huge page
return (pud_entry & 0x000FFFFFC0000000) | (va & 0x3FFFFFFF)
pmd_base = pud_entry & ADDR_MASK
pmd_entry = read_phys(pmd_base + pmd_idx * 8)
if not (pmd_entry & 1): return None
if pmd_entry & (1 << 7): # 2 MB huge page
return (pmd_entry & 0x000FFFFFFFE00000) | (va & 0x1FFFFF)
pt_base = pmd_entry & ADDR_MASK
pt_entry = read_phys(pt_base + pt_idx * 8)
if not (pt_entry & 1): return None
return (pt_entry & ADDR_MASK) | offset
整个脚本(包含标志解码和美观输出)位于 `pagewalk.py` 中。请加载它并使用它来验证您的手动工作,或探索其他地址:```
(gdb) source ./pagewalk.py
Page walk command loaded. Usage: pagewalk <virtual-address>
(gdb) pagewalk 0x7ffe08985c90
Decoded Virtual Address:
PGD=0x0ff
PUD=0x1f8
PMD=0x044
PT=0x185
Offset=0xc90
CR3: 0x00000000066c7000
PGD[0x0ff]: 0x0000000006713067 [Present RW User Accessed Dirty]
PUD[0x1f8]: 0x00000000066ac067 [Present RW User Accessed Dirty]
PMD[0x044]: 0x00000000066c4067 [Present RW User Accessed Dirty]
PT[0x185]: 0x80000000037fd867 [Present RW User Accessed Dirty NX]
Physical address: 0x00000000037fdc90

注意脚本如何在继续遍历之前检查PUD和PMD级别的大页。这与我们在"大页"部分讨论的逻辑相同:如果PageSize(位7)被设置,遍历提前结束,且偏移量更宽。
尝试遍历一个函数的地址:你会发现NX位是清除的(代码必须可执行)。尝试一个只读数据段:你会看到R/W是清除的。
在整个练习中,我们使用monitor xp直接读取物理内存。这之所以可行,是因为QEMU的监视器位于虚拟机之外,可以访问客户机的物理地址空间。在实际的漏洞利用中,你并没有这种便利。
内核通过直接映射区域为自己解决了这个问题:一个覆盖所有物理RAM的连续虚拟映射。在x86-64上,这个区域通常从0xffff888000000000开始,但在启用KASLR时基址是随机的。内核将实际基址存储在一个名为page_offset_base的符号中。
我们使用nokaslr引导,因此基址为默认值。我们来确认一下:```
(gdb) x/s 0xffff888000000000 + 0x37fdc90
0xffff888037fdc90: "FLAG{p4g3_t4bl3_w4lk3r}"
相同的物理内存,通过内核虚拟地址访问。这正是内核自身读取任意物理内存的方式:`phys_to_virt()` 就是 `page_offset_base + phys_addr`。
这也解释了为何内核漏洞利用关注 `page_offset_base` 的泄漏。如果启用了 KASLR,你将不知道直接映射的起始位置,因此无法将物理地址转换为内核虚拟地址。泄漏该基址后,便可以通过直接映射读取或写入任何物理地址,包括页表条目本身。
---
## 查找另一个进程的页表
我们的设置确保当 gdb 暂停虚拟机时,CR3 指向挑战二进制文件的页表。但如果需要遍历一个*不同*进程的页表呢?
每个进程的 CR3 值存储在其 `task_struct` 中。路径为:```
task_struct -> mm_struct -> pgd -> physical page
在带有内核符号的gdb中,你可以找到init的task_struct(PID 1)并提取 其页表根:``` (gdb) p/x init_task.mm->pgd $1 = 0xffff8880066c7000
这是直接映射中的内核虚拟地址。去除基地址以获取物理地址:```
0xffff8880066c7000 - 0xffff888000000000 = 0x66c7000
这就是我们最初使用的同一个CR3,这很有道理:在这个最小的initramfs中,我们的挑战二进制程序_就是_PID 1。
对于其他进程,你需要遍历任务列表(init_task.tasks链表),找到目标进程,然后以相同的方式提取其mm->pgd。每个进程都有自己的页表树,根节点位于其自身的CR3。内核在每次上下文切换时交换CR3,从而让每个进程拥有私有内存的假象。
如果你不是仅仅阅读,而是实际动手完成了地址遍历,那么你现在拥有了一种任何图表都无法给予的能力:对内存如何在硬件层面实际工作的直观理解。以下是这种直观理解带来回报的地方:
ASLR随机化的是虚拟地址,而不是遍历过程。 页表的结构始终相同:四级、每级512项、相同的位布局。ASLR改变的是你将要计算的索引,但过程是完全一样的。
W^X是通过页表强制执行的。 PTE中的R/W位和NX位正是使mprotect生效的关键。当利用程序试图在栈上执行shellcode时,CPU在地址翻译过程中检查NX位并触发故障。
SMEP和SMAP检查User位。 超级用户态执行保护/访问保护(SMEP/SMAP)会检查页表所有层级中的User/Supervisor位。如果任何一级将地址标记为用户态,而内核代码试图执行或访问它,CPU就会触发故障。这就是为什么现代内核漏洞利用不能简单地跳转到用户态shellcode。
内核漏洞利用常常直接针对页表。 如果你能写入某个PTE,就可以改变虚拟地址所映射的物理内存,修改权限,或者将内核内存重新映射为用户可访问。理解地址遍历就是理解攻击面。
KPTI将页表一分为二。 现在每个进程不再只有一套页表,而是两套:一套用于用户态(几乎所有内核页都未映射),另一套用于内核态(包含所有内容)。内核在每次系统调用入口和退出时交换CR3。你可以观察到这一点:在用户态暂停虚拟机并读取CR3,然后在系统调用入口设置断点并再次读取CR3。两者是不同的。用户态页表根本不含内核内存的条目,因此即使遍历完成,也没有什么可以泄露。
下次你在内核漏洞利用文章中看到“重新映射页表”时,它就不再抽象了。你会确切知道他们指的是哪些字节,因为你已经亲手读取过它们。