Skip to content

Latest commit

 

History

History
339 lines (274 loc) · 20.2 KB

kernel-权限与权限提升.md

File metadata and controls

339 lines (274 loc) · 20.2 KB

真是好久没写到kernel相关的东西,先前把内存和程序相关的东西搞一搞转眼就到五月中旬了,也终于有功夫开始研究起kernel安全相关的东西。

关于权限就不得不先说一下进程/线程,对于linux来说其实没有什么线程/进程,而是只有task的概念,具体表现出来就是一个个的task_struct,但是由于用户态内核态的特权级的问题,而出现了内核进程用户进程的区别,然而内核进程的说法又是有问题的,因为先前说了对于系统来说实际只有task的概念,每个task之间应该相互独立各自有各自的资源,内核中的task都是公用的一份内核资源,而共享资源却又做着不同的事情是线程的概念,因此划分出了:

  1. 内核线程
  2. 用户进程
  3. 用户线程

进程是最小的资源分配单位,而线程则是最小的调度单位

回来说权限问题,kernel是怎么做权限管理的呢?这儿要搞明白的一件事就是权限管理kernel提供的一个能力,它本身的运行不需要什么权限划分,可以理解成kernel的任意行为都是最高权限行为,而对于使用系统的人来说,才需要划分出权限来,这就有了用户的概念,而用户操作的本质其实就是进程访问资源,那么权限管理的实现其实主要依赖的就是两部分:

  1. 用户权限划分
  2. 进程信任凭证

多用户Unix like的一个特性,这源于计算机设计之初的场景需要,因为以前的计算机非常的大,一个计算系统是由中央计算机和各地的终端组成的,那就必须得有一种防止不同用户相互影响的方案提出来。而渐渐的,又出现了需要协同创作修改等需求,这就出现了用户组,用户权限这些概念,而这些用户中最为特殊的莫过于superuser也就是root

root这个名字推测可能源于目录结构中的/

但这些其实都是设计上的概念,那么具体落实到代码和方案上是怎么实现的?这又要谈及linux的权限校验机制,依靠的是uidgiduid类似于身份证一样的概念,整个系统中唯一,一个用户都有一个自己的uid,同样的道理一个用户组也有一个gid,但是不同的用户可以有相同的gid

用户的uidgid是怎么对启动的进程产生影响,这才是最值得研究的一个点,一个进程的全部信息都保存在task_struct中,其中自然就有关于权限方面的数据,也就是进程信任凭证

 /* 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;

都是相同的结构体task cred,其结构如下:

struct cred {
    atomic_t usage;
    kuid_t uid;
    kgid_t gid;
    kuid_t suid;
    kgid_t sgid;
    kuid_t euid;
    kgid_t egid;
    kuid_t fsuid;
    kgid_t fsgid;
    unsigned int securebits;
    kernel_cap_t cap_inheritable;
    kernel_cap_t cap_permitted;
    kernel_cap_t cap_effective;
    kernel_cap_t cap_bset;
    kernel_cap_t cap_ambient;
    unsigned char jit_keyring;
    struct key *session_keyring;
    struct key *process_keyring;
    struct key *thread_keyring;
    struct key *request_key_auth;
    void *security;
    struct user_struct *user;
    struct user_namespace *user_ns;
    struct group_info *group_info;
    struct callback_head rcu;
}
SIZE: 168

是不是看起来有点不能理解,因为按照之前的设计理念,一个进程只需要一个cred且这个cred里面只需要保存uidgid信息,这样就能限定该task_struct的权限了吗?因为资源的校验也仅仅只是看这两个值的啊? 如果光从鉴权的角度来说,这么设计是没错,但是却忽略了权限变化的事情。比如suid权限的执行文件,执行人的权限和实际进程的需要的权限其实并不一致,或者说进程的逻辑中存在setuid这样的情况,因此实际上来说,凭证中包含的信息要更为复杂以便应对不同的需求。

其实按照名称来说也就是四种:

  1. uidgid -> real user IDreal group ID
  2. suidsgid -> saved set user IDsaved set group ID
  3. euidegid -> effective user IDeffective group ID
  4. fsuidfsgid -> file-system user IDfile-system group ID

uidgid标识了进程的真实归属,对于用户操作而产生的进程来说,其实往往都继承自初始的shell进程,而shell进程的ID又是在登录之初经过校验后login进程调用setuid根据用户信息设置的。先说下euidegid,这个是校验机制中真正会去验证的ID,而suid则是一个buffer,是effectice user id的拷贝,这个ID的意义在于一个进程在运行过程中可以权限自由在euiduid之间切换,因为一个进程应该尽可能以低权限运行,所以只有在需要时,进程才会切换到高权限。fsuid/fsgid比较特殊,可以算是linux独有的,传统unix中进程访问文件,发送信号,IPC通信什么的都是只依靠euid,然而到了linux中把访问文件这一个校验给分开来,单独设计了一个fsuid/fsgid,但是又为了和传统一致,这个值的设置依赖euid/egid,当euid/egid被修改时fsuid/fsgid会跟着被修改,这就保证了一致性,而唯一的区别在于可以通过setfsuid()/setfsgid()单独设置这个值,不过这个值现在基本没啥用了,但是为了保证软件兼容而保留下来这个值。

因为我通过修改一个root权限的fsuid和修改一个非rootfsuid都没能对读取/etc/shadow这个操作造成任何影响。参考这个id引入的历史原因是为了解决NFS,但是后来又被其余方式替代了,大概是真的没什么用了吧

回看task_struct中的三个凭证,其中real_credcred又是一个新的概念--主客体,其中cred主体凭证real_cred则是客体凭证,在正常的进程逻辑中,其实往往需要的仅仅只有cred用来获取资源,而倘若是遇到了进程通信这种情况时,那么一个进程是主体,一个进程是客体,被访问者就需要出示real_cred用来验证对方的权限。ptracer_cred是在ptrace时候才会涉及的东西,也就是tracee执行exec加载setuid executable的时候用到的凭证。这个设计的原因很简单:

  • tracer可以任意更改tracee寄存器内存,而setuid exectuable在执行的时候会将euid修改成创建者的uid,一般情况下都是root,那如果不加验证的话,完全可以造成越权操作。

因此tracee应当保存tracercred,执行时验证权限,如不满足则不修改euid而是以原有权限执行。

正常的权限提升

程序的权限变化无非两个地方:

  1. 启动时
  2. 运行时

启动时的权限变化

启动时的权限变化那就要先看默认的情况下是怎么样的,先前分析过一个程序的启动流程,整个程序的加载到运行经过的有do_execveat_common -> exec_binprm -> load_elf_binary,一步一步地看一下关于cred的变化过程。

do_execveat_common

这一个过程中存在一个bprm->cred的初始化操作retval = prepare_bprm_creds(bprm);,排除加锁的操作,函数调用链很简单

prepare_bprm_creds
    -> prepare_exec_creds

        -> prepare_creds

简单贴一下prepare_creds的逻辑

struct task_struct *task = current;
 const struct cred *old;
 struct cred *new;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
old = task->cred;
 memcpy(new, old, sizeof(struct cred));

那么实际上来说,这儿prepare出来的cred实际上就只是其父进程的一个copy而已,唯一的区别就是增加了引用计数而已。接下来会有一个必要的安全检查check_unsafe_exec,例如是否被ptrace,不过并不怎么涉及权限设置,而是根据检查结果设置了几个标志位。

retval = prepare_binprm(bprm);

这一个函数调用是一个比较重点的地方,先前的文章里这儿没有细说,只是单纯的提到了调用bprm_fill_uid设置了权限信息,又调用kernel_readfile内容读入缓存,那这次就说道说道具体的权限变化。

int prepare_binprm(struct linux_binprm *bprm)
{
 int retval;
 loff_t pos = 0;
 bprm_fill_uid(bprm);
 /* fill in binprm security blob */
 retval = security_bprm_set_creds(bprm);
 if (retval)
  return retval;
 bprm->called_set_creds = 1;
 memset(bprm->buf, 0, BINPRM_BUF_SIZE);
 return kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos);
}
EXPORT_SYMBOL(prepare_binprm)

memset开始就可以不理会了,只有之前的是权限相关的设置逻辑。 bprm_fill_uid是针对新进程cred的初次填充,会首先无条件的把新进程的euid/egid设置为当前进程的euid/egid

 bprm->cred->euid = current_euid();
 bprm->cred->egid = current_egid();

bprm->cred是直接拷贝自current->cred的,但是要重新设置一遍的原因我个人觉得应该是受到bprm_mm_init的影响。不过影响不大,毕竟重新设置是必须经过的步骤,无法越过,但是从设计上来讲的话,应该是因为prepare_bprm_cred实际的作用仅仅是创建出cred对象出来,而prepare_bprm才是真正的初始化凭证信息

虽然先无条件设置了euid/egid,但是接着就要去检查一下S_ISUID/S_ISGID,说白点就是看要执行的这个程序是否被设置了suid/sgid,如果被设置的话,就要根据设置的内容再去修改euid/egid文件所有者/文件所有组的ID。

bprm_fill_uid填充完成后就进入到了security_bprm_set_creds,这个是一个安全检测,但其实也存在修改cred的可能,这个函数调用了LSM的框架,所以最终调用不同版本可能各有不同,但是我这个版本调用的是cap_bprm_set_creds。 排除掉一堆乱七八糟的内容,对euid/egid有影响的逻辑只有这么一块:

/* 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 = __is_setuid(new, old) || __is_setgid(new, old);


 if ((is_setid || __cap_gained(permitted, new, old)) &&
     ((bprm->unsafe & ~LSM_UNSAFE_PTRACE) ||
      !ptracer_capable(current, new->user_ns))) {
  /* 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);
 }

这一段逻辑的结果会导致euid/egid被重新赋值为uid/gid,先前bprm_fill_uidsetuid/setgid的情况下存在提升权限的可能,因此这儿就如注释所说又会降权。 关注一下条件,is_setid的结果取决于上一行__is_setuid__is_setgid的或结果,内部逻辑是将新进程的euid/egid和当前进程的uid/gid作对比,看是否一致,倘若相等的话,说明执行的不是setid程序

static inline bool __is_setuid(struct cred *new, const struct cred *old)
{ return !uid_eq(new->euid, old->uid); }


static inline bool __is_setgid(struct cred *new, const struct cred *old)
{ return !gid_eq(new->egid, old->gid); }

__cap_gained(permitted, new, old)是关于linux的capability特性的方法,这个在之前的虚拟化里面略有涉及,简单来说就是超越setid这种非黑即白的更细化的权限限制机制。_cap_gained调用的其实是cat_issubset

#define __cap_gained(field, target, source) \
 !cap_issubset(target->cap_##field, source->cap_##field)

逻辑上简单来讲就是判断target->cap_permitted是否为source->cap_permitted的子集,而结合函数上下文来看的话,就是判断新进程的能力集是否是其父进程的能力集的子集,如果不是的话,说明要执行的程序被单独设置了新的能力集,这就可能存在越权的问题。 bprm->unsafe & ~LSM_UNSAFE_PTRACE需要结合先前的check_unsafe_exec一起说才行,这个函数会修改bprm->unsafe,倘若当前进程被ptrace的话,则需要将其与LSM_UNSAFE_PTRACE按位或运算

/* bprm->unsafe reasons */  
#define LSM_UNSAFE_SHARE 1  
#define LSM_UNSAFE_PTRACE 2 
#define LSM_UNSAFE_NO_NEW_PRIVS 4 

而到了cap_bprm_set_creds的逻辑判断中,通过和取反后的LSM_UNSAFE_PTRACE进行与操作,这儿的意义有点模糊,因为只要bprm->unsafe非0那结果就必然是true,而决定bprm->unsafe却不仅仅是ptrace,还有task_no_new_privs(current)p->fs->users > n_fs,前者的意义是检测当前进程的atomic_flags是否为PFA_NO_NEW_PRIVS,这个好像涉及原子操作,放以后再说,后者则是检查当前进程的fs_struct引用数量是否大于线程组中具有相同fs_struct的线程数量和,如果大于的话说明当前进程是一个非安全的共享进程(多见于进程通信)。

static inline bool task_no_new_privs(struct task_struct *p)
{
    return test_bit(PFA_NO_NEW_PRIVS, &p->atomic_flags); //检测addr的第nr位是否为1
}

回归到条件判断中仅剩!ptracer_capable(current, new->user_ns),看名字依然是关于ptrace的,这个函数的作用主要体现在tracee调用execve时,可见内核在这部分的安全校验上下了多大功夫,细看一下函数逻辑

/**
 * 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);
}

除开rcu的部分不用理会外,核心又是一个lsm函数,其中的credcurrent->ptracer_cred,即如果current确实被ptrace的话保存的就是其tracer的票据。security_capable_noaudit的检测逻辑如下:

 /* 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? */
  if (ns == cred->user_ns)
   return cap_raised(cred->cap_effective, cap) ? 0 : -EPERM;


  /*
   * If we're already at a lower level than we're looking for,
   * we're done searching.
   */
  if (ns->level <= cred->user_ns->level)
   return -EPERM;


  /* 
   * The owner of the user namespace in the parent of the
   * user namespace has all caps.
   */
  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;
 }

第一个if主要用以检测traceruser namespace和新进程的namespace是否相同,如果相同的话就去检测一下是tracer否有CAP_SYS_PTRACE能力,有则通过检查,第二个if则是判断如果新进程的namespace等级已经高于(越小越高)tracer的等级,那就直接不操作了直接返回错误-EPERM(操作不允许),第三个if则是如果新进程的父namespace就是tracernamespace并且ownertracer权限相等的话,则通过检查,这儿的for循环主要是为了ns = ns->parent,不然存在那种新进程和tracer隔了几十个user namespace的情况。

这儿和unsafe部分的区别在于,bprm->unsafe发生在current主动去attach新的进程,而ptracer_capable则是发生在current发起tracemecurrent->parent追踪

那么这样再来看刚才的那整个条件判断,当一个setid的程序被执行,且执行者trace了这个新程序或者执行者发起了traceme但是tracer的权限不足时,就进入下一步检测

if (!ns_capable(new->user_ns, CAP_SETUID) ||
      (bprm->unsafe & LSM_UNSAFE_NO_NEW_PRIVS)) {

即如果新进程的user namespace中没有CAP_SETUID的权限或者bprm->unsafeLSM_UNSAFE_NO_NEW_PRIVS位为1,即没有获取新权限能力,则降权,把权限重新降低为所有者权限。

这边分析的很乱,可能得抽空修改

这边有一个PTRACE_TRACEME 本地提权漏洞很值得学习一下。至此prepare_binprm中的权限设置部分就完结了

exec_binprm

没有,略过

load_elf_binary

这一部分就到了最终的权限设置的部分,上下遍历下来唯一的相关函数只有install_exec_creds

void install_exec_creds(struct linux_binprm *bprm)
{
 security_bprm_committing_creds(bprm);


 commit_creds(bprm->cred);
 bprm->cred = NULL;


 /*
  * Disable monitoring for regular users
  * when executing setuid binaries. Must
  * wait until new credentials are committed
  * by commit_creds() above
  */
 if (get_dumpable(current->mm) != SUID_DUMP_USER)
  perf_event_exit_task(current);
 /*
  * cred_guard_mutex must be held at least to this point to prevent
  * ptrace_attach() from altering our determination of the task's
  * credentials; any time after this it may be unlocked.
  */
 security_bprm_committed_creds(bprm);
 mutex_unlock(&current->signal->cred_guard_mutex);
}
EXPORT_SYMBOL(install_exec_creds);

先前的核心函数是security_bprm_set_creds,而这儿就是security_bprm_committing_creds了,同样的也是LSM的东西,不过好像并没有做什么改变的样子,因此实际上cred在这之前就已经是完全设置完了。而commit_creds逻辑代码挺长的,但是实际上的作用就是直接把传入的cred装载到当前进程上,甚至说很多内核提权的相关手段都是利用了此函数。

运行时权限变化

一个进程在启动后的权限就应当是固定的,而要去提升其权限,本质上就是修改其task_struct中的euid/egid值,然而这个值的位置又是在内核内存中,因此在一定的道理上说,只有root有能力提升一个已经在运行的程序权限,但是如果程序在运行过程中去exec一个setid的程序的话,就以进程本身来说其实是改变了权限的,但是进程逻辑其实也相应的发生了变化,不再是原本的进程。

后记

本来写这个实际上是想要开始涉足提权的部分,但是研究了一段时间发现时机还未到,所以就先写到这儿不写了。

参考资料