从Chrome渲染器代码执行到内核利用MSG_OOB

访问原始链接 Google 翻译

引言

六月初,我在审查一个新的Linux内核功能时,了解到了面向流的UNIX域套接字支持的MSG_OOB功能。我审查了MSG_OOB的实现,并发现了一个影响Linux >=6.9的安全漏洞(CVE-2025-38236)。我向Linux报告了这个漏洞,并且它得到了修复。有趣的是,虽然Chrome没有使用MSG_OOB功能,但它在Chrome渲染器沙箱中被暴露了出来。(此后,作为对此问题的回应,在Chrome渲染器中发送MSG_OOB消息已被阻止。)

这个漏洞很容易触发;以下操作序列会导致UAF(释放后使用):

char dummy;
int socks[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, socks);
send(socks[1], "A", 1, MSG_OOB);
recv(socks[0], &dummy, 1, MSG_OOB);
send(socks[1], "A", 1, MSG_OOB);
recv(socks[0], &dummy, 1, MSG_OOB);
send(socks[1], "A", 1, MSG_OOB);
recv(socks[0], &dummy, 1, 0);
recv(socks[0], &dummy, 1, MSG_OOB);

我很好奇,在x86-64 Debian Trixie系统上,从Chrome Linux桌面渲染器沙箱内部实际利用这样的漏洞有多困难,即直接从渲染器中的本地代码执行提升权限到内核。即使漏洞可以触及,找到用于堆对象重新分配、延迟注入等有用原语又有多难呢?

漏洞利用代码已发布在我们的bug跟踪器上;在阅读本文时,你可能需要参考它。

背景故事:该功能

2021年,通过提交314001f0bf92(“af_unix:添加OOB支持”,落地于Linux 5.15),添加了对MSG_OOB与AF_UNIX流套接字一起使用的支持。有了这个功能,可以发送一个字节的“带外”数据,接收方可以在其余数据之前读取它。这个功能非常有限——带外数据始终是单个字节,并且一次只能有一个待处理的带外数据字节。(连续发送两个带外消息会导致第一个消息变成正常的带内消息。)正如2024年一封电子邮件线程中所讨论的,除了Oracle产品外,这个功能几乎无处使用,当时有人提议移除该功能;然而,当内核配置中启用AF_UNIX套接字支持时,它默认是启用的,并且在提交5155cbcdbf03(“af_unix:为CONFIG_AF_UNIX_OOB添加提示”)于2024年12月落地之前,甚至无法禁用MSG_OOB支持。

因为Chrome渲染器沙箱允许面向流的UNIX域套接字,并且没有过滤send()/recv()函数的flags参数,所以这个深奥的功能在沙箱内部是可用的。

当消息(由套接字缓冲区/struct sk_buff表示,简称SKB)在两个连接的面向流套接字之间发送时,该消息被添加到接收套接字的->sk_receive_queue中,这是一个链表。一个SKB有一个长度字段->len,描述其中包含的数据长度(既计算SKB“头部缓冲区”中的数据,也计算SKB以其他方式间接引用的数据)。一个SKB还包含一些暂存空间,当前拥有该SKB的子系统可以使用这些空间(struct sk_buff中的char cb[48]);UNIX域套接字使用辅助函数#define UNIXCB(skb) (*(struct unix_skb_parms *)&((skb)->cb))访问此暂存空间,它们在那里存储的内容之一是字段u32 consumed,它存储了已从套接字读取的SKB字节数。UNIX域套接字使用辅助函数unix_skb_len()计算SKB的剩余长度,该函数返回skb->len - UNIXCB(skb).consumed。

MSG_OOB消息(通过类似send(sockfd, &message_byte, 1, MSG_OOB)的方式发送,在内核中经过queue_oob())也会像普通消息一样添加到->sk_receive_queue中;但是为了允许接收套接字在队列其余部分之前访问最新的带外消息,接收套接字的->oob_skb指针会更新以指向此消息。当接收套接字通过类似recv(sockfd, &received_byte, 1, MSG_OOB)的方式接收OOB消息时(在unix_stream_recv_urg()中实现),相应的套接字缓冲区仍保留在->sk_receive_queue上,但其consumed字段会增加,导致其剩余长度(unix_skb_len())变为0,并且->oob_skb指针被清除;正常的接收路径在遇到剩余长度为0的SKB时必须处理这种情况。

这意味着正常的recv()路径(unix_stream_read_generic()),在调用recv()而不带MSG_OOB时运行,必须能够处理剩余长度为0的SKB,并且必须在删除OOB SKB时注意清除->oob_skb指针。manage_oob()应该负责处理这一点。本质上,当正常接收路径从->sk_receive_queue获取SKB时,它会调用manage_oob()来处理处理OOB机制所需的所有修复;manage_oob()随后将返回第一个包含至少1字节剩余数据的SKB,并且manage_oob()确保此SKB不再被引用为->oob_skb。然后unix_stream_read_generic()可以继续进行,就好像OOB机制不存在一样。

背景故事:漏洞及其成因

2024年中,发现了一个用户空间API不一致的问题,当尝试从包含剩余长度为0的SKB(由接收OOB SKB遗留)的接收队列的套接字读取时,recv()可能会虚假地返回0(通常表示文件结束)。针对此问题的修复引入了两个密切相关、可导致UAF的安全问题;它被标记为修复了原始MSG_OOB实现引入的错误,但幸运的是实际上只被反向移植到Linux 6.9.8,因此有问题的修复并未进入较旧的LTS内核分支。

在有问题的修复之后,manage_oob()如下所示:

static struct sk_buff *manage_oob(struct sk_buff *skb, struct sock *sk,
                                  int flags, int copied)
{
        struct unix_sock *u = unix_sk(sk);

        if (!unix_skb_len(skb)) {
                struct sk_buff *unlinked_skb = NULL;

                spin_lock(&sk->sk_receive_queue.lock);

                if (copied) {
                        skb = NULL;
                } else if (flags & MSG_PEEK) {
                        skb = skb_peek_next(skb, &sk->sk_receive_queue);
                } else {
                        unlinked_skb = skb;
                        skb = skb_peek_next(skb, &sk->sk_receive_queue);
                        __skb_unlink(unlinked_skb, &sk->sk_receive_queue);
                }

                spin_unlock(&sk->sk_receive_queue.lock);

                consume_skb(unlinked_skb);
        } else {
                struct sk_buff *unlinked_skb = NULL;

                spin_lock(&sk->sk_receive_queue.lock);

                if (skb == u->oob_skb) {
                        if (copied) {
                                skb = NULL;
                        } else if (!(flags & MSG_PEEK)) {
                                if (sock_flag(sk, SOCK_URGINLINE)) {
                                        WRITE_ONCE(u->oob_skb, NULL);
                                        consume_skb(skb);
                                } else {
                                        __skb_unlink(skb, &sk->sk_receive_queue);
                                        WRITE_ONCE(u->oob_skb, NULL);
                                        unlinked_skb = skb;
                                        skb = skb_peek(&sk->sk_receive_queue);
                                }
                        } else if (!sock_flag(sk, SOCK_URGINLINE)) {
                                skb = skb_peek_next(skb, &sk->sk_receive_queue);
                        }
                }

                spin_unlock(&sk->sk_receive_queue.lock);

                if (unlinked_skb) {
                        WARN_ON_ONCE(skb_unref(unlinked_skb));
                        kfree_skb(unlinked_skb);
                }
        }
        return skb;
}

在此更改之后,syzbot(由Google运营的公共syzkaller实例)报告了在以下场景中发生释放后使用,如针对syzbot报告问题的修复提交所述:

  1. send(MSG_OOB)
  2. recv(MSG_OOB)
     -> The consumed OOB remains in recv queue
  3. send(MSG_OOB)
  4. recv()
     -> manage_oob() returns the next skb of the consumed OOB
     -> This is also OOB, but unix_sk(sk)->oob_skb is not cleared
  5. recv(MSG_OOB)
     -> unix_sk(sk)->oob_skb is used but already freed

换句话说,问题是当接收队列如下所示时(最旧的消息显示在顶部):

  • SKB 1:unix_skb_len()=0
  • SKB 2:unix_skb_len()=1 <--OOB pointer

并且发生正常的recv()时,manage_oob()会走!unix_skb_len(skb)分支,该分支删除剩余长度为0的SKB并跳转到下一个SKB;但它随后不会像通常那样经过skb == u->oob_skb检查,这意味着在SKB被正常接收路径消耗之前,它没有清除->oob_skb指针,从而创建了一个悬空指针,将在后续的recv(... MSG_OOB)上导致UAF。

这个问题得到了修复,使manage_oob()中对剩余长度为0的SKB和->oob_skb的检查独立:

static struct sk_buff *manage_oob(struct sk_buff *skb, struct sock *sk,
                                  int flags, int copied)
{
        struct sk_buff *read_skb = NULL, *unread_skb = NULL;
        struct unix_sock *u = unix_sk(sk);

        if (likely(unix_skb_len(skb) && skb != READ_ONCE(u->oob_skb)))
                return skb;

        spin_lock(&sk->sk_receive_queue.lock);

        if (!unix_skb_len(skb)) {
                if (copied && (!u->oob_skb || skb == u->oob_skb)) {
                        skb = NULL;
                } else if (flags & MSG_PEEK) {
                        skb = skb_peek_next(skb, &sk->sk_receive_queue);
                } else {
                        read_skb = skb;
                        skb = skb_peek_next(skb, &sk->sk_receive_queue);
                        __skb_unlink(read_skb, &sk->sk_receive_queue);
                }

                if (!skb)
                        goto unlock;
        }

        if (skb != u->oob_skb)
                goto unlock;

        if (copied) {
                skb = NULL;
        } else if (!(flags & MSG_PEEK)) {
                WRITE_ONCE(u->oob_skb, NULL);

                if (!sock_flag(sk, SOCK_URGINLINE)) {
                        __skb_unlink(skb, &sk->sk_receive_queue);
                        unread_skb = skb;
                        skb = skb_peek(&sk->sk_receive_queue);
                }
        } else if (!sock_flag(sk, SOCK_URGINLINE)) {
                skb = skb_peek_next(skb, &sk->sk_receive_queue);
        }

unlock:
        spin_unlock(&sk->sk_receive_queue.lock);

        consume_skb(read_skb);
        kfree_skb(unread_skb);

        return skb;
}

但剩下的一个问题是,当此函数发现由recv(..., MSG_OOB)遗留的剩余长度为0的SKB时,它会跳转到下一个SKB并假设它也不是剩余长度为0的SKB。如果这个假设被打破,manage_oob()可以返回指向第二个剩余长度为0的SKB的指针,这很糟糕,因为调用者unix_stream_read_generic()不希望看到剩余长度为0的SKB:

static int unix_stream_read_generic(struct unix_stream_read_state *state,
                                    bool freezable)
{
[...]
        int flags = state->flags;
[...]
        int skip;
[...]
        skip = max(sk_peek_offset(sk, flags), 0); // 0 if MSG_PEEK isn't set

        do {
                struct sk_buff *skb, *last;
[...]
                last = skb = skb_peek(&sk->sk_receive_queue);
                last_len = last ? last->len : 0;

again:
#if IS_ENABLED(CONFIG_AF_UNIX_OOB)
                if (skb) {
                        skb = manage_oob(skb, sk, flags, copied);
                        if (!skb && copied) {
                                unix_state_unlock(sk);
                                break;
                        }
                }
#endif
                if (skb == NULL) {
[...]
                }

                while (skip >= unix_skb_len(skb)) {
                        skip -= unix_skb_len(skb);
                        last = skb;
                        last_len = skb->len;
                        skb = skb_peek_next(skb, &sk->sk_receive_queue);
                        if (!skb)
                                goto again;
                }
[...]
                /* Mark read part of skb as used */
                if (!(flags & MSG_PEEK)) {
                        UNIXCB(skb).consumed += chunk;
[...]
                        if (unix_skb_len(skb))
                                break;

                        skb_unlink(skb, &sk->sk_receive_queue);
                        consume_skb(skb); // frees the SKB

                        if (scm.fp)
                                break;
                } else {

如果MSG_PEEK未设置(这是SKB实际上可以被释放的唯一情况),skip始终为0,并且while (skip >= unix_skb_len(skb))循环条件应始终为假;但是当剩余长度为0的SKB意外到达这里时,条件变为0 >= 0,循环会跳转到第一个剩余长度不为0的SKB。那个SKB可能是->oob_skb;在这种情况下,这再次绕过了manage_oob()中的逻辑,该逻辑本应在当前->oob_skb被释放之前将->oob_skb设置为NULL。

因此,剩余的漏洞可以通过首先执行以下操作两次来触发,在->sk_receive_queue中创建两个剩余长度为0的SKB:

send(socks[1], "A", 1, MSG_OOB);
recv(socks[0], &dummy, 1, MSG_OOB);

如果随后使用send(socks[1], "A", 1, MSG_OOB)发送另一个OOB SKB,->sk_receive_queue将如下所示:

  • SKB 1:unix_skb_len()=0
  • SKB 2:unix_skb_len()=0
  • SKB 3:unix_skb_len()=1 <--OOB pointer

现在,recv(socks[0], &dummy, 1, 0)将触发漏洞并释放SKB 3,同时让->oob_skb指向它;使得后续带有MSG_OOB的recv()系统调用能够使用悬空指针。

初始原语

这个漏洞产生一个悬空的->msg_oob指针。使用这个悬空指针的唯一方式几乎就是带有MSG_OOB的recv()系统调用,无论是否带有MSG_PEEK,这是在unix_stream_recv_urg()中实现的。(还有其他代码路径会触及它,但它们大多只是指针比较,除了SIOCATMARK的unix_ioctl()处理程序,这在Chrome的seccomp沙箱中被阻止了。)

unix_stream_recv_urg()执行以下操作:

static int unix_stream_recv_urg(struct unix_stream_read_state *state)
{
        struct socket *sock = state->socket;
        struct sock *sk = sock->sk;
        struct unix_sock *u = unix_sk(sk);
        int chunk = 1;
        struct sk_buff *oob_skb;

        mutex_lock(&u->iolock);
        unix_state_lock(sk);
        spin_lock(&sk->sk_receive_queue.lock);

        if (sock_flag(sk, SOCK_URGINLINE) || !u->oob_skb) {
[...]
        }

        // read dangling pointer
        oob_skb = u->oob_skb;

        if (!(state->flags & MSG_PEEK))
                WRITE_ONCE(u->oob_skb, NULL);

        spin_unlock(&sk->sk_receive_queue.lock);
        unix_state_unlock(sk);

        // read primitive
        // ->recv_actor() is unix_stream_read_actor()
        chunk = state->recv_actor(oob_skb, 0, chunk, state);

        if (!(state->flags & MSG_PEEK))
                UNIXCB(oob_skb).consumed += 1; // write primitive

        mutex_unlock(&u->iolock);

        if (chunk < 0)
                return -EFAULT;

        state->msg->msg_flags |= MSG_OOB;
        return 1;
}

在高层次上,对state->recv_actor()的调用(沿着调用路径unix_stream_read_actor -> skb_copy_datagram_msg -> skb_copy_datagram_iter -> __skb_datagram_iter(cb=simple_copy_to_iter))提供了一个读取原语:它试图将oob_skb引用的一个字节数据复制到用户空间,因此通过将oob_skb指向的内存替换为受控的、可重复写入的数据,可以重复导致copy_to_user(, , 1)使用任意内核指针。只要MSG_PEEK被设置,这就可以重复;只有当MSG_PEEK被清除时,->msg_oob指针才会被清除。

这个漏洞产生的唯一写入原语是当MSG_PEEK未设置时发生的增量UNIXCB(oob_skb).consumed += 1。在我查看的构建中,被增量的consumed字段位于oob_skb的0x44字节处,这个对象实际上是以0x100字节对齐分配的。这意味着,如果写入原语应用于64位长度值或指针,它将必须在相对于8字节对齐的覆盖目标偏移4处进行增量,并且它将有效地将64位指针/长度增加4 GiB。

我对此问题的漏洞利用

被丢弃的使用写入原语的策略:指针增量

可以释放sk_buff并将其重新分配为某个在偏移0x40处包含指针的结构。写入原语将有效地将此指针增加4 GiB(因为它会在指针偏移4字节处增加1)。但这从根本上依赖于机器拥有显著超过4 GiB的RAM,这感觉很糟糕,有点像作弊。

总体策略

由于这个问题相对直接地导致了一个半任意的读取(受制于usercopy加固限制),但写入原语要棘手得多,我决定采用一般方法:首先让读取原语工作;然后使用读取原语来辅助利用写入原语。这样,理想情况下,在读取原语引导之后的一切都可以通过足够的工作变得可靠。

处理每CPU状态

这个漏洞利用中的许多内容依赖于每CPU内核数据结构,如果在错误的时间任务在CPU之间迁移,将会失败。在漏洞利用的某些地方,我反复使用sched_getcpu()检查漏洞利用运行在哪个CPU上,如果CPU编号发生变化则重试;尽管我懒得在所有地方都完美地做到这一点,并且通过更直接地依赖“可重启序列”子系统可以做得更好。

请注意,Chrome沙箱策略禁止__NR_getcpu;但这对sched_getcpu()完全没有影响,特别是在x86-64上,因为glibc更喜欢使用两种比getcpu()系统调用更快的替代方案:

  • 内核的rseq子系统为每个线程在用户空间维护一个struct rseq,其中包含线程当前正在运行的cpu_id;如果rseq可用,glibc将从rseq结构读取。
  • 在x86-64上,vDSO包含getcpu()系统调用的纯用户空间实现,它依赖于RDPID指令或,如果该指令不可用,则依赖于LSL指令来确定当前CPU的ID,而无需执行系统调用。(这在内核源代码的vdso_read_cpunode()中实现,该代码被编译到映射到用户空间的vDSO中。)

设置读取原语 - 主要是无聊的喷洒

在目标Debian内核上,struct sk_buff位于skbuff_head_cache SLUB缓存中,该缓存通常使用order-1不可移动页面。我很难找到也使用order-1页面的良好重新分配原语(尽管maple_node可能是一个选项);所以我选择了重新分配为管道页面(order-0不可移动),尽管这意味着重新分配将通过伙伴分配器进行,并且要求order-0不可移动列表变空,以便拆分order-1页面。

这并不新颖,所以我将只在此描述策略的一些有趣方面 - 如果你想更好地理解如何释放SLUB页面并将其重新分配为其他东西,有很多现有的文章,包括我前段时间写的一篇(“攻击阶段:将对象的页面释放到页面分配器”部分),尽管那篇没有讨论伙伴分配器。

为了使order-1页面重新分配为order-0页面更可能成功,漏洞利用首先分配大量order-0不可移动页面以耗尽order-0和order-1不可移动空闲列表。在沙箱中,分配大量内核内存的大多数方式都受到限制;特别是,Debian上的默认文件描述符表大小软限制(RLIMIT_NOFILE)是4096(Chrome保持此限制不变),我既不能使用setrlimit()来增加这个数字(由于seccomp),也不能创建具有单独文件描述符表的子进程。(真正的漏洞利用可能能够通过利用多个渲染器进程来解决这个问题,尽管这似乎很麻烦。)我拥有的用于分配大量不可移动页面的一个原语是页表:通过创建一个巨大的匿名VMA(只读以避免触及Chrome的RLIMIT_DATA限制),然后在整个VMA上触发读取错误,可以分配无限数量的页表。我使用这个方法来喷洒大约总RAM的10%的页表。(为了弄清楚机器有多少RAM,我测试mmap()是否适用于不同的大小,依赖于__vm_enough_memory()的OVERCOMMIT_GUESS行为;但由于RLIMIT_DATA限制,这在沙箱中实际上并不精确。一个更干净、噪音更小的方法可能是实际填满RAM,并使用mincore()来弄清楚工作集在页面被换出或丢弃之前可以达到多大。)

之后,我创建41个UNIX域套接字,并使用它们每个喷洒256个SKB分配;由于每个SKB使用0x100字节,这分配了略超过2.5 MiB的内核内存。这足以稍后将一个slab页面从SLUB的每CPU部分列表以及页面分配器的每CPU空闲列表中刷新出来,一直进入伙伴分配器。

然后我设置一个包含悬空指针的SLUB页面,尝试将此页面一直刷新到伙伴分配器中,并通过使用256个管道每个分配2个页面(这是管道始终拥有的最小大小,参见PIPE_MIN_DEF_BUFFERS)将其重新分配为管道页面。这分配了25624KiB = 2 MiB的order-0页面。

此时,我可能已将SKB重新分配为管道页面;但我不知道SKB位于哪个管道中,或者在哪个偏移处。为了弄清楚这一点,我在管道页面中存储指向不同数据的假SKB;然后,通过使用recv(..., MSG_OOB|MSG_PEEK)触发漏洞,我可以读取指向位置的一个字节,并缩小SKB在哪个管道中的位置。我还不知道任何内核对象的地址;但copy_to_user()的X86-64实现是对称的,如果你传递用户空间指针作为源,它也可以工作,所以我现在可以简单地在精心制作的SKB中使用用户空间数据指针。(SMAP在这里不是问题 - 在copy_to_user()中,所有内存访问都禁用了SMAP。在x86-64上,copy_to_user()实际上是围绕copy_user_generic()的包装器实现,这是一个接受内核和用户空间地址作为源和目标的辅助函数。)

之后,我能够通过受控的SKB使用recv(..., MSG_OOB|MSG_PEEK)对任意内核指针调用copy_to_user(..., 1)。

读取原语的特性

在x86-64上,基于copy_to_user()的读取原语的一个非常酷的方面是,即使在无效的内核指针上调用它也不会崩溃 - 如果内核内存访问失败,recv()系统调用将简单地返回一个错误(-EFAULT)。

主要限制是usercopy加固(__check_object_size())会捕获尝试从某些特定内存范围读取的操作:

  • 环绕的范围 - 这里不是问题,反正只能使用长度为1的范围。
  • 地址<=16 - 这里不是问题。
  • 当前进程的内核栈,如果满足某些其他条件。这里不是问题 - 即使我想从内核栈读取,我可能也想读取另一个线程的内核栈,这不受保护。
  • 内核.text部分 - 所有.data等都是可访问的,只有.text受到限制。当针对特定的内核构建时,这并不真正相关。
  • kmap()映射 - 这些在x86-64上不存在。
  • 已释放的vmalloc分配,或跨越vmalloc分配边界的范围。这里不是问题。
  • 直接映射或内核映像地址范围中跨越高阶folio边界的范围。这里不是问题,反正只能使用长度为1的范围。
  • 直接映射或内核映像地址范围中用作非kmalloc slab缓存中的SLUB页面的范围,位于usercopy允许列表不允许的偏移处(参见__check_heap_object())。这是最烦人的部分。

(可能还有其他使用此漏洞读取内存的方式,具有不同的约束,例如使用__skb_datagram_iter()中的frag_iter->len读取来影响随后从中读取已知数据的偏移,但这似乎很麻烦。)

定位内核映像

此时,为了打破内核映像的KASLR,有很多选项,部分得益于copy_to_user()在访问无效地址时不会崩溃;但一个不错的选择是通过只读IDT映射在固定地址0xfffffe0000000000(CPU_ENTRY_AREA_RO_IDT_VADDR)读取中断描述符表(IDT)条目,这将产生内核中断处理程序的地址。

使用读取原语观察分配器状态和其他内容

从这里开始,我的目标是使用读取原语来辅助利用写入原语;我希望能够回答以下问题:

  • struct page */struct ptdesc */struct slab *与直接映射中相应区域之间的映射是什么?(这很容易,只需要从.data/.bss部分读取一些全局变量。)
  • 下一个sk_buff分配将在哪个地址?
  • 这个特定页面的当前状态是什么?
  • 我的页表位于何处,给定的虚拟地址映射到哪个物理地址?

因为usercopy加固阻止访问专用slab中的对象,所以读取struct kmem_cache的内容是不可能的,因为kmem_cache是从不允许usercopy的专用slab类型分配的。但有许多重要的内核内存片段是可读的,因此可以解决这个问题:

  • 内核.data/.bss部分,其中包含诸如指向kmem_cache实例的指针。
  • vmemmap区域,其中包含所有struct page/struct folio/struct ptdesc/struct slab的实例(这些类型共同有效地形成了一个union),描述了每个页面的状态。这些还包含诸如SLUB空闲列表头指针;指向与给定SLUB页面关联的kmem_cache的指针;或将所有进程的根页表连接在一起的内联链表元素。
  • 其他线程的内核栈(位于vmalloc内存中)。
  • 每CPU内存分配(位于vmalloc内存中),特别是用于SLUB和页面分配器中的内存分配快速路径;以及描述每CPU内存范围位于何处的元数据。
  • 页表。

因此,要观察给定slab缓存的SLUB分配器状态,可以首先从内核.data/.bss部分读取相应的kmem_cache*,然后扫描所有每CPU内存中看起来像struct kmem_cache_cpu的对象(带有struct slab 和一个指向相应直接映射范围的空闲列表指针),并检查struct slab的kmem_cache指向哪个kmem_cache,以确定kmem_cache_cpu是否用于正确的slab缓存。之后,读取原语可用于从struct kmem_cache_cpu中读取slab缓存的每CPU空闲列表头指针。

要观察struct page/struct slab/…的状态,读取原语可用于简单地读取页面的引用计数和映射计数(其中包含类型信息)。这使得可以观察诸如“这个页面是否已被释放,还是仍然分配”以及“这个页面已被重新分配为什么类型的页面”之类的事情。

要定位当前进程的页表根,同样不可能直接通过mm_struct,因为它是从不允许usercopy的专用slab类型分配的(除了saved_auxv字段)。但解决这个问题的一种方法是改为遍历所有根页表的全局链表(pgd_list),该链表将其元素存储在struct ptdesc内部,并搜索一个struct ptdesc,其pt_mm字段指向当前进程的mm_struct。这个mm_struct的地址可以从每CPU变量cpu_tlbstate.loaded_mm获得。之后,可以通过读取原语遍历页表。

寻找重新分配目标:CONFIG_RANDOMIZE_KSTACK_OFFSET的魔力

已经丢弃了“将指针增加4 GiB”和“重新分配为maple tree节点”的策略后,我寻找一些其他分配,这些分配会将对象放置在递增地址0x…44处的值会导致良好原语的位置。在那里有一些重要的标志字段,或指定指针数组大小的长度字段,或类似的东西,那会很好。我花了很多时间查看可以从Chrome沙箱内部在内核堆上分配的各种对象类型,但没有找到什么好的。

最终,我意识到我走错了路。显然,尝试以堆对象为目标是很愚蠢的,因为有更好的东西:可以将目标页面重新分配为内核栈的顶部页面!

这最初听起来可能是个愚蠢的想法;但Debian的内核配置启用了CONFIG_RANDOMIZE_KSTACK_OFFSET=y和CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT=y,导致每个系统调用调用将堆栈指针随机向下移动最多0x3f0字节,粒度为0x10字节。这应该是一种安全缓解措施,但当我已经拥有任意读取时,它对我有利:我不必找到距离前一个0x100字节边界0x44字节的覆盖目标,而是实际上只需要找到距离前一个0x10字节边界0x4字节的覆盖目标,然后不断进行系统调用并检查它们执行的堆栈深度,直到我随机幸运地使堆栈落在正确的位置。

考虑到这一点,我开始在堆栈上寻找覆盖目标,深受Seth的漏洞利用的启发,该漏洞利用覆盖了包含在copy_from_user中使用的长度的溢出寄存器。直接以正常的copy_from_user()为目标在这里行不通 - 如果我将copy_from_user()内部使用的64位长度增加4 GiB,那么即使复制由于用户空间故障在中途失败,copy_from_user()也会尝试memset()剩余的内核内存为零。

我发现,在代码路径pipe_write -> copy_page_from_iter -> copy_from_iter上,copy_page_from_iter()的64位长度变量bytes存储在寄存器R14中,该寄存器溢出到copy_from_iter()的堆栈帧中;并且这个堆栈溢出位于我可以破坏的堆栈位置。

当用户空间在管道上调用write()时,内核构造一个迭代器(struct iov_iter),封装传递给write()的用户空间内存范围。(有不同类型的迭代器可以封装单个用户空间范围、一组用户空间范围或各种类型的内核内存。)然后,pipe_write()(在较新的内核中称为anon_pipe_write())本质上运行一个循环,该循环在管道中分配一个新的pipe_buffer槽,将一个新的页面分配放入此管道缓冲区槽,并使用copy_page_from_iter()从iov_iter复制最多一个页面的数据(PAGE_SIZE字节)到管道缓冲区槽的页面。copy_page_from_iter()有效地接收两个长度值:适合调用者提供的页面的字节数(bytes,此处初始设置为PAGE_SIZE)和封装用户空间内存范围的struct iov_iter中可用的字节数(i->count)。实际复制的数据量受两者限制。

如果我设法在copy_from_iter()忙于将数据复制到内核时,将包含bytes的溢出寄存器R14增加4 GiB,那么在copy_from_iter()返回后,copy_page_from_iter()将有效地不再受bytes限制,仅受i->count限制(基于用户空间传递给write()的长度);因此它将进行第二次迭代,该迭代复制到管道缓冲区页面后面的越界内存中。如果用户空间调用write(fd, buf, 0x3000),并且覆盖发生在将用户空间缓冲区的字节0x1000-0x1fff复制到第二个管道缓冲区页面的过程中,那么字节0x2000-0x2fff将被写入第二个管道缓冲区页面后面的越界内存,此时i->count将降至0,终止操作。

将SLUB页面重新分配为堆栈页面,借助arb-read辅助

因此,为了获得在堆栈页面中增量释放后使用值的能力,我再次开始耗尽低阶页面分配器缓存。但这一次,arb-read可用于确定正确页面内偏移处的对象何时位于sk_buff slub缓存的SLUB空闲列表顶部;并且arb-read还可以确定我是否成功分配了整个slab页面价值的对象,没有其他对象混合其中。然后,当将页面从SLUB分配器中刷新出来时,arb-read有助于验证页面是否真的已被释放(其引用计数字段应降至0);之后,页面从页面分配器的每CPU空闲列表中刷新出来。

然后,为了重新分配页面,我运行一个循环,首先分配一个管道页面,然后检查目标页面的引用计数字段。如果目标页面的引用计数上升,我可能找到了目标页面,可以退出循环;否则,我再次释放管道页面,将其重新分配为页表以耗尽页面,然后重试。(直接分配为页表会很麻烦,因为页表具有RCU生命周期,所以一旦页面被分配为页表,就很难重新分配它。由于文件描述符表大小较低,并且每个管道FD对可能只能引用两个页面,因此将耗尽的页面保留在管道缓冲区中可能效果不佳。)

一旦我将目标页面重新分配为管道缓冲区,我再次释放它,然后释放另外三个页面(来自其他辅助管道),然后使用clone()系统调用创建一个新线程。如果一切顺利,clone()将为新内核堆栈分配四个页面:首先是我最后释放的另外三个页面,然后目标页面作为堆栈的最后一页。通过遍历页表,我可以验证目标页面是否真的被重用为目标堆栈的最后一页。

使用写入原语的剩余先决条件

此时,我已经设置了写入原语,可以在特定的堆栈内存位置触发它。写入原语本质上首先读取一些周围的(堆栈)内存(在unix_stream_read_actor()及其被调用者skb_copy_datagram_msg -> skb_copy_datagram_iter中),并期望该内存具有某种结构,然后才递增特定堆栈位置的值。

我也知道我想覆盖哪个堆栈分配。

剩下的问题是:

  1. 我需要确保管道缓冲区页面后面的OOB copy_from_user()将覆盖一些有助于破坏内核的数据。
  2. 我需要能够检测pipe_write()在哪个堆栈深度运行,并根据此决定是重试还是继续触发漏洞。
  3. UAF增量之前的UAF读取需要看到正确类型的数据以避免崩溃。
  4. copy_from_iter()需要花费足够的时间,以允许我递增其堆栈帧中的一个值。

选择OOB覆盖目标

页表在这里有几个很好的特性:

  • 我可以很容易地分配任意多的页表。
  • 我可以很容易地确定内核为我的进程分配的页表的物理和内核虚拟地址(通过使用arb读取遍历页表)。
  • 它们是order-0不可移动分配,就像管道缓冲区一样,因此页面分配器将在相同的2MiB页面块中分配它们。

所以我选择使用OOB copy_from_user()来覆盖页表。

这要求我能够观察我的管道缓冲区页面位于何处;为此,我再次使用SLUB每CPU空闲列表观察技巧,这次是在kmalloc-cg-192 slab缓存上,以弄清楚新创建的管道的pipe_inode_info位于何处。从那里,我可以遍历到管道的pipe_buffer数组,其中包含指向管道使用的页面的指针。

通过能够观察我的页表位于何处以及管道缓冲区页面分配在何处,我可以基本上交替分配页表和管道缓冲区页面,直到得到两个相邻的。

检测pipe_write()堆栈深度

为了通过write()系统调用运行pipe_write(),以便我可以可靠地确定函数在哪个深度运行并决定是否继续破坏,而无需竞争,我可以准备一个管道,使其最初只有一个空闲的pipe_buffer,然后以0x3000的长度调用write()。这将导致pipe_write()首先将0x1000字节存储在最后一个空闲的pipe_buffer槽中,然后等待空间再次可用。从另一个线程,可以通过重复在管道上调用poll()来检测pipe_write()何时使用了最后一个空闲的pipe_buffer槽:当poll()停止报告管道准备好写入(POLLOUT)时,pipe_write()一定已经用完了最后一个空闲的pipe_buffer槽。

此时,我知道内核堆栈的系统调用入口部分不再变化。要检查系统调用是否在特定深度执行,只需使用arb读取检查从x64_sys_call返回到do_syscall_64的返回地址是否在内核堆栈上的预期位置 - 它不能是先前系统调用留下的返回地址,因为存储该返回地址的相同堆栈位置总是在系统调用结束时被后续对syscall_exit_to_user_mode的调用破坏。

如果堆栈随机化是正确的,那么我可以进行更多设置,并通过使用read()清除管道缓冲区条目来恢复pipe_write();否则,我将使用read()清除管道缓冲区条目,让pipe_write()运行完成,然后重试。

让增量原语中的读取看到正确的数据

增量原语发生在此调用图中:

unix_stream_recv_urg
  [read dangling pointer from ->oob_skb]
  unix_stream_read_actor [called as state->recv_actor]
    [UAF read UNIXCB(skb).consumed]
    skb_copy_datagram_msg
      skb_copy_datagram_iter
        __skb_datagram_iter
          skb_headlen
            [UAF read skb->len]
            [UAF read skb->data_len]
          skb_frags_readable
            [UAF read skb->unreadable]
          skb_shinfo [for reading nr_frags]
            skb_end_pointer
              [UAF read skb->head]
              [UAF read skb->end]
          skb_walk_frags
            skb_shinfo [for reading frag_list]
            [forward iteration starting at skb_shinfo(skb)->frag_list along ->next pointers]
  [UAF increment of UNIXCB(oob_skb).consumed]

这里一个有希望的方面是,此代码路径首先进行所有读取;然后它通过skb_walk_frags()遍历攻击者控制的指针的链表;然后它进行写入。skb_walk_frags()定义如下:

#define skb_walk_frags(skb, iter)	\
	for (iter = skb_shinfo(skb)->frag_list; iter; iter = iter->next)

并在__skb_datagram_iter()中这样使用:

	skb_walk_frags(skb, frag_iter) {
		int end;

		WARN_ON(start > offset + len);

		end = start + frag_iter->len;
		if ((copy = end - offset) > 0) {
			if (copy > len)
				copy = len;
			if (__skb_datagram_iter(frag_iter, offset - start,
						to, copy, fault_short, cb, data))
				goto fault;
			if ((len -= copy) == 0)
				return 0;
			offset += copy;
		}
		start = end;
	}

因此,如果我在悬空的->oob_skb指针指向我控制的数据时在UNIX域套接字上运行recv(..., MSG_OOB),并精心制作该假SKB,使其skb_shinfo(skb)->frag_list指向另一个具有->len=0和指向自身的->next指针的假SKB,我可以导致系统调用陷入无限循环。它将一直循环,直到我将->next指针替换为NULL,此时它将仅执行UAF增量。

这是个好消息:我不需要确保堆栈同时包含UAF读取的正确数据和UAF增量的覆盖目标,我可以首先将受控数据放在堆栈上,然后单独将覆盖目标放在堆栈上。

为了将受控数据放在堆栈上,我最初考虑使用select()或poll(),因为我知道这些系统调用将大量数据从用户空间复制到堆栈上;然而,这些系统调用的缺点是立即验证提供的数据,并且很难让它们实际停留在系统调用中,而不是立即因错误而退出系统调用,并在此过程中经常破坏堆栈上的数据数组。最终我发现,在面向数据报的UNIX域套接字上使用sendmsg()效果很好:实现sendmsg()系统调用的___sys_sendmsg()将把msg->msg_name指向的目标地址导入到堆栈缓冲区(struct sockaddr_storage address)中,然后调用特定于协议的->sendmsg处理程序 - 在面向数据报的UNIX域套接字的情况下,是unix_dgram_sendmsg()。此函数粗略验证目标地址的结构(检查它指定了AF_UNIX系列并且不大于struct sockaddr_un),然后等待套接字队列中有空间可用,然后再对目标地址进行任何其他操作。这使得可以将108字节的受控数据放在内核堆栈上,并且该数据将保留在那里,直到系统调用可以继续或在套接字队列中有空间可用或套接字关闭时退出。我实际上需要在堆栈上再多一点数据,但幸运的是struct iovec iovstack[UIO_FASTIOV]直接在address前面,并且iovstack末尾未使用的元素保证被清零,这要归功于CONFIG_INIT_STACK_ALL_ZERO=y,这恰好是我需要的。

能够可靠地等待sendmsg()系统调用进入内核并将目标地址复制到内核堆栈上,然后再检查其堆栈状态,这将很有帮助;幸运的是,这是可能的,通过使用msg->msg_control和msg->msg_controllen提供一个单字节的“控制消息”,这将被忽略,因为它太小,无法成为合法的控制消息,但将在目标地址被复制到堆栈后,在____sys_sendmsg()中被复制到内核堆栈上。可以通过将其指向尚未填充页表条目的用户空间地址,然后在此用户空间地址上