3.1.11 Linux 内核漏洞利用

从用户态到内核态

企图 用户态漏洞利用 内核态漏洞利用
蛮力法利用漏洞 应用程序可以多次崩溃并重启(或自动重启) 这将导致机器陷入不一致的状态,通常会导致死机或重启
影响目标程序 攻击者对被攻击程序(特别是本地攻击)拥有更多的控制(例如攻击者可以设置被攻击程序的运行环境)。被攻击程序是它的库子系统的唯一使用者(例如内存分配表) 攻击者需要和其他所有欲“影响”内核的应用程序竞争。所有的应用程序都是内核子系统的使用者
执行 shellcode shellcode 可以利用已经通过安全和正确性保证的用户态门来进行内核系统调用 shellcode 在更高的权限级别上执行,并且必须在不惊动系统的情况下正确地返回到应用程序
绕过反漏洞利用保护措施 这要求越来越复杂的方法 大部分保护措施在内核态,但并不能保护内核本身。攻击者甚至能禁用大部分保护措施

内核漏洞分类

未初始化的、未验证的、已损坏的指针解引用

这类漏洞涵盖了所有使用指针的情况,所指内容遭到破坏、没有被正确设置、或者是没有做足够的验证。

我们知道一个静态声明的指针被初始化为 NULL,但其他情况下这些指针被明确地赋值之前,都是未初始化的,它的值是存放指针处的内存里的任意内容。例如下面这样,指针被存放在栈上,而它的内容是之前函数留在栈上的 “A” 字符串:

  1. #include <stdio.h>
  2. #include <string.h>
  3. void big_stack_usage() {
  4. char big[0x100];
  5. memset(big, 'A', 0x100);
  6. printf("Big stack: %p ~ %p\n", big, big+0x100);
  7. }
  8. void ptr_un_initialized() {
  9. char *p;
  10. printf("Pointer value: %p => %p\n", &p, p);
  11. }
  12. int main() {
  13. big_stack_usage();
  14. ptr_un_initialized();
  15. }
  1. $ gcc -fno-stack-protector pointer.c
  2. $ ./a.out
  3. Big stack: 0x7fffd6b0e400 ~ 0x7fffd6b0e500
  4. Pointer value: 0x7fffd6b0e4f8 => 0x4141414141414141

下面看一个真实的例子,来自 FreeBSD8.0:

  1. struct ucred ucred, *ucp; // [1]
  2. [...]
  3. refcount_init(&ucred.cr_ref, 1);
  4. ucred.cr_uid = ip->i_uid;
  5. ucred.cr_ngroups = 1;
  6. ucred.cr_groups[0] = dp->i_gid; // [2]
  7. ucp = &ucred;

[1] 处的 ucred 在栈上进行了声明,然后 cr_groups[0] 被赋值为 dp->i_gid。遗憾的是,struct ucred 结构体的定义是这样的:

  1. struct ucred {
  2. u_int cr_ref; /* reference count */
  3. [...]
  4. gid_t *cr_groups; /* groups */
  5. int cr_agroups; /* Available groups */
  6. };

我们看到 cr_groups 是一个指针,而且没有被初始化就直接使用。这也就意味着,dp->i_gid 的值在 ucred 被分配时被写入到栈上的任意地址。

继续看未经验证的指针,这往往发生在多用户的内核地址空间中。我们知道内核空间位于用户空间的上面,它的页表在所有进程的页表中都有备份。有些虚拟地址被选做限制地址,限定地址以上或以下的虚拟地址归内核使用,而其他的归用户空间使用。内核函数也就是使用这个限定地址来判断一个指针指向的是内核还是用户空间。如果是前者,则可能只需做少量的验证,但如果是后者,则要格外小心,否则一个用户空间的地址可能在不受控制的情况下被解引用。

看一个 Linux 的例子,CVE-2008-0009:

  1. error = get_user(base, &iov->iov_base); // [1]
  2. [...]
  3. if (unlikely(!base)) {
  4. error = -EFAULT;
  5. break;
  6. }
  7. [...]
  8. sd.u.userptr = base; // [2]
  9. [...]
  10. size = __splice_from_pipe(pipe, &sd, pipe_to_user);
  11. [...]
  12. static int pipe_to_user(struct pipe_inode_info *pipe, struct pipe_buffer *buf, struct splice_desc *sd)
  13. {
  14. if (!fault_in_pages_writeable(sd->u.userptr, sd->len)) {
  15. src = buf->ops->map(pipe, buf, 1);
  16. ret = __copy_to_user_inatomic(sd->u.userptr, src + buf->offset, sd->len); // [3]
  17. buf->ops->unmap(pipe, buf, src);
  18. [...]
  19. }

代码的第一部分来自函数 vmsplice_to_user(),在 [1] 处使用了 get_user() 获得了目的指针。该目的指针未经检查就默认它是一个用户地址指针,然后通过 [2] 传递给了 __splice_from_pipe(),同时传递函数 pipe_to_user 作为 helper function。这个函数依然是未经检查就调用了 __copy_to_user_inatomic()[3],对该指针做解引用的操作,如果攻击者传递的是一个内核地址,则利用该漏洞能够写入任意数据到任意的内核内存中。这里要知道的还有 Linux 中以两个下划线开头的函数(例如 __copy_to_user_inatomic())是不会对所提供的目的(或源)用户指针做任何检查的。

最后,一个被损坏的指针往往是其他漏洞的结果(例如缓冲区溢出),攻击者可以任意修改指针的内容,获得更多的控制权。

内存破坏漏洞

这类漏洞是由于程序的错误操作重写了内核空间的内存(包括内核栈和内核堆)导致的。

内核栈在每次进程进入到内核态时发挥作用。内核栈与用户栈基本相同,但也有一些细小的差别,例如它的大小通常是受限制的。另外,所有进程的内核栈都是一块相同的内核地址空间中的一部分,所以他们开始于不同的虚拟地址并且占据不同的虚拟地址空间。

由于内核栈与用户栈的相似性,其发生漏洞的地方也大体相同,例如使用不安全的函数(strcpy(), sprintf() 等),数组越界,缓冲区溢出等。

针对内核堆的漏洞往往是缓冲区溢出造成的。通过溢出,重写了溢出块后面的块,或者重写了缓存相关的元数据,都可能造成漏洞利用。

整数误用

整数溢出和符号转换错误是最常见的两种整数误用漏洞。这类漏洞往往不容易单独利用,但它可能会导致另外的一些漏洞(例如内存溢出)的发生。

整数溢出发生在将一个超出整数数据存储范围的数赋值给一个整数变量。在不加控制的加法和乘法运算中如果堆参见运算的参数不加验证,也有可能发生整数溢出。

符号转换错误发生在将一个无符号数当做有符号数处理的时候。一个经典的场景是,一个有符号数经过某个最大值检测后传入一个函数,而这个函数只接收无符号数。

看一个 FreeBSD V6.0 的例子:

  1. int fw_ioctl (struct cdev *dev, u_long cmd, caddr_t data, int flag, fw_proc *td)
  2. {
  3. [...]
  4. int s, i, len, err = 0; [1]
  5. [...]
  6. struct fw_crom_buf *crom_buf = (struct fw_crom_buf *)data; [2]
  7. [...]
  8. if (fwdev == NULL) {
  9. [...]
  10. len = CROMSIZE;
  11. [...]
  12. } else {
  13. [...]
  14. if (fwdev->rommax < CSRROMOFF)
  15. len = 0;
  16. else
  17. len = fwdev->rommax - CSRROMOFF + 4;
  18. }
  19. if (crom_buf->len < len) [3]
  20. len = crom_buf->len;
  21. else
  22. crom_buf->len = len;
  23. err = copyout(ptr, crom_buf->ptr, len); [4]
  24. }

[1] 处的 len 是有符号整数,crom_buf->len 也是有符号数并且该值是我们可以控制的,如果它被设为一个负数,那么无论 len 的值是什么,[3] 处的条件都会满足。然后在 [4] 处,copyout() 被调用,该函数原型如下:

  1. int copyout(const void *__restrict kaddr, void *__restrict udaddr, size_t len) __nonnull(1) __nonnull(2);

第三个参数的类型 size_t 是一个无符号整数,所以当 len 是一个负数的时候,会被认为是一个很大的正整数,造成任意内核内存读取。

更多内存可以参见章节 3.1.2。

竞态条件

如果有两个或两个以上执行者将要执行某一动作并且执行结果会由于它们执行顺序的不同而完全不同时,也就是发生了竞争条件。避免竞争条件的方法有很多,例如通过锁、信号量、条件变量等来保证各种行动者之间的同步性。竞争条件中最重要的一点是可竞争窗口的大小,它对于触发竞态条件的难易至关重要,由于这个原因,一些竞态条件的情况只能在对称多处理器(SMP)中被利用。

逻辑 bug

逻辑 bug 有很多种,下面介绍一个引用计数器溢出。我们知道共享资源都有一个引用计数,并在计数为零时释放掉资源,保持足够的内存空间。操作系统往往提供 get 和 put/drop 这样的函数来显式地增加和减少引用计数。

看一个 FreeBSD V5.0 的例子:

  1. int fpathconf(td, uap)
  2. struct thread *td;
  3. register struct fpathconf_args *uap;
  4. {
  5. struct file *fp;
  6. struct vnode *vp;
  7. int error;
  8. if ((error = fget(td, uap->fd, &fp)) != 0) [1]
  9. return (error);
  10. [...]
  11. switch (fp->f_type) {
  12. case DTYPE_PIPE:
  13. case DTYPE_SOCKET:
  14. if (uap->name != _PC_PIPE_BUF)
  15. return (EINVAL); [2]
  16. p->p_retval[0] = PIPE_BUF;
  17. error = 0;
  18. break;
  19. [...]
  20. out:
  21. fdrop(fp, td); [3]
  22. return (error);
  23. }

fpathconf() 系统调用用于获取一个特定的开放的文件描述符信息。所以该调用开头 [1] 处通过 fget() 获取该文件描述符结构的引用,然后在退出的时候 [3] 处通过 fdrop() 释放该引用。然而在 [2] 处的代码没有释放相关的引用计数就直接返回了。如果多次调用 fpathconf() 并触发 [2] 处的返回,则有可能导致引用计数器的溢出。

内核利用方法

参考资料