你已经读过关于分页的内容。图表看起来也合理。四级页表,每级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
---
## 标志位与权限