从Chrome渲染器代码执行到内核:利用MSG_OOB漏洞的完整剖析

本文详细分析了Linux内核中MSG_OOB特性在AF_UNIX流套接字中的安全漏洞(CVE-2025-38236),探讨了从Chrome渲染器沙箱中利用该漏洞实现内核权限提升的全过程,包括漏洞触发、堆喷、任意读/写原语构建及最终内核控制的技术细节。

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

引言

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

该漏洞很容易触发,以下序列会导致UAF:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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桌面渲染器沙箱内部实际利用此类漏洞的难度,即直接从渲染器中的本地代码执行提升到内核权限。即使漏洞可访问,找到有用的堆对象重分配、延迟注入等原语又有多难?

利用代码已发布在我们的bugtracker上;在阅读本文时你可能需要参考它。

背景:特性

2021年通过提交314001f0bf92(“af_unix: Add OOB support”,落地于Linux 5.15)添加了对AF_UNIX流套接字使用MSG_OOB的支持。通过此特性,可以发送单个字节的"带外"数据,接收方可以提前读取该数据。该特性非常有限——带外数据始终是单个字节,且一次只能有一个待处理的带外数据字节(连续发送两个带外消息会导致第一个变为正常的带内消息)。除Oracle产品外,此特性几乎无处使用,如2024年一封提议移除该特性的电子邮件线程所述;然而,当内核配置中启用AF_UNIX套接字支持时,它默认启用,甚至在2024年12月提交5155cbcdbf03(“af_unix: Add a prompt to CONFIG_AF_UNIX_OOB”)落地之前无法禁用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)(在unix_stream_recv_urg()中实现)接收OOB消息时,相应的套接字缓冲区仍留在->sk_receive_queue上,但其consumed字段递增,导致其剩余长度(unix_skb_len())变为0,且->oob_skb指针被清除;正常接收路径在遇到剩余长度为0的SKB时必须处理此情况。

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

背景:漏洞及其成因

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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实例)报告在以下场景中发生use-after-free,如修复syzbot报告问题的提交所述:

  1. send(MSG_OOB)
  2. recv(MSG_OOB) -> 消耗的OOB保留在接收队列中
  3. send(MSG_OOB)
  4. recv() -> manage_oob()返回消耗的OOB的下一个skb -> 这也是OOB,但unix_sk(sk)->oob_skb未清除
  5. recv(MSG_OOB) -> unix_sk(sk)->oob_skb被使用但已释放

换句话说,问题是当接收队列如下所示(最旧消息在顶部): SKB 1: unix_skb_len()=0 SKB 2: unix_skb_len()=1 <–OOB指针

且发生正常recv()时,manage_oob()走!unix_skb_len(skb)分支,该分支删除剩余长度为0的SKB并跳转到后续SKB;但它然后不经过skb == u->oob_skb检查,否则会清除->oob_skb指针,从而在SKB被正常接收路径消耗时创建悬空指针,导致后续recv(… MSG_OOB)上的UAF。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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))循环条件应始终为false;但当意外出现剩余长度为0的SKB时,条件变为0 >= 0,循环跳到第一个剩余长度不为0的SKB。该SKB可能是->oob_skb;在这种情况下,这再次绕过了manage_oob()中应在当前->oob_skb被释放前将->oob_skb设置为NULL的逻辑。

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

1
2
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指针

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

初始原语

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

unix_stream_recv_urg()执行以下操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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,这可以重复;仅当

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计