只有“将一位设为 1”的越界能力,也可能改变一个对象的生命周期。CVE-2022-0995 的 watch queue 过滤器提供了这样的起点:破坏的不是权限标志,而是管道里指向页描述符的指针。
PageJack 把小对象内的指针破坏转换成页级释放后使用(UAF),进而影响来自其他缓存的对象。这里以 Linux 5.13 的源码与案例为边界,逐项检查每次能力转换所需的条件,不把一次成功演示当作稳定性保证。
同一输入,两套上限
watch_queue_set_filter 先统计有效条目,再分配内部过滤器并填充。x86-64 的 type_filter[2] 为 16 字节,两个循环却分别把它换算成 128 位与 1024 位。
| 阶段 | 接受条件 | x86-64 上限 |
|---|---|---|
| 统计并决定分配大小 | type < sizeof(type_filter) * 8 | 128 |
| 填充并设置位图 | type < sizeof(type_filter) * BITS_PER_LONG | 1024 |
| 仅后一个循环接受 | 128 ≤ type < 1024 | 896 个候选索引 |
这不是“1024 也可接受”的闭区间。输入还有条目数量与掩码校验;处于这个区间,并不自动意味着所有字段都可任意写入。
计数不足会让后续写入超过已分配的尾随条目数组;__set_bit(q->type, wfilter->type_filter) 则形成位图外置位。后者是 set-bit,不是 XOR 翻转:目标位本来为 1 时,值保持不变。越过位图范围也不等于已经越过整个分配对象,仍要计算后续字段与对象边界。
页描述符不是数据页地址
下图展示的是指针关系,不是实际内核地址或物理页间距:
flowchart TD A["管道 A:被改写的 page 指针"] --> P["同一个 struct page"] B["管道 B:正常的 page 指针"] --> P P --> D["承载数据的物理页"] A -. "未通过正常 get_page 增引用" .-> R["引用计数小于真实持有关系"]
pipe_buffer.page 指向 struct page 描述符,而非管道内容的字节数组。一次 p | mask 必须恰好得到另一张受控页的描述符地址,且改变的位原先为 0,才建立所需别名。
例如,若某构建的连续页描述符步长为 64 字节,指针相差 0x1000 对应 64 个描述符。再假定数据页为 4 KiB,所描述的页号距离才对应 256 KiB,而非一张 4 KiB 页。这个算例只说明单位关系,不假设任意内存模型都具有同样映射。
布局、尺寸等级与缓存归属
按所讨论的 x86-64 自然对齐布局,pipe_buffer 为 40 字节。private 位于偏移 32,而不是紧跟 4 字节 flags 的偏移 28:
| 偏移 | 名称 | 类型 | 大小 |
|---|---|---|---|
| 0x00 | page | struct page * | 8 |
| 0x08 | offset | unsigned int | 4 |
| 0x0c | len | unsigned int | 4 |
| 0x10 | ops | const struct pipe_buf_operations * | 8 |
| 0x18 | flags | unsigned int | 4 |
| 0x1c | padding | 4 | |
| 0x20 | private | unsigned long | 8 |
16 项数组请求 640 字节,两项请求 80 字节;在相应 SLUB 配置下,它们通常进入 1 KiB 与 96 字节等级。但大小匹配只是必要条件之一。
watch filter 使用 GFP_KERNEL,管道数组使用 GFP_KERNEL_ACCOUNT。实际缓存还受内存记账、内核配置及回移修改影响;分离到 kmalloc-cg-* 后,同一个尺寸等级不再意味着可以相邻。调整管道容量也要检查返回值、ring slot 数量与当前占用。
以下离线模型验证范围、自然对齐和指针单位;其中地址、描述符大小与数据页大小均为示例,不读取内核内存:
import ctypes as C
class PipeBuffer(C.Structure):
_fields_ = [("page", C.c_uint64), ("offset", C.c_uint32),
("len", C.c_uint32), ("ops", C.c_uint64),
("flags", C.c_uint32), ("private", C.c_uint64)]
assert C.sizeof(PipeBuffer) == 40 and PipeBuffer.private.offset == 32
count_limit, fill_limit = 16 * 8, 16 * 64
mismatch = [t for t in range(fill_limit) if t >= count_limit]
assert (mismatch[0], mismatch[-1], len(mismatch)) == (128, 1023, 896)
p, mask = 0x10000000, 0x1000
assert p | mask == 0x10001000
assert (p | mask) | mask == p | mask
descriptor_size, data_page_size = 64, 4096
assert mask // descriptor_size == 64
assert 64 * data_page_size == 262144
assert (40 * 16, 40 * 2) == (640, 80)
print("range=[128,1024); entries=896; pipe_buffer=40; private=32")
print("illustrative descriptor delta: 64 pages, not one page")释放一个缓冲区不等于归还页面
没有增加引用计数的别名,会让计数与真实持有关系失配。但何时归还页面仍取决于释放路径。
Linux v5.13 的 anon_pipe_buf_release 包含单页缓存逻辑:
if (page_count(page) == 1 && !pipe->tmp_page)
pipe->tmp_page = page;
else
put_page(page);当计数为 1 且缓存为空时,页面先进入 pipe->tmp_page,而非立即回到 buddy 分配器。free_pipe_info 最后会释放缓存页;这与读取耗尽一个缓冲区、关闭一个仍有其他引用的文件描述符,是不同生命周期事件。
因此,分析中应分别证明:别名形成、正常持有者释放、页面真正归还,以及另一个访问路径仍存活。少了任何一项,“页级 UAF”就仍只是推断。
跨缓存复用还差哪些条件
页面归还后,可以被其他用途重新领取。案例选择 struct file 作为后续目标,但打开一个文件不意味着一定新分配一张 slab 页;原缓存空槽、slab 阶数、每 CPU 状态和其他内核活动都会改变复用结果。
- 1
邻接与置位
确认越界命中目标结构,改动后指针仍有效,并通过不同管道的标识关联验证别名;没有崩溃并不足够。
- 2
实际页面释放
确认释放已越过临时缓存和其他引用,而不是仅观察到一次 close。
- 3
目标对象复用
区分目标缓存领取了这张页,以及目标对象实际位于页内哪个偏移。
- 4
写入位置重合
比较管道本次页内写入位置与目标对象起点加字段偏移,同时检查管道合并写入条件。
案例中的 f_mode 偏移为 68,即 0x44,属于该实验构建,而非所有 Linux 5.13 配置的 ABI 承诺。预先写入 68 字节并不自动定位字段:还要考虑 offset + len、对象在页内的起点和写入分支。
即便内部模式位发生变化,实际文件操作仍受其他状态、文件操作实现与检查约束。字段变化与最终效果应分别记录。
演示证据与修复落点
案例演示先显示普通用户 UID 1000;随后多次出现 Bug not found,再报告某管道读到另一标识,最后出现文件内容变化与 UID 0。中间的失败尝试值得保留,它说明布局或别名条件并非每次成立。
| 观察 | 支持什么 | 还需要什么 |
|---|---|---|
| 管道标识发生关联 | 存在共享数据的候选关系 | 排除重复输入与读取位置变化 |
| 工具报告字段修改成功 | 程序进入了对应阶段 | 对象、页内位置和实际字段观察 |
| 内容改变且最终 UID 为 0 | 该次演示达到最终效果 | 不据此推导跨构建成功率 |
上游提交 c993ee0f9f81caf5767a50d1faeba39a0dc82af2 同时约束两个循环,并将位图声明绑定到已知类型数量。下面仅摘出边界检查差异:
Jann Horn 报告了此缺陷,David Howells 提交修复。PageJack 方法由 Zhiyun Qian 在 Black Hat USA 2024 介绍,贡献者包括 Jiayi Hu、Jinmeng Zhou、Qi Tang 与 Wenbo Shen;这里讨论的 Linux 5.13 演示由 Jean Vincent 展示。
部署应确认发行版是否包含该修复或等价回移,以及 watch queue 配置与入口是否可达。最重要的审计结论是:有限写入可以经由指针与生命周期被放大,但每一步放大都应有独立证据。离线模型验证了算术与布局,未重新执行内核利用。