Project Zero:突破声音屏障第一部分:使用Mach消息对CoreAudio进行模糊测试
Project Zero
谷歌Project Zero团队的新闻和更新
2025年5月9日,星期五
使用Mach消息对CoreAudio进行模糊测试
特邀作者:Dillon Franke,高级安全工程师,20%时间参与Project Zero项目
每秒,高权限的MacOS系统守护进程都会接收和处理数百条IPC消息。在某些情况下,这些消息处理程序会从沙箱化或非特权进程接收数据。
在本博客文章中,我将探讨使用Mach IPC消息作为攻击向量来发现和利用沙箱逃逸。我将详细介绍如何使用自定义模糊测试工具、动态插桩以及大量调试/静态分析,在coreaudiod系统守护进程中发现高风险的类型混淆漏洞。在此过程中,我将讨论遇到的一些困难和权衡。
透明地说,这是我首次涉足MacOS安全研究领域并构建自定义模糊测试工具。我希望这篇文章能为那些希望从事类似研究工作的人提供指导。
我将开源我构建的模糊测试工具,以及在整个项目中对我有用的几个工具。所有这些都可以在这里找到:https://github.com/googleprojectzero/p0tools/tree/master/CoreAudioFuzz
方法:知识驱动的模糊测试
在这个研究项目中,我采用了结合模糊测试和手动逆向工程的混合方法,我称之为知识驱动的模糊测试。这种方法是从我的朋友Ned Williamson那里学到的,它在自动化和针对性调查之间取得了平衡。模糊测试提供了快速测试各种输入并识别系统行为偏离预期区域的手段。然而,当模糊测试器的代码覆盖率停滞不前或出现特定障碍时,手动分析就会介入,迫使我更深入地研究目标的内部工作原理。
知识驱动的模糊测试有两个关键优势。首先,研究过程永远不会停滞,因为提高模糊测试器代码覆盖率的目标始终存在。其次,实现这一目标需要深入了解你正在模糊测试的代码。当你开始筛选合法的、与安全相关的崩溃时,逆向工程过程将使你对代码库有广泛的了解,从而能够从知情角度分析崩溃。
我在本研究期间遵循的循环如下:
- 识别攻击向量
- 选择目标
- 创建模糊测试工具
- 模糊测试并产生崩溃
- 分析崩溃和代码覆盖率
- 迭代改进模糊测试工具
- 重复步骤4-6
识别攻击向量
标准浏览器沙箱通过限制直接操作系统访问来限制代码执行。因此,利用浏览器漏洞通常需要使用单独的"沙箱逃逸"漏洞。
由于进程间通信(IPC)机制允许两个进程相互通信,它们自然可以作为从沙箱化进程到无限制进程的桥梁。这使它们成为沙箱逃逸的主要攻击向量,如下所示。
我选择Mach消息(MacOS操作系统中最低级别的IPC组件)作为本研究关注的攻击向量。我选择它们主要是因为我希望在最核心的层面理解MacOS IPC机制,以及Mach消息历史安全问题的记录。
先前工作和背景
在利用链中利用Mach消息远非新想法。例如,Ian Beer在2016年发现了与处理task_t Mach端口相关的XNU内核中的核心设计问题,该问题允许通过Mach消息进行利用。另一篇文章展示了2019年一个野外利用链如何利用Mach消息进行堆修饰技术。我还从Ret2 Systems的博客文章中获得了很多灵感,该文章介绍了如何利用Mach消息处理程序发现并武器化Safari沙箱逃逸。
我不会花太多时间详细说明Mach消息的工作原理(这最好留给关于该主题的更全面的文章),但这里简要概述一下本博客文章中的Mach IPC:
- Mach消息存储在内核管理的消息队列中,由Mach端口表示
- 如果进程持有给定端口的接收权限,则可以从该端口获取消息
- 如果进程持有给定端口的发送权限,则可以向该端口发送消息
MacOS应用程序可以向引导服务器注册服务,这是一个特殊的mach端口,默认情况下所有进程都有发送权限。这允许其他进程向引导服务器发送Mach消息查询特定服务,而引导服务器可以响应该服务Mach端口的发送权限。MacOS系统守护进程通过launchd注册Mach服务。你可以查看/System/Library/LaunchAgents和/System/Library/LaunchDaemons目录中的.plist文件来了解注册的服务。例如,下面的.plist文件突出显示了为MacOS上的通讯录应用程序注册的Mach服务,使用标识符com.apple.AddressBook.AssistantService。
|
|
选择目标
决定研究Mach服务后,下一个问题是选择哪个服务作为目标。为了让沙箱化进程向服务发送Mach消息,必须明确允许。如果进程使用Apple的App Sandbox功能,这是在.sb文件中完成的,使用TinyScheme格式编写。下面的代码片段显示了WebKit GPU进程沙箱文件的摘录。allow mach-lookup指令用于允许沙箱化进程查找并向服务发送Mach消息。
|
|
这帮助我显著缩小了关注范围,从所有MacOS进程缩小到具有沙箱可访问Mach服务的进程:
除了检查沙箱配置文件外,我还使用Jonathan Levin的sbtool实用程序来测试给定进程可以交互哪些Mach服务。该工具(有点过时,但我能够编译它)在底层使用内置的sandbox_exec函数,提供可访问的Mach服务标识符的完整列表:
|
|
最终,我选择研究coreaudiod守护进程,特别是com.apple.audio.audiohald服务,原因如下:
- 这是一个复杂的进程
- 它允许来自多个有影响力应用程序的Mach通信,包括Safari GPU进程
- Mach服务有大量的消息处理程序
- 该服务似乎允许控制和修改音频硬件,这可能需要提升的权限
- coreaudiod二进制文件及其大量使用的CoreAudio框架都是闭源的,这将提供独特的逆向工程挑战
创建模糊测试工具
选择攻击向量和目标后,下一步是创建一个模糊测试工具,能够通过攻击向量(Mach消息)在目标内的适当位置发送输入。
覆盖引导的模糊测试器是一种强大的武器,但只有当它的能量集中在正确的地方时才能发挥作用——就像放大镜聚焦阳光来生火一样。如果没有适当的聚焦,能量就会消散,影响甚微。
确定入口点
理想情况下,模糊测试器应该完美复制潜在攻击者可用的环境和能力。然而,这并不总是实用的。通常需要做出权衡,例如接受更高的误报率以换取更高的性能、简化的插桩或易于开发。因此,确定模糊测试的"正确位置"高度依赖于特定目标和研究目标。
选项1:进程间模糊测试
所有Mach消息都使用mach_msg API发送和接收,如下所示。因此,我认为模糊测试coreaudiod的Mach消息处理程序最直观的方法是编写一个调用mach_msg API的模糊测试工具,并允许我的模糊测试器修改消息内容以产生崩溃。这种方法看起来像这样:
然而,这种方法有一个很大的缺点:由于我们发送的是IPC消息,模糊测试工具将与目标处于不同的进程空间。这意味着代码覆盖率信息需要跨进程边界共享,而大多数模糊测试工具不支持这一点。此外,内核消息队列处理会增加显著的性能开销。
选项2:直接工具
虽然前期需要更多工作,但另一个选择是编写一个模糊测试工具,直接加载并调用感兴趣的Mach消息处理程序。这将具有巨大的优势,即将我们的模糊测试器和插桩与消息处理程序放在同一进程中,使我们能够更轻松地获取代码覆盖率。
这种模糊测试方法的一个显著缺点是它假设所有模糊测试器生成的输入都通过了内核的Mach消息验证层,而在真实系统中,这发生在消息处理程序被调用之前。正如我们稍后将看到的,情况并非总是如此。然而,在我看来,在同一进程空间中进行模糊测试的优点(速度和易于收集代码覆盖率)超过了潜在增加误报的缺点。
该方法如下:
- 识别用于处理传入mach消息的合适函数
- 编写模糊测试工具从coreaudiod加载消息处理代码
- 使用模糊测试器生成输入并调用模糊测试工具
- 希望获得收益
查找Mach消息处理程序
首先,我搜索了Mach服务标识符com.apple.audioaudiohald,但在coreaudiod二进制文件中没有找到对其的引用。接下来,我使用otool检查它加载的库。从逻辑上讲,CoreAudio框架似乎是容纳我们消息处理程序代码的良好候选。
|
|
然而,我惊讶地发现otool返回的路径不存在!
|
|
Dyld共享缓存
一些研究显示,自MacOS Big Sur起,大多数框架二进制文件不存储在磁盘上,而是存储在dyld共享缓存中,这是一种预链接库的机制,允许应用程序运行得更快。幸运的是,IDA Pro、Binary Ninja和Ghidra支持解析dyld共享缓存以获取其中存储的库。我还使用了这个有用的工具成功提取库以进行额外分析。
在IDA中获取CoreAudio框架后,我很快找到了一个对bootstrap_check_in的调用,其中服务标识符作为参数传递,证明CoreAudio框架二进制文件负责设置我想要模糊测试的Mach服务。然而,尽管进行了相当多的逆向工程,仍然不清楚消息处理代码在哪里发生。
原来这是因为使用了Mach接口生成器(MIG),这是Apple的一种接口定义语言,通过抽象掉大部分Mach层,使编写RPC客户端和服务器变得更加容易。编译时,MIG消息处理代码被打包到一个称为子系统的结构中。可以轻松地grep这些子系统来查找它们的偏移量:
|
|
接下来,我在IDA中搜索对_HALS_HALB_MIGServer_subsystem符号的交叉引用,这识别了解析传入Mach消息的MIG服务器函数!该例程如下所示,第一个参数(rdi寄存器)是传入的Mach消息,第二个(rsi寄存器)是返回给客户端的消息。MIG服务器函数从Mach消息中提取msgh_id参数,并使用它来索引到MIG子系统中。然后,调用必要的函数处理程序。
我通过为_HALB_MIGServer_server函数在coreaudiod进程上设置LLDB断点(在禁用SIP后)进一步确认了这一点。然后,我调整了系统音量,断点被命中:
在这个例子中,跟踪从MIG子系统调用的消息处理程序显示,根据Mach消息的msgh_id调用了_XObject_HasProperty函数。
根据msgh_id,可以从MIG子系统访问几十个消息处理程序。它们很容易通过MIG添加的函数名称前的便利__X前缀来识别。
_HALB_MIGServer_server函数在接近低级消息处理代码的同时仍然类似于调用mach_msg的输入之间取得了很好的平衡。我决定这是注入模糊测试输入的地方。
创建基本模糊测试工具
确定要模糊测试的函数后,下一步是编写一个程序来读取文件并将文件内容作为输入传递给目标函数。这可能就像将CoreAudio库与我的模糊测试工具链接并调用_HALB_MIGServer_server函数一样简单,但不幸的是,该函数未导出。
相反,我借用了Ivan Fratric及其TinyInst工具的一些逻辑(我们稍后会详细讨论),该工具从库中返回提供的符号地址。该代码解析Mach-O二进制文件的结构,特别是它们的头部和加载命令,以定位和提取符号信息。这使得即使目标函数未导出,也可以在我的模糊测试工具中解析和调用它。
因此,我的工具的高级功能如下:
- 加载CoreAudio库
- 从CoreAudio库获取目标函数的函数指针
- 从文件读取输入
- 使用输入调用目标函数
我的模糊测试工具的完整实现可以在这里找到。下面显示了一个调用工具从输入文件发送消息的示例:
|
|
收集合法的Mach消息
我现在有了一种直接向想要模糊测试的MIG子系统(_HALB_MIGServer_server)传递数据的方法。然而,我不知道处理程序期望的特定消息大小、选项或数据。虽然覆盖引导的模糊测试器会随着时间的推移开始发现正确的消息格式,但在刚开始模糊测试时获取合法输入的种子语料库以提高效率是有利的。
为此,我使用LLDB在MIG子系统上设置断点并转储第一个参数(包含传入的Mach消息)。然后,我操作操作系统以导致Mach消息发送到coreaudiod。Audio MIDI Setup MacOS应用程序最终对此非常有用,因为它允许创建、编辑和删除音频设备。
模糊测试并产生崩溃
拥有小型种子语料库和输入传递机制后,下一步是配置模糊测试器以使用创建的模糊测试工具并获取代码覆盖率。我使用了由Ivan Fratric构建和维护的优秀Jackalope模糊测试器。我选择Jackalope主要是因为它高度可定制——它允许轻松实现自定义变异器、插桩和样本传递。此外,我很欣赏它在macOS上的无缝使用,特别是其由TinyInst驱动的代码覆盖率功能。相比之下,我尝试使用Frida针对macOS上的系统守护进程收集代码覆盖率但失败了。
我使用以下命令启动Jackalope模糊测试运行:
|
|
迭代改进模糊测试工具
这个工具很快产生了许多崩溃,表明我走在正确的轨道上。然而,我很快了解到,初始崩溃通常不表示安全错误,而是模糊测试工具本身的设计错误或无效假设。
迭代1:目标初始化
我的模糊测试方法的一个困难是,我的目标函数(Mach消息处理程序)期望HAL系统处于特定状态才能开始接收Mach消息。通过简单地使用我的模糊测试工具调用库函数,这些假设被打破了。
这导致错误开始出现。如下图所示,该工具绕过了coreaudiod进程在启动期间通常会处理的大部分引导功能。
代码覆盖率以及错误消息在帮助确定模糊测试工具忽略的一些初始化步骤方面非常有用。例如,我注意到我的数据流在大多数Mach消息处理程序中总是早期失败,记录消息"Error: there is no system"。
原来我需要初始化HAL系统才能正确与Mach API交互。在我的情况下,在我的模糊测试工具中调用_AudioHardwareStartServer函数处理了大部分必要的初始化。
迭代2:API调用链
我的模糊测试工具的第一次尝试很酷,但它做了一个相当大的假设:所有可访问的Mach消息处理程序都相互独立运行。我很快了解到,这个假设是错误的。当我运行模糊测试器时,开始出现如下错误消息:
该错误似乎表明SetPropertyData Mach处理程序期望通过先前的Mach消息注册客户端。显然,我正在模糊测试的Mach处理程序是有状态的,并且相互依赖才能正常运行。我的模糊测试工具需要考虑这一点,才能有望在目标上获得良好的代码覆盖率。
这突出了模糊测试世界中的一个常见问题:大多数覆盖引导的模糊测试器接受单个输入(一堆字节),而许多我们想要模糊测试的东西以完全不同的格式接受数据,例如几个不同类型的参数,甚至几个函数调用。这份Google文章很好地解释了这个问题,Ned Williamson在2019年的OffensiveCon演讲也是如此。
为了绕过这个限制,我们可以使用一种我称之为API调用链的技术,该技术将每个模糊测试输入视为可以读取的流,以构建多个有效输入。因此,每次模糊测试迭代都能够生成多个Mach消息。这个简单但重要的见解允许模糊测试器使用相同的代码覆盖率知情输入探索单独函数调用的相互依赖性。
FuzzedDataProvider类是LibFuzzer的一部分,但可以作为头文件包含用于任何模糊测试工具,是消费模糊测试样本并将其转换为更有意义数据类型的绝佳选择。考虑以下伪代码:
|
|
这段代码将字节块转换为一种机制,可以以确定性方式重复使用模糊数据调用API。更重要的是,覆盖引导的模糊测试器将能够探索和识别一系列提高代码覆盖率的API调用。从模糊测试器的角度来看,它只是修改字节数组,完全不知道底层发生的额外复杂性。
例如,我的模糊测试器很快发现,与audiohald服务的大多数交互需要在调用大多数API之前调用_XSystem_Open消息处理程序来注册客户端。随着时间的推移,模糊测试器保存到其语料库的输入自然反映了这一事实。
迭代3:模拟有错误/不需要的功能
有时覆盖率会停滞,模糊测试器难以探索新的代码路径。例如,假设我们正在模糊测试一个HTTP服务器,但它一直卡住,因为它试图在启动时读取和解析配置文件。如果我们的重点是服务器的请求解析和响应逻辑,我们可能会选择模拟我们不关心的功能,以便将模糊测试器的代码覆盖率探索集中在其他地方。
在我的模糊测试工具的情况下,调用初始化例程导致我的工具尝试向引导服务器注册com.apple.audio.audiohald Mach服务,这抛出了一个错误,因为它已经由launchd注册。由于我的工具不需要注册Mach服务来注入消息(请记住,我们的工具直接调用MIG子系统),我决定模拟该功能。
当处理纯C函数时,可以使用函数插值轻松修改函数的行为。在下面的示例中,我声明了一个新版本的bootstrap_check_in函数,它只返回KERN_SUCCESS,有效地将其nop掉,同时告诉调用者它是成功的。
|
|
在C++函数的情况下,我使用TinyInst的Hook API来修改有问题的功能。在一个特定场景中,我的模糊测试器不断使目标崩溃,因为CFRelease函数被使用NULL指针调用。一些进一步的分析告诉我,这是一个与安全无关的错误,其中用户的输入(假设包含有效的plist对象)没有得到适当验证。如果plist对象无效或为NULL,下游函数调用将包含NULL,并会发生中止。
因此,我编写了以下TinyInst钩子,它检查传递给函数的plist对象是否为NULL。如果是,我的钩子提前返回函数调用,绕过有错误的代码。
|
|
接下来,我修改了Jackalope以使用我的插桩,使用CreateInstrumentation API。这样,我的钩子在每次模糊测试迭代期间都被应用,烦人的NULL CFRelease调用停止了发生。下面的输出显示了钩子防止了因传递给麻烦API的NULL plist对象而导致的崩溃:
|
|
重现和构建此带有自定义插桩的模糊测试器的代码可以在这里找到:https://github.com/googleprojectzero/p0tools/tree/master/CoreAudioFuzz/jackalope-modifications
迭代4:改进样本结构
以模糊测试为中心的审计技术的一个优点是,它突出了你正在审计的代码中的知识差距。当你解决这些差距时,你会更深入地了解你的模糊测试工具应生成的输入的结构和约束。这些见解使你能够改进你的工具以产生更有针对性的输入,有效地渗透更深的代码路径并提高整体代码覆盖率。以下小节重点介绍了我如何识别和实施机会来迭代改进我的模糊测试工具,显著提高其效率和有效性的示例。
消息处理程序语法检查
模糊测试运行的代码覆盖率结果非常有说服力。我注意到,在运行我的模糊测试器几天后,它很难探索大多数Mach消息处理程序的开始部分之后。一个简单的例子如下所示(探索的基本块以蓝色突出显示),其中几个比较没有通过,导致函数早期出错。这里,rdi寄存器是我们发送给处理程序的传入Mach消息。
这些比较检查Mach消息格式是否正确,消息长度设置为0x34,并且消息中设置了各种选项。如果不是,它将被丢弃。
考虑到这一点,我修改了我的模糊测试工具,设置我发送给_XIOContext_SetClientControlPort处理程序的Mach消息中的字段,使它们通过这些条件。模糊测试器可以随意修改消息的其他部分,但由于这些方面需要符合严格的指导方针,我只是对它们进行了硬编码。
这些小的修改是我为目标构建的输入结构的开始。在将这些指导方针添加到模糊测试器之后,我的模糊测试效率得到了天文数字般的提高——此后不久,我的代码覆盖率增加了2000%。
行外(OOL)消息数据
我注意到我的模糊测试设置开始从对mig_deallocate的调用产生大量崩溃,该调用释放给定地址。起初,我以为我找到了一个有趣的错误,因为我可以控制传递给mig_deallocate的地址:
然而,我很快了解到,Mach消息可以包含各种类型的行外(OOL)数据。这允许客户端分配一个内存区域并在Mach消息中放置一个指向它的指针,该指针将由消息处理程序处理并在某些情况下释放。当使用mach_msg API发送Mach消息时,XNU内核将验证OOL描述符指向的内存是否由客户端进程正确拥有和可访问。
我并没有发现漏洞;我的模糊测试工具只是附加到了下游的一个点,该点绕过了内核通常会执行的正