调试的第一个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 | SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *, |
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 | //https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L2091 |
在do_fork函数中调用了_do_fork函数,_do_fork()中首先检查父进程的ptrace字段,确定是否有另外一个进程在跟踪父进程;然后调用copy_process函数复制进程描述符,重点关注copy_process这个函数。
1 | //https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L2016 |
copy_process函数创建进程描述符以及子进程执行所需要的所有其他数据结构,与本次漏洞利用相关的函数是在copy_process函数中调用的cgroup_can_fork函数。
1 | //https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/fork.c#L1535 |
cgroup_can_fork函数源码如下,在该函数中,全局变量have_canfork_callback作为第三个参数传递给函数do_each_subsys_mask,第一个参数ss是一个结构体指针,在执行完do_each_subsys_mask后,会调用ss->can_fork(child),结构体cgroup_subsys里面有很多函数指针:
1 | //https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/cgroup/cgroup.c#L5380 |
然后看一下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 | //https://elixir.bootlin.com/linux/v4.14-rc4/source/kernel/cgroup/cgroup.c#L592 |
调试
系统环境
系统内核版本是4.14.0,文件系统是我随便找了一个题目的:
1 | / # uname -a |
查找我们用到的变量cgroup_can_fork的地址:
1 | / # cat /proc/kallsyms | grep cgroup_can_fork |
正常执行
首先看一下正常执行过程。在cgroup_can_fork函数下断点,随意输入一条命令,创建进程触发fork系统调用,执行到断点,单步执行得到0xffffffff810e019b处,rip+0xe5f2b8是全局变量have_canfork_callback的地址:
have_canfork_callback变量的值如下:
1 | pwndbg> x /8gx 0xffffffff810e01a2+0xe5f2b8 |
继续执行,函数执行到0xffffffff810e01a6,调用了一个函数,目前不确定这个函数是什么。本次调用这个函数返回值是4:
看下一条汇编,当函数返回值大于3时,函数跳转到函数结束处,销毁堆栈执行到返回:
既然不确定在地址0xffffffff810e01a6调用的哪个函数,那假如这个函数返回值小于等于三的话,会执行什么代码呢?或许看了后面的执行流程我们就能猜出这个函数是什么。程序在0xffffffff810e01b9处有一个跳转:
程序跳转至看一下0xffffffff810e01d6,看一下这个地址处的汇编:
观察汇编,看到在地址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的地址:
此时,我们可以知道,地址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 | prepare_kernel_cred = (prepare_kernel_cred_t)get_kernel_sym("prepare_kernel_cred"); |
然后mmap一段大小为4096的匿名空间,起始地址为0,并将shellcode拷贝至地址0处:
1 | static const unsigned char shellcode[] = { |
shellcode长度为8个字节,在地址8处,将自定义函数get_root函数地址拷贝至此处,get_root函数通过commit_creds(prepare_kernel_cred(0))来提权:
1 | void get_root() { |
然后执行fork创建进程,并调用waitid函数将cgroup_can_fork地址作为其第二个参数传入:
1 | if(-1 == (pid = fork())) { |
将cgroup_can_fork作为waitid参数传入后,在执行完waitid后,cgroup_can_fork被修改为0x11:
此时再创建一个进程,触发fork系统调用,遍历cgroup_can_fork位图,由于cgroup_can_fork被修改为0x11,也就是1 0 0 0 1,由于CGROUP_SUBSYS_COUNT默认值为4,因此我们得到在位图中最后一个为1的位置是第1bit,因此在地址0xffffffff810e01a6返回值为0:
返回值0小于3,程序继续执行,执行到0xffffffff810e01e0处call [rax+0x50],rax+0x50处的内容为0:
那就是调用地址为0处的函数,0地址处我们早早就布置上了shellcode,程序跳转至地址0处执行,我们的shellcode只有8个字节,且其汇编内容就是jmp 0x8:
地址8处我们写入了get_root函数起始地址,执行commit_creds(prepare_kernel_cred(0))进行提权,如果提权成功就尝试进行get_shell:
1 | void get_shell() { |
最终执行结果如下,虽然getuid已经为0提取成功,在get shell时提示“can’t access tty, job control turned off”,我感觉应该是在配置内核的dev的问题,但是尝试了网上很多解决方案都没有成功解决。
在这里有绕过SEMP、SMAP、Chrome沙箱的exp,由于还没有学习浏览器的漏洞利用,所以先留个坑~~
修复
这个漏洞的原因是使用unsafe_put_user替换掉原来安全的put_user:
修复是在关闭SMAP之前调用access_ok函数对infop参数进行检查。
参考
https://paper.seebug.org/451/
https://bbs.pediy.com/thread-247014.htm
《深入理解Linux内核》