作者:f0rm2l1n@浙江大学AAA战队,team BlockSec
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:paper@seebug.org

最近一段时间,我们团队针对Linux内核的蓝牙栈代码进行了漏洞挖掘。如安全维护者Greg所感慨的,Linux的蓝牙实现是buggy的

seems to be a busy time with people hammering on the bluetooth stack these days...

非常幸运,我们找到了一些品相不错的漏洞,其中有些可以稳定的造成任意代码执行以提升攻击者权限,在本文中,我将介绍其中特别的一位: 蓝色华容道 (CVE-2021-3573)

对于临界区的代码,虽然使用了锁从而看起来很安全,但是错误的锁搭配,就像派关羽去守华容道那样,最终只得不达所期

概述

CVE-2021-3573是一个在蓝牙控制器卸载过程中,由条件竞争 (race condition) 带来的释放后使用漏洞 (use-after-free)。具有 CAP_NET_ADMIN 权限的本地攻击者可以在用户态伪造一个假的控制器,并主动地卸载该控制器以触发这个条件竞争。基于这个UAF,攻击者可以巧妙利用堆喷去覆盖恶意数据,以进一步劫持控制流,完成权限提升。

漏洞细节

既然是 race 造成的 UAF,那我们肯定要研究一条 USING 的线程以及一条 FREEING 的线程。不过在此之前,我们首先看一个系统调用实现,即蓝牙 HCI 套接字的绑定过程,函数 hci_sock_bind()

注: 所有代码片段以内核 v5.12.0 作为参考

static int hci_sock_bind(struct socket *sock, struct sockaddr *addr,
             int addr_len)
{
...
    switch (haddr.hci_channel) {
    case HCI_CHANNEL_RAW:
        if (hci_pi(sk)->hdev) {
            err = -EALREADY;
            goto done;
        }

        if (haddr.hci_dev != HCI_DEV_NONE) {
            hdev = hci_dev_get(haddr.hci_dev);
            if (!hdev) {
                err = -ENODEV;
                goto done;
            }

            atomic_inc(&hdev->promisc);
        }
...
        hci_pi(sk)->hdev = hdev;
...
}

简单来说,函数 hci_sock_bind() 将通过用户传递的参数 haddr 中关键的 hci_dev 索引去寻找特定标号的控制器设备,并通过代码 hci_pi(sk)->hdev = hdev; 在该设备(即对象hdev)与当前套接字之间建立联系。当这个 bind 系统调用完成之后,这个套接字就可以被称为一个绑定过的套接字了(bound socket)。

可以看到,这里取得 hdev 是通过 hci_dev_get 函数,换言之,hdev 通过引用计数进行维护。

USING 线程

一个完成绑定的套接字是允许调用 hci_sock_bound_ioctl() 函数中的命令的,见如下代码

/* Ioctls that require bound socket */
static int hci_sock_bound_ioctl(struct sock *sk, unsigned int cmd,
                unsigned long arg)
{
    struct hci_dev *hdev = hci_pi(sk)->hdev;

    if (!hdev)
        return -EBADFD;
...
    switch (cmd) {
...

    case HCIGETCONNINFO:
        return hci_get_conn_info(hdev, (void __user *)arg);

    case HCIGETAUTHINFO:
        return hci_get_auth_info(hdev, (void __user *)arg);

    case HCIBLOCKADDR:
        if (!capable(CAP_NET_ADMIN))
            return -EPERM;
        return hci_sock_blacklist_add(hdev, (void __user *)arg);

    case HCIUNBLOCKADDR:
        if (!capable(CAP_NET_ADMIN))
            return -EPERM;
        return hci_sock_blacklist_del(hdev, (void __user *)arg);
    return -ENOIOCTLCMD;
}

可以看到函数里提供了四个有效的额外命令,分别和访问当前连接的信息、当前连接的认证,以及设备的黑名单相关。这四个命令分别由四个额外的函数来响应 - hci_get_conn_info() - hci_get_auth_info() - hci_sock_blacklist_add() - hci_sock_blacklist_del()

响应函数实际上都最终会去操作 hdev 对象中维护的链表,举个例子,我们可以看黑名单添加函数 hci_sock_blacklist_add()

static int hci_sock_blacklist_add(struct hci_dev *hdev, void __user *arg)
{
    bdaddr_t bdaddr;
    int err;

    if (copy_from_user(&bdaddr, arg, sizeof(bdaddr)))
        return -EFAULT;

    hci_dev_lock(hdev);

    err = hci_bdaddr_list_add(&hdev->blacklist, &bdaddr, BDADDR_BREDR);

    hci_dev_unlock(hdev);

    return err;
}

代码逻辑很简单,其通过 copy_from_usr 去获取用户态提供的一个蓝牙地址,随后会遍历 hdev->blacklist 来决定是否要将该地址插入链表。其他三个函数类似,他们都使用到了 hdev 上相关的数据成员。

FREEING 线程

正常情况下,一个完成绑定的套接字应该通过如下的代码片段来解除其和下层设备 hdev 之间的联系。

static int hci_sock_release(struct socket *sock)
{
    hdev = hci_pi(sk)->hdev;
    if (hdev) {
...
        atomic_dec(&hdev->promisc);
        hci_dev_put(hdev);
    }
...
}

可以看到,这里的操作和 bind 中的操作是非常对称的,看起来也相当的安全。

可是,这里并非唯一一个能解除联系的代码片段。试想现在电脑上运行的蓝牙控制器(就比如市面上买的USB的那种)突然被拔掉,这个时候这些绑定到该设备的套接字怎么办?理论上,下层的代码应该要通知套接字去主动放弃该联系。

负责传达的代码就是 hci_sock_dev_event(),当控制器被移除时,内核会调用到 hci_unregister_dev() 函数,该函数会以 HCI_DEV_UNREG 的形式去调用 hci_sock_dev_event(),见如下代码。

void hci_sock_dev_event(struct hci_dev *hdev, int event)
{
...
    if (event == HCI_DEV_UNREG) {
        struct sock *sk;

        /* Detach sockets from device */
        read_lock(&hci_sk_list.lock);
        sk_for_each(sk, &hci_sk_list.head) {
            bh_lock_sock_nested(sk);
            if (hci_pi(sk)->hdev == hdev) {
                hci_pi(sk)->hdev = NULL; // {1}
                sk->sk_err = EPIPE;
                sk->sk_state = BT_OPEN;
                sk->sk_state_change(sk);

                hci_dev_put(hdev); // {2}
            }
            bh_unlock_sock(sk);
        }
        read_unlock(&hci_sk_list.lock);
    }
}

可以见到,当事件是 HCI_DEV_UNREG 时,该函数会遍历全局的套接字链表 hci_sk_list 并寻找绑定到了正要移除设备的那些套接字(hci_pi(sk)->hdev == hdev)。随后,标记为{1}的代码行会更新套接字结构体并通过{2}代码放弃 hdev 的引用。

hdev 对象的最后引用会在驱动代码调用 hci_free_dev() 时候减少到0,并由 bt_host_release 完成对其内存的回收。

这条不那么常规的 FREEING 线程是很不安全的,事实上,它可以与 USING 线程形成如下的条件竞争。

hci_sock_bound_ioctl thread    |    hci_unregister_dev thread
                               |
                               |
if (!hdev)                     |
    return -EBADFD;            |
                               |
                               |    hci_pi(sk)->hdev = NULL;
                               |    ...
                               |    hci_dev_put(hdev);
                               |    ...
                               |    hci_free_dev(hdev);
// UAF, for example            |
hci_dev_lock(hdev);            |
                               |
                               |

读者可以访问当时OSS上的漏洞描述 (https://www.openwall.com/lists/oss-security/2021/06/08/2) 去查看我准备的POC样例以及UAF KASan捕获时候的栈报告。

漏洞利用

可能已经有读者开始发牢骚了:条件竞争,哼,什么玩意儿。条件竞争漏洞可以说是漏洞里面品相最差的了,即使这一个能构成UAF,但不能稳定触发便是绝对软肋。

好吧,很显然有牢骚的读者并没有去OSS上阅读漏洞描述,实际上,这个条件竞争可以被100%稳定的触发。

如果读者有过CTF经验,那么再仔细读一下代码的话一定可以发现个钟奥妙

static int hci_sock_blacklist_add(struct hci_dev *hdev, void __user *arg)
{
    bdaddr_t bdaddr;
    int err;

    if (copy_from_user(&bdaddr, arg, sizeof(bdaddr))) // {3}
        return -EFAULT;

可以看到{3}标记的代码是使用了copy_from_user()的,那么,只要依靠 userfaultfd 黑魔法,我们可以随心所欲的掌控 USING 的线程挂起时间。

hci_sock_bound_ioctl thread    |    hci_unregister_dev thread
                               |
                               |
if (!hdev)                     |
    return -EBADFD;            |
                               |
copy_from_user()               |
____________________________   |
                               |
                               |    hci_pi(sk)->hdev = NULL;
                               |    ...
    userfaultfd 挂起            |    hci_dev_put(hdev);
                               |    ...
                               |    hci_free_dev(hdev);
____________________________   |
// UAF, for example            |
hci_dev_lock(hdev);            |
                               |
                               |

OK,在可以稳定触发漏洞的基础上,让我们来试着做更多事情吧

实话实说,这是我的首个 0 day 利用,写的过程可以说是感慨万千,不过整体而言,跟做一个CTF内核题的区别不大 另外,如下的利用中使用的USING thread并非是上文讨论的hci_sock_bound_ioctl而是hci_sock_sendmsg,其同样也可以用userfaultfd辅助,就不赘述了

Leaking

想要打穿内核,放到最前面的一个任务便是绕过随机化KASLR,在这一关卡上我是摔了跟斗的,因为当时的我斗气一定想要用另外一个自己发现的OOB read漏洞来泄露指针。

在错过一次过后(主要是泄露的成功率比较低)便还是拨乱反正,就用这一个洞来同时完成泄露以及内存破坏。原理也很简单:我只要让 USING 线程触发到一个 WARNING 或者碰到内核不会挂掉的页错误即可。

如下是一个可用的NPD造成的泄露。

[   17.793908] BUG: kernel NULL pointer dereference, address: 0000000000000000
[   17.794222] #PF: supervisor read access in kernel mode
[   17.794405] #PF: error_code(0x0000) - not-present page
[   17.794637] PGD 0 P4D 0
[   17.794816] Oops: 0000 [#1] SMP NOPTI
[   17.795043] CPU: 0 PID: 119 Comm: exploit Not tainted 5.12.1 #18
[   17.795217] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS rel-1.14.0-0-g155821a1990b-prebuilt.qemu.org 04/01/2014
[   17.795543] RIP: 0010:__queue_work+0xb2/0x3b0
[   17.795728] Code: 8b 03 eb 2f 83 7c 24 04 40 0f 84 ab 01 00 00 49 63 c4 49 8b 9d 08 01 00 00 49 03 1c c6 4c 89 ff e8 73 fb ff ff 48 85 c0 74 d5 <48> 39 030
[   17.796191] RSP: 0018:ffffac4d8021fc20 EFLAGS: 00000086
[   17.796329] RAX: ffff9db3013af400 RBX: 0000000000000000 RCX: 0000000000000000
[   17.796545] RDX: 0000000000000000 RSI: 0000000000000003 RDI: ffffffffbdc4cf10
[   17.796769] RBP: 000000000000000d R08: ffff9db301400040 R09: ffff9db301400000
[   17.796926] R10: 0000000000000000 R11: ffffffffbdc4cf18 R12: 0000000000000000
[   17.797109] R13: ffff9db3021b4c00 R14: ffffffffbdb106a0 R15: ffff9db302260860
[   17.797328] FS:  00007fa9edf9d740(0000) GS:ffff9db33ec00000(0000) knlGS:0000000000000000
[   17.797541] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[   17.797699] CR2: 0000000000000000 CR3: 000000000225c000 CR4: 00000000001006f0
[   17.797939] Call Trace:
[   17.798694]  queue_work_on+0x1b/0x30
[   17.798865]  hci_sock_sendmsg+0x3bc/0x960
[   17.798973]  sock_sendmsg+0x56/0x60
[   17.799081]  sock_write_iter+0x92/0xf0
[   17.799170]  do_iter_readv_writev+0x145/0x1c0
[   17.799303]  do_iter_write+0x7b/0x1a0
[   17.799386]  vfs_writev+0x93/0x160
[   17.799527]  ? hci_sock_bind+0xbe/0x650
[   17.799638]  ? __sys_bind+0x8f/0xe0
[   17.799725]  ? do_writev+0x53/0x120
[   17.799804]  do_writev+0x53/0x120
[   17.799882]  do_syscall_64+0x33/0x40
[   17.799969]  entry_SYSCALL_64_after_hwframe+0x44/0xae
[   17.800186] RIP: 0033:0x7fa9ee08d35d
[   17.800405] Code: 28 89 54 24 1c 48 89 74 24 10 89 7c 24 08 e8 ca 26 f9 ff 8b 54 24 1c 48 8b 74 24 10 41 89 c0 8b 7c 24 08 b8 14 00 00 00 0f 05 <48> 3d 008
[   17.800798] RSP: 002b:00007ffe3c870e00 EFLAGS: 00000293 ORIG_RAX: 0000000000000014
[   17.800969] RAX: ffffffffffffffda RBX: 0000556f50a02f30 RCX: 00007fa9ee08d35d
[   17.801118] RDX: 0000000000000003 RSI: 00007ffe3c870ea0 RDI: 0000000000000005
[   17.801267] RBP: 00007ffe3c870ee0 R08: 0000000000000000 R09: 00007fa9edf87700
[   17.801413] R10: 00007fa9edf879d0 R11: 0000000000000293 R12: 0000556f50a00fe0
[   17.801560] R13: 00007ffe3c870ff0 R14: 0000000000000000 R15: 0000000000000000
[   17.801769] Modules linked in:
[   17.801928] CR2: 0000000000000000
[   17.802233] ---[ end trace 2bbc14e693eb3d8f ]---
[   17.802373] RIP: 0010:__queue_work+0xb2/0x3b0
[   17.802492] Code: 8b 03 eb 2f 83 7c 24 04 40 0f 84 ab 01 00 00 49 63 c4 49 8b 9d 08 01 00 00 49 03 1c c6 4c 89 ff e8 73 fb ff ff 48 85 c0 74 d5 <48> 39 030
[   17.802874] RSP: 0018:ffffac4d8021fc20 EFLAGS: 00000086
[   17.803019] RAX: ffff9db3013af400 RBX: 0000000000000000 RCX: 0000000000000000
[   17.803166] RDX: 0000000000000000 RSI: 0000000000000003 RDI: ffffffffbdc4cf10
[   17.803313] RBP: 000000000000000d R08: ffff9db301400040 R09: ffff9db301400000
[   17.803458] R10: 0000000000000000 R11: ffffffffbdc4cf18 R12: 0000000000000000
[   17.803605] R13: ffff9db3021b4c00 R14: ffffffffbdb106a0 R15: ffff9db302260860
[   17.803753] FS:  00007fa9edf9d740(0000) GS:ffff9db33ec00000(0000) knlGS:0000000000000000
[   17.803921] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[   17.804042] CR2: 0000000000000000 CR3: 000000000225c000 CR4: 00000000001006f0

Wow,可以看到寄存器 RDI, R11 以及 R14 都放着非常可疑的内核地址。通过查看 System.map 我们发现寄存器 R14 正好存放着全局数据对象 __per_cpu_offset 的地址 (调试环境下还没有开启KASLR),那么我们可以通过它来计算 KASLR 的偏移,以绕过随机化保护。

$ cat System.map | grep bdb106a0
ffffffffbdb106a0 R __per_cpu_offset

Exploitation

RIP hijacking

在KASLR绕过之后,下一个目标便是怎样去劫持控制流。为达此目标,一个UAF漏洞最简单的方式就是基于堆喷去覆盖目标对象上的函数指针,这样子,只要这些被覆写的函数指针被用到的时候,便可以完成控制流劫持了。嗯,思路简单直接,而且这件事情看起来相当容易:因为 hdev 对象是 hci_dev 结构体,并由 kmalloc-8k 的缓存进行维护。由于对象的大小已经如此之大,这使得其所在的缓存相当的稳定,我们很简单的就可以通过像 setxattr 这样的方法完成对该目标的占位。

此外,这个结构体的尾巴上实在是有很多可口的函数指针啊

struct hci_dev {
...
    int (*open)(struct hci_dev *hdev);
    int (*close)(struct hci_dev *hdev);
    int (*flush)(struct hci_dev *hdev);
    int (*setup)(struct hci_dev *hdev);
    int (*shutdown)(struct hci_dev *hdev);
    int (*send)(struct hci_dev *hdev, struct sk_buff *skb);
    void (*notify)(struct hci_dev *hdev, unsigned int evt);
    void (*hw_error)(struct hci_dev *hdev, u8 code);
    int (*post_init)(struct hci_dev *hdev);
    int (*set_diag)(struct hci_dev *hdev, bool enable);
    int (*set_bdaddr)(struct hci_dev *hdev, const bdaddr_t *bdaddr);
    void (*cmd_timeout)(struct hci_dev *hdev);
    bool (*prevent_wake)(struct hci_dev *hdev);
};

好的,在假设我们能堆喷并完全覆盖整个 hdev 对象的前提下,我们能完成控制流劫持么?Emmm,事情好像没那么容易,因为 USING 线程的第一现场并没有调用到任何函数指针。

static int hci_sock_sendmsg(struct socket *sock, struct msghdr *msg,
                size_t len)
{
...
    hdev = hci_pi(sk)->hdev;
    if (!hdev) {
        err = -EBADFD;
        goto done;
    }
...
    if (memcpy_from_msg(skb_put(skb, len), msg, len)) {
        err = -EFAULT;
        goto drop;
    }

    hci_skb_pkt_type(skb) = skb->data[0];
    skb_pull(skb, 1);

    if (hci_pi(sk)->channel == HCI_CHANNEL_USER) {
...
    } else if (hci_skb_pkt_type(skb) == HCI_COMMAND_PKT) {
...
        if (ogf == 0x3f) {
            skb_queue_tail(&hdev->raw_q, skb);
            queue_work(hdev->workqueue, &hdev->tx_work); // {4}
        } else {
            /* Stand-alone HCI commands must be flagged as
             * single-command requests.
             */
            bt_cb(skb)->hci.req_flags |= HCI_REQ_START;

            skb_queue_tail(&hdev->cmd_q, skb);
            queue_work(hdev->workqueue, &hdev->cmd_work); // {5}
        }
    } else {
        if (!capable(CAP_NET_RAW)) {
            err = -EPERM;
            goto drop;
        }

        skb_queue_tail(&hdev->raw_q, skb);
        queue_work(hdev->workqueue, &hdev->tx_work); // {4}
    }
...
}

整个 hci_sock_sendmsg() 函数做的事情就是去用户态拿到要发送的数据包,并根据数据包的类型去决定要将 cmd_work 还是 tx_work 放入工作队列。

诶?工作队列?虽然不是直接的函数调用,这也是和控制流相关的逻辑啊。可能老师傅们已经悟到,可以通过覆盖 hdev->cmd_work 或者 hdev->tx_work 来完成控制流劫持了。实际上,相关的 work_struct 中确实存在可口的函数指针。

typedef void (*work_func_t)(struct work_struct *work);

struct work_struct {
    atomic_long_t data;
    struct list_head entry;
    work_func_t func;
#ifdef CONFIG_LOCKDEP
    struct lockdep_map lockdep_map;
#endif
};

即成员 work_func_t func。由于我们可以覆盖完整的整个 hdev 对象,去把这几个 work_struct 改掉看起来也只是小菜一碟哈。

只不过,我又错了。

仅仅去覆盖掉 work_struct 是没有用的,因为 queue_work() 必须要求一个合法的工作队列来承载这个要被调度的工作。即我们需要一个合法的 hdev->workqueue 才能完成上述的攻击。

这有可能可以做到么?workqueuehdev下的一个指针对象,如果我们能将其改写成一个已知的而且指向合法的工作队列的指针的话,事情就可以顺利进行。

虽然听起来合理,但这个方案难度是很大的。因为 workqueue_struct 并非是全局的数据结构,而是在 hdev 对象注册时候被动态创建的,位于内核的SLUB堆上。即使在前文我们已经完成了对于KASLR的泄露绕过,但我们并没有任意读能力,因此想泄露出一个合法工作队列所在的堆地址这一方案实在是黄粱美梦。

当然,安全研究者永不言败,如果没法覆盖一个新的工作队列指针上去,那我们就想办法用旧的吧!hdev对象在堆喷覆盖之前,其workqueue成员指向的是已经在hci_unregister_dev()中被释放掉的一个工作队列,换言之,其指向的是一个被释放了的,kmalloc-512的堆对象。我们可以再次使用堆喷的方式,想办法在该位置上喷射上去一个合法的工作队列。

针对workqueue_struct的喷洒已经是利用中的第二次堆喷了,有趣的是,这一次堆喷并不是要喷我们自己的数据,而是想喷上去一个合法的工作队列结构体。所以堆喷的路径并非大家知道的msg, setxattr。我的做法就是想办法再多创建一些虚拟的蓝牙设备,毕竟每个设备初始化的时候都会创建hdev中的工作队列的。

值得一提,对于workqueue_struct的喷射比之前对于hdev的喷射要困难了许多,这是因为kmalloc-512的对象好像非常“热门”,总是有地方冒出来。在我的利用中,我通过调整设备初始化的顺序来增加喷射的成功率,细节可以见代码。

当这一次喷射成功时,workqueue指针就指向了一个合法的工作队列,而 queue_work 就可以成功将需要被调度的 work_struct 压入工作队列。不过呢,因为在 hdevworkqueuecmd_worktx_work 的前面,所以我们没法在这一步就去覆盖掉 work_structfn 成员。

不过这其实还好,因为将要被调度的 hci_cmd_work 或者 hci_tx_work 都会跑到一个会使用到 hdev 内函数指针的 hci_send_frame 代码内,我们可以在那个时候再搞定控制流。

只不过呢,我又又错了。

因为,这个利用思路非常不稳定:我们没法很好地预测工作队列调度目标 work_struct 的时间,这个时延可能非常短,以至于我根本没有机会让 setxattr 喷上我想要的数据而函数指针就已经被用过了。这些该死的函数指针偏偏放在结构体的末尾,我又偏偏需要hdev中保留的workqueue的值,如下逻辑。

====> overwrite the hdev
+--+-----------+-----+---------+----------+---------+-----+---------------+
   | workqueue | ... | rx_work | cmd_work | tx_work | ... | code pointers |
+--+-----------+-----+---------+----------+---------+-----+---------------+

真是伤脑筋,难道就没有一个比较好的,可以预测的访问函数指针的位置么?

当然,或者说碰巧,是有的。正如老话所言,当上帝把门关上时,他一定会打开一扇窗。我在已有的调用路径上发现了又一个财宝,那就是延时工作: delayed_work

static void hci_cmd_work(struct work_struct *work)
{
    struct hci_dev *hdev = container_of(work, struct hci_dev, cmd_work);
...
        hdev->sent_cmd = skb_clone(skb, GFP_KERNEL);
        if (hdev->sent_cmd) {
            ...
            if (test_bit(HCI_RESET, &hdev->flags))
                cancel_delayed_work(&hdev->cmd_timer);
            else
                schedule_delayed_work(&hdev->cmd_timer,
                              HCI_CMD_TIMEOUT); // {6}
        } else {
            skb_queue_head(&hdev->cmd_q, skb);
            queue_work(hdev->workqueue, &hdev->cmd_work);
        }

{6}标记的代码会为发出去的命令注册一个延迟工作,以处理该命令的回复超时的情况。delayed_work的逻辑其实很work_struct非常想,只不过呢,我们有一段非常可预测的时间窗了

#define HCI_CMD_TIMEOUT     msecs_to_jiffies(2000)  /* 2 seconds */

2秒,看起来非常合适。现在,我们可以让堆喷的setxattr先一直卡着,直到接近2秒的时候再覆盖上我们的恶意数据,这样子就可以保证前文所计划的攻击都能完成,并且我们获得了一个劫持RIP的原语。

ROP

ROP的故事并没有控RIP的故事那样精彩,不过在做栈迁移的时候还是有一些小技巧的。

/* HCI command timer function */
static void hci_cmd_timeout(struct work_struct *work)
{
    struct hci_dev *hdev = container_of(work, struct hci_dev,
                        cmd_timer.work);
...
    if (hdev->cmd_timeout)
        hdev->cmd_timeout(hdev);

在正常的情况下,cmd_timer会唤醒函数hci_cmd_timeout去完成超时处理,我们看到函数内有基于hdev->cmd_timeout的函数指针使用。在该位置劫持控制流后,第一个跳向的gadget一定得想办法将栈迁移到可控的堆上去(最好就是我们覆盖的 hdev 成员)。在内核中找了几遍后,我们却没有找到非常合适的gadget。

比如说,我们经常使用的一个类型的gadget便是直接通过mov去写rsp

For example, the popular one

0xffffffff81e0103f: mov rsp, rax; push r12; ret;

但是,此时我们劫持控制流的代码hdev->cmd_timeout(hdev)其底层实现是__x86_indirect_thunk_rax,也就是说,此时的rax寄存器是刚好指向要跳往的gadget的,一心不可二用,rax此时又怎么能指向堆地址呢?

还有一些经典的通过 xchg 去迁移栈的,只不过那往往是用于 SMAP 保护关闭的情况。我们的目标环境是保护全开的,这类 gadget 也不好用。

迁栈的问题确实困扰了我许久,感谢队内大佬Nop帮助,我们最好找到了一个非常合适的迁栈方法。

首先,我们使用的gadget是

   0xffffffff81060a41 <__efi64_thunk+81>:   mov    rsp,QWORD PTR [rsp+0x18]
   0xffffffff81060a46 <__efi64_thunk+86>:   pop    rbx
   0xffffffff81060a47 <__efi64_thunk+87>:   pop    rbp
   0xffffffff81060a48 <__efi64_thunk+88>:   ret

其会将栈上 rsp + 0x18 位置的值给 RSP 寄存器,那么,接着就是一个关键点,它既能满足控制流劫持,又可以刚好让 [rsp + 0x18] 指向合适的堆地址。

我最后选定的目标是 hci_error_reset,其内部又一个 hdev->hw_error 的调用。而且通过调试,我们发现调用点的栈满足所需,[rsp + 0x18]刚好是指向 hdev 内部的,Perfect!

static void hci_error_reset(struct work_struct *work)
{
    struct hci_dev *hdev = container_of(work, struct hci_dev, error_reset);

    BT_DBG("%s", hdev->name);

    if (hdev->hw_error)
        hdev->hw_error(hdev, hdev->hw_error_code);
    else
        bt_dev_err(hdev, "hardware error 0x%2.2x", hdev->hw_error_code);
...
}

剩下的工作就是大家都熟悉的ROP了,出于只是展示的需要,我实现的ROP其仅仅完成的是对于modprobe_path的修改。代码以及demo开源在github上 https://github.com/f0rm2l1n/ExP1oiT5/tree/main/CVE-2021-3573

感兴趣的读者可以试着写一下更完善的ROP

修复的故事

如果是你,你会怎样修复在这样一个条件竞争的漏洞呢?

当我提交该漏洞时候,我向内核社区提供了一份如下的补丁作为参考。

---
 net/bluetooth/hci_sock.c | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/net/bluetooth/hci_sock.c b/net/bluetooth/hci_sock.c
index 251b9128f530..eed0dd066e12 100644
--- a/net/bluetooth/hci_sock.c
+++ b/net/bluetooth/hci_sock.c
@@ -762,7 +762,7 @@ void hci_sock_dev_event(struct hci_dev *hdev, int event)
        /* Detach sockets from device */
        read_lock(&hci_sk_list.lock);
        sk_for_each(sk, &hci_sk_list.head) {
-           bh_lock_sock_nested(sk);
+           lock_sock(sk);
            if (hci_pi(sk)->hdev == hdev) {
                hci_pi(sk)->hdev = NULL;
                sk->sk_err = EPIPE;
@@ -771,7 +771,7 @@ void hci_sock_dev_event(struct hci_dev *hdev, int event)

                hci_dev_put(hdev);
            }
-           bh_unlock_sock(sk);
+           release_sock(sk);
        }
        read_unlock(&hci_sk_list.lock);
    }
--
2.30.2

从漏洞发现者的角度来看,这个漏洞的根本成因在于有一个特殊的 FREEING 线程,其可能可以在别的 USING 线程(如hci_sock_bound_ioctl and hci_sock_sendmsg)还在使用 hdev 对象时候便将该目标给释放掉。

所以呢,我的补丁通过替换锁来完成对于该 FREEING 线程的堵塞。在打上这个补丁之后,KASAN并不会再有任何的报告,感觉是没啥问题的。

悲哀的是,我又又又错了。

因为我本人并不是非常清楚内核中的同步机制,这里对于锁的替换仅仅是参考相关的 USING 线程,以直觉方式完成的。我并没有去仔细进行锁的分析,以至于我提供的补丁是有可能造成死锁的(我真的不是故意的呜呜呜)

更糟糕的是,内核并没有经过任何犹豫便打上了我提供的补丁。

灾难大概在补丁进入内核主线的一周之后开始初见端倪:我开始收到各种各样的邮件来控诉这个荒唐的补丁。其中最早来的是谷歌的 Anand K. Mistry,他向我展示了在开启 CONFIG_LOCK_DEBUG 后生成的错误报告以及死锁的可能性分析。在他之后,也有越来越多的内核开发者注意到了这条有问题的补丁。其中很大的促进因素是谷歌的模糊测试机器人 syzbot

Also, this regression is currently 7th top crashers for syzbot

这个机器人将触发这个锁错误的测试报告不断发送给蓝牙的维护者(因为实在太好触发了,设备一旦卸载这个错误就会被捕获)。

我实在是羞愧的想挖个洞把自己给埋了。可能你会很好奇,再提交一份正确的修复不就好了么?但悲哀的事实是,这个条件竞争并不好修复,社区中也开展了充足的讨论,读者可以阅读下面的链接去了解该情况。

https://lore.kernel.org/linux-bluetooth/nycvar.YFH.7.76.2107131924280.8253@cbobk.fhfr.pm/ https://www.spinics.net/lists/linux-bluetooth/msg92649.html https://marc.info/?l=linux-bluetooth&m=162441276805113&w=2 https://marc.info/?l=linux-bluetooth&m=162653675414330&w=2

让人欣慰的是,内核大佬 Tetsuo Handa 与蓝牙维护者 Luiz 对这个问题是十分上心的,我相信一份正确的补丁会很快成埃落定的。

== 7月28日更新 ==

Yeah,一份看起来不错的补丁已经发布在了bluetooth-next分支上:

这次连着几天的讨论甚至让我接触到了Linus,心情实在难以平复 :)

结论

这是我的首个Linux内核0 day利用,说真的,这个过程中我真的学到了很多:写漏洞利用真的就是一门艺术。

当然,需要承认的是这个漏洞虽然可以稳定触发,但品相也还是有缺点的:其要求 CAP_NET_ADMIN 权限,所以在野场景下的 fullchain 利用要求攻击者先攻破具有该权限的 daemon 才行。

这是挖掘本地蓝牙栈漏洞的一个固有缺点,因为我们需要在用户态模拟一个假的蓝牙控制器,而这件事情显然不会是零权限的。更好品质的漏洞自然应该像 BleedingTooth 那样在不要求任何点击的情况下,在远程完成代码执行。

我相信这种理想类型的漏洞会是我们的终极目标。


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1653/