原文:https://github.com/Cryptogenic/Exploit-Writeups/blob/master/FreeBSD/PS4%204.55%20BPF%20Race%20Condition%20Kernel%20Exploit%20Writeup.md

提示:虽然这个漏洞主要是针对PS4平台的,但如果攻击者对/dev/bpf具有读/写权限,或者能够从root权限升级为内核模式代码执行权限的话,它也同样适用于尚未修复该漏洞的其他FreeBSD平台。因此,这里将本文发布到了本网站的“FreeBSD”目录中,而不是“PS4”目录下。

引言


首先需要说明的是,这个漏洞是由qwerty发现的。不过,由于该漏洞的利用方式相当独特,所以,我打算专门写一篇文章来详细介绍其运行机制。与该漏洞相关的完整源代码可以从这里找到。同时,之前我也介绍过如何在具有用户空间访问权限的情况下利用webkit的相关漏洞,具体请参考https://github.com/Cryptogenic/Exploit-Writeups/blob/master/WebKit/setAttributeNodeNS%20UAF%20Write-up.md。

回顾4.05版本的漏洞利用代码


如果您读过本人撰写的4.05内核漏洞利用文章的话,可能已经注意到,在那篇文章中,关于在获取代码执行权之前是如何设法转储内核的方法被我故意忽略了。同时,在cdev对象之前使用的目标对象,也被我一笔带过了。实际上,这个目标对象就是bpf_d。之所以这么做,是因为当时这个BPF漏洞利用代码尚未公开,并且它还是一个0-day,所以在那篇文章中我故意忽略了这些内容,并且重写了漏洞利用代码,让它使用了一个完全不同的对象(事实证明这样做的效果更好,因为cdev反而更稳定了)。

对于4.05版本来说,BPF是一个很好的目标对象,因为它不仅带有指向代码执行起始位置的函数指针,同时还具有一个获取任意读取原语的方法,这些将在下面详细说明。尽管没有它们也可以利用这个漏洞,但有了它们的话,后面就不必亲自编写实现转储功能的代码了。由于本节内容与4.55版本的漏洞利用的关系不是非常密切,所以我会尽量保持简短;另外,如果读者只对4.55版本的漏洞利用感兴趣的话,可以直接跳过本节内容。

bpf_d对象提供了一些与存储数据的“槽”相关的字段。由于本节内容只涉及旧版漏洞利用,因此这里只介绍与旧版漏洞利用相关的字段。

struct bpf_d {
    // ...
    caddr_t         bd_hbuf;        /* hold slot */ // Offset: 0x18
    // ...
    int             bd_hlen;        /* current length of hold buffer */ // Offset: 0x2C
    // ...
    int             bd_bufsize;     /* absolute length of buffers */ // Offset: 0x30
    // ...
}

这些槽是用于保存某些信息的,准确来说是返回给使用read()函数读取bpf文件描述符的调用方的信息。通过将偏移量为0x18(bd_hbuf)处的变量的值设为待转储内存的地址,并将偏移量为0x2C和0x30处(分别为bd_hlen和bd_bufsize)变量的值设为我们指定的任意大小(为了转储整个内核,可将其设为0x2800000),就可以通过对bpf文件描述符调用read()来获得一个针对任意内核地址的读取原语,从而得以轻松转储内核内存。

是FreeBSD的问题还是索尼的问题?为什么他们都没有……


有趣的是,这个漏洞实际上是FreeBSD的问题,而不是(至少不是直接)由索尼的代码引起的。虽然这是一个FreeBSD的漏洞,但它对于大多数系统来说并不是很有用,因为/dev/bpf设备驱动程序的属主是root用户,并且该程序的权限被设置为0600(表示属主具有读/写权限,而其他任何人都没有相应的权限)——虽然它可用于从root权限升级为内核模式代码执行权限。下面,让我们来看看PS4内核中与/dev/bpf有关的make_dev()(取自4.05内核转储)。

seg000:FFFFFFFFA181F15B                 lea     rdi, unk_FFFFFFFFA2D77640
seg000:FFFFFFFFA181F162                 lea     r9, aBpf        ; "bpf"
seg000:FFFFFFFFA181F169                 mov     esi, 0
seg000:FFFFFFFFA181F16E                 mov     edx, 0
seg000:FFFFFFFFA181F173                 xor     ecx, ecx
seg000:FFFFFFFFA181F175                 mov     r8d, 1B6h
seg000:FFFFFFFFA181F17B                 xor     eax, eax
seg000:FFFFFFFFA181F17D                 mov     cs:qword_FFFFFFFFA34EC770, 0
seg000:FFFFFFFFA181F188                 call    make_dev

我们看到,上述代码会将UID 0(root用户的UID)设为第三个参数(表示属主)对应的寄存器的值。然而,这里的权限位被设置为0x1B6,其对应的八进制值为0666,这就意味着任何人都可以读/写权限打开/dev/bpf文件。我不明白为什么会出现这种情况,根据qwerty的推测,可能是由于bpf用于局域网游戏的缘故。但无论如何,这都是一个糟糕的设计决定,因为bpf通常被认为是有特权的,所以不应该允许不可信的进程(比如WebKit)来访问bpf。在大多数平台上,/dev/bpf文件的权限将设为0x180或0600。

什么是竞争条件


这里的漏洞的类型被称为“竞争条件”。在讨论该漏洞细节之前,读者必须先了解竞争条件是什么以及它们是如何引发安全问题的(特别是在内核中)。在复杂的软件(如内核)中,各种资源通常都是需要进行共享的(或者说是“全局”的)。也就是说,一些线程可能会执行某段访问一些资源代码,与此同时,其他线程已经在访问这些资源。如果一个线程要访问某资源,而另一个线程也在访问该资源,并且没有使用独占访问方式,这时将会发生什么情况?肯定会出现竞争条件现象啊!

所谓竞争条件,是指由于事件发生的顺序与开发人员预期的顺序不符而导致未定义行为的反常现象。在简单的单线程程序中,是不会出现这种问题的,因为执行过程都是线性的。当代码并行运行的时候,这就会成为一个真正的问题。为了防止这些问题,人们引入了原子指令和锁机制。当线程A想要访问关键资源时,需要先请求锁定该资源。如果另一个线程B已经在使用这个资源,那么请求锁定该资源的线程A将进入等待状态,直到线程B释放对该资源的锁定为止。每个线程在用完资源后,都必须释放对资源的锁定,否则就可能导致死锁。

就算引入了锁定机制(如互斥锁),事情也没有想象的那么简单——开发人员还必须得正确使用它们才行。例如,如果一段共享数据已经得到验证和处理,但在对数据进行锁定时,数据还是合法的吗?我们知道,验证和锁定之间存在一个时间窗口,而数据在此期间有可能发生改变,所以尽管开发人员认为数据已经过验证,但数据在验证之后、使用之前可能已经被恶意数据所替换。实际上,并行编程是一项非常有挑战性的任务,特别是作为开发人员,通常不希望在锁定和解锁之间插入太多代码,因为这会影响性能。

有关竞争条件的更多信息,请参阅此处的Microsoft页面。

关于数据包过滤程序


由于该漏洞出现在过滤程序系统中,因此了解包过滤程序的基本知识是非常重要的。过滤程序本质上是一些伪指令集,它们都是通过bpf_filter()进行解析的。虽然伪指令集非常小,但可以用来完成诸如执行基本算术运算和在缓冲区内复制值等操作。在这里,我们不会对BPF VM的进行全面深入的介绍,相反,我们只需知道它生成的代码是以内核模式运行的就可以了——这就是针对/dev/bpf的读/写操作需要具备相应特权的原因。

如果读者对BPF VM的操作码感兴趣的话,请访问http://fxr.watson.org/fxr/source/net/bpf.h?v=FREEBSD90#L995。

界外写原语


下面,让我们看一下bpf_filter()中“STOREX”助记符的处理程序,具体代码如下所示:

u_int32_t mem[BPF_MEMWORDS];
// ...
case BPF_STX:
    mem[pc->k] = X;
    continue;

对于漏洞利用代码开发人员来说,这正是我们最感兴趣的东西。如果我们可以将pc->k设为任意值,就可以创建一个用于堆栈的界外写原语。这对于我们来说是非常有用的,例如,可以使用它来破坏存储在堆栈中的返回指针,这样bpf_filter()返回时,就可以启动一个ROP链了。这种情形是非常理想的,因为该攻击策略不仅动静小,而且也很稳定,因为根本不必担心典型的栈/堆溢出所引发的各种问题。

不幸的是,这些指令需要通过检验后才能运行,因此,在设置pc->k的值的时候,一旦它超出mem边界,就无法通过验证。然而,如果在通过验证之后,使用恶意指令替换原代码的话,会出现什么情况呢? 很明显,这会导致“检查时间和使用时间”(TOCTOU)问题。

竞争,替换


设置过滤程序


如果我们考察一下bpfioctl()的代码,会发现其中有许多的命令,有的用于管理接口,有的用于设置缓冲区属性,当然还有命令用于设置读/写过滤程序(这些命令的清单可以在FreeBSD的手册中找到)。如果我们传递“BIOSETWF”命令(在低层用0x8010427B表示)的话,系统会调用bpf_setf()为给定设备设置过滤程序。

case BIOCSETF:
case BIOCSETFNR:
case BIOCSETWF:
#ifdef COMPAT_FREEBSD32
case BIOCSETF32:
case BIOCSETFNR32:
case BIOCSETWF32:
#endif
    error = bpf_setf(d, (struct bpf_program *)addr, cmd);
    break;

如果仔细考察这些指令被复制到内核后的情况的话,就会发现其后马上就会运行bpf_validate(),这意味着现在还不能让pc->k的值出现越界访问。

// ...

size = flen * sizeof(*fp->bf_insns);
fcode = (struct bpf_insn *)malloc(size, M_BPF, M_WAITOK);

if (copyin((caddr_t)fp->bf_insns, (caddr_t)fcode, size) == 0 && bpf_validate(fcode, (int)flen)) {
    // ...
}

// ...

所有权维护的缺失


前面,我们已经考察了用于设置过滤程序的相关代码,现在,让我们来看看使用过滤程序的那些代码。当进程针对有效的bpf设备调用write()系统调用时,系统就会调用函数bpfwrite()。这一点,可以从bpf的cdevsw结构的函数表看出来:

static struct cdevsw bpf_cdevsw = {
    .d_version =    D_VERSION,
    .d_open =       bpfopen,
    .d_read =       bpfread,
    .d_write =      bpfwrite,
    .d_ioctl =      bpfioctl,
    .d_poll =       bpfpoll,
    .d_name =       "bpf",
    .d_kqfilter =   bpfkqfilter,
};

用户可以通过bpfwrite()函数将数据包写入接口。并且,对于传递给bpfwrite()函数的所有数据包,都需要接受为该接口设置的写入过滤程序的相应检查。而该过滤程序则是通过IOCTL进行设置的,具体详情请见下文。

首先,该过滤程序会进行权限检查(在这里用处不大,因为在PS4上,任何不受信任的进程都可以成功执行写操作,毕竟所有人都有读写该设备的权限),并在调用bpf_movein()之前设置一些缓冲区。

bzero(&dst, sizeof(dst));
m = NULL;
hlen = 0;
error = bpf_movein(uio, (int)d->bd_bif->bif_dlt, ifp, &m, &dst, &hlen, d->bd_wfilter);
if (error) {
    d->bd_wdcount++;
    return (error);
}
d->bd_wfcount++;

下面,我们再来看看bpf_movein()函数。

*mp = m;

if (m->m_len < hlen) {
    error = EPERM;
    goto bad;
}

error = uiomove(mtod(m, u_char *), len, uio);
if (error)
    goto bad;

slen = bpf_filter(wfilter, mtod(m, u_char *), len, len);
if (slen == 0) {
    error = EPERM;
    goto bad;
}

请注意,bpf_movein()中肯定没有实现锁定操作,而且在调用方函数bpfwrite()中也没有实现锁定操作。因此,这个在设备上执行给定过滤程序程序的函数bpf_filter(),完全是在解锁状态下被调用的。此外,bpf_filter()本身也没有执行任何锁定操作。在执行写入过滤程序的过程中,没有对所有权进行相应的维护,实际上,这里根本就没有获取所有权的操作。如果在设置过滤程序时,令其通过bpf_setf()函数检查后用free()函数释放其内存,然后在过滤程序执行时用无效指令重新分配内存的话,将会出现什么情况呢?

通过让三个线程(一个进程会设置一个有效的非恶意过滤程序,另一个进程设置一个无效的恶意过滤程序,还有一个进程尝试连续对bpf结构执行写操作)互相竞争,就可能出现这样的情形(并且该情形很容易被攻击者所利用),那就是有效的指令会被无效指令所替换,这样,我们就可以修改pc->k的值,从而可以在堆栈上实现溢出攻击了。

释放过滤程序


我们需要这样一个函数,要求它在运行的过程中触发UAF漏洞的,同时,还能够利用另一个线程释放该过滤程序。为此,可以查看bpf_setf()函数的代码,请注意,在为过滤程序指令分配新缓冲区之前,该函数首先会检查是否存在旧的缓冲区——如果存在则会将其销毁。

static int bpf_setf(struct bpf_d *d, struct bpf_program *fp, u_long cmd) {
    struct bpf_insn *fcode, *old;

    // ...

    if (cmd == BIOCSETWF) {
        old = d->bd_wfilter;
        wfilter = 1;
        // ...
    } else {
        wfilter = 0;
        old = d->bd_rfilter;
        // ...
    }

    // ...

    if (old != NULL)
        free((caddr_t)old, M_BPF);

    // ...

    fcode = (struct bpf_insn *)malloc(size, M_BPF, M_WAITOK);

    // ...

    if (wfilter)
        d->bd_wfilter = fcode;
    else {
        d->bd_rfilter = fcode;
        // ...
        if (cmd == BIOCSETF)
            reset_d(d);
        }
    }

    // ...
}

因为bpf_filter()复制了一份d->bd_wfilter,所以,当在一个线程中为替换该过滤程序而调用free()函数释放其空间后,第二个线程还在使用相同的指针(现在已经调用过free()函数了),从而导致UAF漏洞。因此,试图设置无效过滤程序的线程实际上在进行堆喷射,并最终分配到相同的地址。我们的三个线程将执行以下操作:

  • 不断地设置含有有效指令的过滤程序,并可以通过验证检查。
  • 不断地设置含有用无效指令的另一个过滤程序,释放旧指令内存空间并用新指令(我们的恶意指令)替换旧指令。
  • 不断地对bpf结构执行写操作。最终,“有效”过滤程序将被无效的过滤程序所破坏,但是这个过程是在过滤程序经过验证检查后发生的,所以write()使用它时就会出现内存损坏错误。通过精心制作的指令,可以覆盖堆栈上的返回地址,这样就能够控制内核模式下的代码执行流程了。

小结


在本文的上篇中,我们介绍了竞争条件的概念,以及数据包过滤程序的相关知识。同时,还介绍了引发竞争条件方法,以及如何替换经过验证的数据的准备知识,在下篇中,我们将为读者进一步介绍利用该内核漏洞的详细方法。

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖