Project Zero:从Chrome渲染器代码执行到利用MSG_OOB攻击内核
引言
在6月初,我在审查一个新的Linux内核特性时,了解到面向流的UNIX域套接字支持的MSG_OOB特性。我审查了MSG_OOB的实现,发现了一个影响Linux >=6.9的安全漏洞(CVE-2025-38236)。我将该漏洞报告给了Linux,并得到了修复。有趣的是,虽然Chrome不使用MSG_OOB特性,但它在Chrome渲染器沙箱中暴露了此特性。(此后,Chrome渲染器中已阻止发送MSG_OOB消息以应对此问题。)
这个漏洞很容易触发;以下序列会导致UAF:
|
|
我很好奇在x86-64 Debian Trixie系统上,从Chrome Linux桌面渲染器沙箱内部实际利用此类漏洞有多困难,直接从渲染器中的本地代码执行提升权限到内核。即使漏洞可访问,找到用于堆对象重新分配、延迟注入等有用原语有多困难?
利用代码已发布在我们的bugtracker上;在阅读本文时,您可能需要参考它。
背景:特性
对AF_UNIX流套接字使用MSG_OOB的支持是在2021年通过commit 314001f0bf92(“af_unix: Add OOB support”,落地于Linux 5.15)添加的。通过此特性,可以发送一个字节的"带外"数据,接收方可以提前读取该数据,而不受其余数据的影响。该特性非常有限 - 带外数据始终是单个字节,并且一次只能有一个待处理的带外数据字节。(连续发送两个带外消息会导致第一个变为正常的带内消息。)除了Oracle产品外,此特性几乎无处使用,正如2024年一封电子邮件线程中所讨论的,有人提议移除该特性;然而,当内核配置中启用AF_UNIX套接字支持时,默认启用此特性,并且在commit 5155cbcdbf03(“af_unix: Add a prompt to 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)(在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,并且manage_oob()确保此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()如下所示:
|
|
在此更改之后,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检查,这意味着在SKB被正常接收路径消耗之前,它不会清除->oob_skb指针,从而创建一个悬空指针,将在后续recv(… MSG_OOB)时导致UAF。
此问题已修复,使manage_oob()中对剩余长度为0的SKB和->oob_skb的检查独立:
|
|
但一个剩余问题是,当此函数发现由recv(…, MSG_OOB)留下的剩余长度为0的SKB时,它会跳转到下一个SKB并假设它也不是剩余长度为0的SKB。如果此假设被打破,manage_oob()可以返回指向第二个剩余长度为0的SKB的指针,这是不好的,因为调用者unix_stream_read_generic()不希望看到剩余长度为0的SKB:
|
|
如果未设置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:
|
|
如果随后使用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_PEEK的recv()系统调用,该系统调用在unix_stream_recv_urg()中实现。(还有其他代码路径接触它,但它们大多只是指针比较,除了unix_ioctl()处理SIOCATMARK的处理器,该处理器在Chrome的seccomp沙箱中被阻止。)
unix_stream_recv_urg()执行以下操作:
|
|
在高层次上,对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(
此漏洞产生的唯一写入原语是当未设置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,这感觉粗糙且有点像作弊。
总体策略
由于此问题相对直接地导致半任意读取(受用户复制硬化限制),但写入原语更加棘手,我决定采用一般方法:首先使读取原语工作;然后使用读取原语辅助利用写入原语。这样,理想情况下,读取原语引导之后的所有内容都可以通过足够的工作变得可靠。
处理每CPU状态
此利用中的许多内容依赖于每CPU内核数据结构,如果任务在错误的时间在CPU之间迁移,将会失败。在利用中的某些位置,我使用sched_getcpu()重复检查利用运行在哪个CPU上,如果CPU编号更改则重试;虽然我懒得在所有地方完美地执行此操作,并且通过更直接地依赖"可重启序列"子系统可以做得更好。
请注意,Chrome沙箱策略禁止__NR_getcpu;但这对sched_getcpu()完全没有影响,特别是在x86-64上,因为有两个比getcpu()系统调用更快的替代方案,glibc更喜欢使用它们:
- 内核的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不可移动空闲列表。在沙箱中,分配大量内核内存的大多数方式受到限制;特别是,默认文件描述符表大小软限制(RLIMIT_NOFILE)在Debian上为4096(Chrome保留此限制不变),我既不能使用setrlimit()增加此数字(由于seccomp),也不能创建具有单独文件描述符表的子进程。(真正的利用可能能够通过利用多个渲染器进程来解决此问题,尽管这似乎很痛苦。)我分配大量不可移动页面的一个原语是页表:通过创建一个巨大的匿名VMA(只读以避免遇到Chrome的RLIMIT_DATA限制),然后在整个VMA上触发读取错误,可以分配无限数量的页表。我使用此方法用页表垃圾邮件约10%的总RAM。(为了弄清楚机器有多少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不是问题 - SMAP对copy_to_user()中的所有内存访问被禁用。在x86-64上,copy_to_user()实际上作为copy_user_generic()的包装器实现,这是一个辅助函数,接受内核和用户空间地址作为源和目标。)
之后,我能够通过recv(…, MSG_OOB|MSG_PEEK)使用受控SKB对任意内核指针调用copy_to_user(…, 1)。
读取原语的属性
在x86-64上,基于copy_to_user()的读取原语的一个非常酷的方面是,即使在无效的内核指针上调用它也不会崩溃 - 如果内核内存访问失败,recv()系统调用将简单地返回错误(-EFAULT)。
主要限制是用户复制硬化(__check_object_size())将捕获尝试从某些特定内存范围读取:
- 环绕的范围 - 这里不是问题,只能使用长度为1的范围。
- 地址<=16 - 这里不是问题。
- 当前进程的内核堆栈,如果满足某些其他条件。这里不是问题 - 即使我想从内核堆栈读取,我可能也想读取另一个线程的内核堆栈,这不受保护。
- 内核.text部分 - 所有.data和这样的都是可访问的,只有.text受限制。当针对特定内核构建时,这并不真正相关。
- kmap()映射 - 这些在x86-64上不存在。
- 已释放的vmalloc分配,或跨越vmalloc分配边界的范围。这里不是问题。
- 直接映射或内核映像地址范围中跨越高阶folio边界的范围。这里不是问题,只能使用长度为1的范围。
- 直接映射或内核映像地址范围中在非kmalloc slab缓存中用作SLUB页面的范围,在用户复制允许列表不允许的偏移处(请参阅__check_heap_object())。这是最烦人的部分。
(可能有其他方式使用此漏洞以不同约束读取内存,例如使用__skb_datagram_iter()中的frag_iter->len读取来影响随后从其中读取已知数据的偏移,但这似乎很麻烦。)
定位内核映像
此时为了打破内核映像的KASLR,有许多选项,部分感谢copy_to_user()在访问无效地址时不会崩溃;但一个不错的选项是通过在固定地址0xfffffe0000000000(CPU_ENTRY_AREA_RO_IDT_VADDR)处的只读IDT映射读取中断描述符表(IDT)条目,该条目产生内核中断处理程序的地址。
使用读取原语观察分配器状态和其他内容
从这里开始,我的目标是使用读取原语辅助利用写入原语;我希望能够回答以下问题:
- struct page */struct ptdesc */struct slab *与直接映射中相应区域之间的映射是什么?(这很容易,只需要从.data/.bss部分读取一些全局变量。)
- 下一个sk_buff分配将在哪个地址?
- 此特定页面的当前状态是什么?
- 我的页表位于何处,给定虚拟地址映射到哪个物理地址?
由于用户复制硬化阻止对专用slab中对象的访问,读取struct kmem_cache的内容是不可能的,因为kmem_cache是从不允许用户复制的专用slab类型分配的。但有许多重要的内核内存片段是可读的,因此可以解决此问题:
- 内核.data/.bss部分,其中包含诸如指向kmem_cache实例的指针。
- vmemmap区域,其中包含所有描述每个页面状态的struct page/struct folio/struct ptdesc/struct slab实例(这些类型一起有效地形成一个联合)。这些还包含诸如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/…的状态,可以使用读取原语简单地读取页面的refcount和mapcount(包含类型信息)。这使得可以观察诸如"此页面是否已释放或仍分配"和"此页面已重新分配为什么类型的页面"之类的事情。
要定位当前进程的页表根,类似地不可能直接通过mm_struct进行,因为它是从不允许用户复制的专用slab类型分配的(除了saved_auxv字段)。但一种解决方法是改为遍历所有根页表的全局链表(pgd_list),该链表将其元素存储在struct ptdesc内部,并搜索具有指向当前进程mm_struct的pt_mm字段的struct ptdesc。此mm_struct的地址可以从每CPU变量cpu_tlbstate.loaded_mm获得。之后,可以通过读取原语遍历页表。
寻找重新分配目标:CONFIG_RANDOMIZE_KSTACK_OFFSET的魔力
已经丢弃了"将指针递增4 GiB"和"重新分配为maple tree节点"策略后,我寻找一些其他分配,这些分配将放置一个对象,使得递增地址0x…44处的值会导致一个不错的原语。在那里有一些重要的标志字段、指定指针数组大小的长度字段或类似的东西会很好。我花了大量时间查看可以从Chrome沙箱内部在内核堆上分配的各种对象类型,但没有找到任何好的。
最终,我意识到我走错了路。显然尝试以堆对象为目标