3.1.11 Linux 内核漏洞利用

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

    1. $ ./a.out
    2. Big stack: 0x7fffd6b0e400 ~ 0x7fffd6b0e500
    3. Pointer value: 0x7fffd6b0e4f8 => 0x4141414141414141

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

    [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:

    代码的第一部分来自函数 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() 等),数组越界,缓冲区溢出等。

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

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

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

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

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

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

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

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

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

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

    看一个 FreeBSD V5.0 的例子:

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

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