从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报告问题的提交所述:
- send(MSG_OOB)
- recv(MSG_OOB) -> 消耗的OOB保留在接收队列中
- send(MSG_OOB)
- recv() -> manage_oob()返回消耗的OOB的下一个skb -> 这也是OOB,但unix_sk(sk)->oob_skb未清除
- 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,这可以重复;仅当