深入探究NSO零点击iMessage漏洞利用:远程代码执行

访问原始链接 Google 翻译

作者:Ian Beer & Samuel Groß,来自 Google Project Zero

我们要感谢公民实验室(Citizen Lab)与我们分享了 FORCEDENTRY exploit 的样本,以及苹果安全工程与架构(SEAR)小组在技术分析方面与我们合作。以下反映的编辑观点仅代表 Project Zero,不一定代表我们在本研究过程中合作组织的观点。

今年早些时候,公民实验室成功捕获了一个被用于针对沙特活动人士的、基于 NSO iMessage 的零点击漏洞利用。在这个由两部分组成的博客系列中,我们将首次描述野外零点击 iMessage 漏洞利用的工作原理。

根据我们的研究和发现,我们评估这是我们在技术上见过的最复杂的漏洞利用之一,进一步证明 NSO 提供的能力可与之前认为只有少数国家才能获得的能力相媲美。

本博客文章中讨论的漏洞已于 2021 年 9 月 13 日在 iOS 14.8 中修复,编号为 CVE-2021-30860。

NSO

NSO集团 是 最引人注目的"访问即服务"提供商之一,他们销售打包的黑客解决方案,使没有本土网络攻击能力的国家行为体能够"付费参与",从而大大扩展了拥有此类网络能力的国家数量。

多年来,公民实验室和国际特赦组织等团体一直在追踪 NSO 移动间谍软件包"飞马(Pegasus)"的使用情况。尽管 NSO 声称他们"[评估] NSO 产品被滥用可能造成的不利人权影响"](https://www.nsogroup.com/governance/human-rights-policy/),但飞马软件已被证实与 沙特政权入侵《纽约时报》记者 Ben Hubbard、入侵摩洛哥 和 巴林 的人权捍卫者、针对国际特赦组织工作人员 以及其他数十起案件有关。

上个月,美国将 NSO 列入"实体清单",严重限制了美国公司与 NSO 开展业务的能力,并在 新闻稿中声明:"[NSO 的工具] 使外国政府能够进行跨国镇压,这是专制政府在其主权边界之外针对持不同政见者、记者和活动人士以压制异议的做法。"

公民实验室能够从一部 iPhone 中恢复这些飞马漏洞利用程序,因此本分析涵盖了 NSO 针对 iPhone 的能力。我们知道 NSO 也销售针对 Android 设备的类似零点击能力;Project Zero 没有这些漏洞利用的样本,但如果您有,请联系我们。

从"一键"到"零点击"

在之前的案例中,例如 2016 年的"百万美元异见者",目标是通过短信收到链接:

2016 年向公民实验室报告的钓鱼短信截图

来源:https://citizenlab.ca/2016/08/million-dollar-dissident-iphone-zero-day-nso-group-uae/

只有当目标点击链接时才会被入侵,这种技术被称为一键漏洞利用。然而,最近有记录表明,NSO 正在向其客户提供零点击漏洞利用技术,即使是那些可能不会点击钓鱼链接的技术高手也完全不知道自己被盯上了。在零点击场景中,不需要用户交互。这意味着,攻击者不需要发送钓鱼信息;漏洞利用只是在后台静默工作。除非不使用设备,否则无法防止零点击漏洞利用的攻击;这是一种无法防御的武器。

一个奇怪的技巧

飞马软件在 iPhone 上的初始入口点是 iMessage。这意味着,仅凭受害者的电话号码或 AppleID 用户名就可以将其作为目标。

iMessage 原生支持 GIF 图像,这是迷因文化中流行的、通常体积小、质量低的动画图像。您可以在 iMessage 聊天中发送和接收 GIF,它们会显示在聊天窗口中。苹果希望这些 GIF 能够无限循环播放,而不是只播放一次,因此在 iMessage 解析和处理流程 的早期阶段(在收到消息之后,但在显示消息之前),iMessage 会在 IMTranscoderAgent 进程(位于"BlastDoor"沙箱之外)中调用以下方法,传递任何收到的扩展名为 .gif 的图像文件:

[IMGIFUtils copyGifFromPath:toDestinationPath:error]

从选择器名称来看,这里的意图可能只是在编辑循环计数字段之前复制 GIF 文件,但该方法的语义不同。在底层,它使用 CoreGraphics API 将源图像渲染到目标路径的新 GIF 文件中。仅仅因为源文件名必须以 .gif 结尾,并不意味着它真的是 GIF 文件。

ImageIO 库,正如之前 Project Zero 博客文章所详述的,用于猜测源文件的正确格式并进行解析,完全忽略文件扩展名。使用这个"假 GIF"技巧,超过 20 种图像编解码器突然成为 iMessage 零点击攻击面的一部分,包括一些非常晦涩和复杂的格式,远程暴露了可能数十万行代码。

注意:苹果告知我们,从 iOS 14.8.1(2021 年 10 月 26 日)开始,他们已经限制了从 IMTranscoderAgent 可访问的 ImageIO 格式,并从 iOS 15.0(2021 年 9 月 20 日)开始,完全移除了 IMTranscoderAgent 中的 GIF 代码路径,GIF 解码完全在 BlastDoor 内进行。

你的 GIF 中的 PDF

NSO 使用"假 GIF"技巧来攻击 CoreGraphics PDF 解析器中的一个漏洞。

大约十年前,PDF 因其普遍性和复杂性而成为流行的漏洞利用目标。此外,PDF 中 JavaScript 的可用性使得开发可靠的漏洞利用变得更加容易。CoreGraphics PDF 解析器似乎不解释 JavaScript,但 NSO 成功地在 CoreGraphics PDF 解析器中找到了同样强大的东西……

极致压缩

在 20 世纪 90 年代末,带宽和存储比现在稀缺得多。正是在这种环境下,JBIG2 标准应运而生。JBIG2 是一种特定领域的图像编解码器,设计用于压缩像素只能是黑色或白色的图像。

它被开发出来是为了实现文本文档扫描的极高压缩比,并在高端办公扫描仪/打印机设备中实现和使用,如下面所示的 XEROX WorkCenter 设备。如果你在十年前使用过类似设备的扫描到 PDF 功能,你的 PDF 中很可能包含一个 JBIG2 流。

一台使用 JBIG2 实现扫描到 PDF 功能的 Xerox WorkCentre 7500 系列多功能打印机

来源:https://www.office.xerox.com/en-us/multifunction-printers/workcentre-7545-7556/specifications

这些扫描仪生成的 PDF 文件异常小,可能只有几千字节。JBIG2 使用两种新颖的技术来实现这些极端的压缩比,这些技术与本次漏洞利用相关:

技术 1:分割与替换

实际上,每个文本文档,尤其是那些用英语或德语等字母表较小的语言编写的文档,每页都由许多重复的字母(也称为字形)组成。JBIG2 尝试将每一页分割成字形,然后使用简单的模式匹配来匹配看起来相同的字形:

简单的模式匹配可以找到页面上所有看起来相似的形状,在本例中是所有的'e'

JBIG2 实际上对字形一无所知,它也不是在做 OCR(光学字符识别)。JBIG 编码器只是在寻找像素的连通区域,并将看起来相似的区域分组在一起。压缩算法就是简单地将所有足够相似的区域替换为其中一个区域的副本:

将所有出现的相似字形替换为其中一个的副本,通常会产生一个仍然相当清晰可读的文档,并能实现非常高的压缩比

在这种情况下,输出是完全可读的,但需要存储的信息量显著减少。你不需要存储整个页面的所有原始像素信息,而只需要存储每个字符"参考字形"的压缩版本以及所有应进行复制的位置的相对坐标。然后,解压缩算法将输出页面视为画布,并在所有存储的位置"绘制"完全相同的字形。

这种方案存在一个重大问题:一个糟糕的编码器太容易意外地交换看起来相似的字符,这可能会产生有趣的后果。D. Kriesel 的博客有一些激励性的例子,其中扫描发票的 PDF 具有不同的数字,或者扫描的建筑图纸的 PDF 最终出现错误的测量结果。这些不是我们要看的问题,但它们是 JBIG2 不再是一种常见压缩格式的重要原因之一。

技术 2:细化编码

如上所述,基于替换的压缩输出是有损的。经过一轮压缩和解压缩后,渲染的输出看起来与输入不完全相同。但 JBIG2 也支持无损压缩以及中间"较少损失"的压缩模式。

它通过存储(并压缩)替换字形与每个原始字形之间的差异来实现这一点。下面是一个示例,显示了左侧替换字符与中间原始无损字符之间的差异掩码:

在位图上使用 XOR 运算符计算差异图像

在这个简单的例子中,编码器可以存储右侧所示的差异掩码,然后在解压缩过程中,可以将差异掩码与替换字符进行 XOR 运算,以恢复构成原始字符的确切像素。本博客文章范围之外还有一些技巧,可以使用替换字符的中间形式作为压缩的"上下文"来进一步压缩该差异掩码。

与其一次性完全编码整个差异,不如分步进行,每次迭代使用逻辑运算符(AND、OR、XOR 或 XNOR 之一)来设置、清除或翻转位。每个连续的细化步骤都使渲染的输出更接近原始输出,这允许对压缩的"有损性"进行一定程度的控制。这些细化编码步骤的实现非常灵活,它们也能够"读取"输出画布上已经存在的值。

JBIG2 流

CoreGraphics PDF 解码器的大部分似乎是苹果专有代码,但 JBIG2 实现来自 Xpdf,其源代码是免费提供的。

JBIG2 格式是一系列段(segment),可以将其视为一系列绘图命令,这些命令在单次传递中按顺序执行。CoreGraphics JBIG2 解析器支持 19 种不同的段类型,包括定义新页面、解码霍夫曼表或将位图渲染到页面上给定坐标等操作。

段由类 JBIG2Segment 及其子类 JBIG2Bitmap 和 JBIG2SymbolDict 表示。

JBIG2Bitmap 表示一个矩形像素数组。其 data 字段指向包含渲染画布的后备缓冲区。

JBIG2SymbolDict 将 JBIG2Bitmap 分组在一起。目标页面表示为 JBIG2Bitmap,单个字形也是如此。

JBIG2Segment 可以通过段号引用,GList 向量类型存储指向所有 JBIG2Segment 的指针。要通过段号查找段,需要顺序扫描 GList。

漏洞

该漏洞是在整理引用段时的一个经典整数溢出:

Guint numSyms; // (1)
numSyms = 0;
for (i = 0; i < nRefSegs; ++i) {
  if ((seg = findSegment(refSegs[i]))) {
    if (seg->getType() == jbig2SegSymbolDict) {
      numSyms += ((JBIG2SymbolDict *)seg)->getSize();  // (2)
    } else if (seg->getType() == jbig2SegCodeTable) {
      codeTables->append(seg);
    }
  } else {
    error(errSyntaxError, getPos(),
          "Invalid segment reference in JBIG2 text region");
    delete codeTables;
    return;
  }
}
...
// get the symbol bitmaps
syms = (JBIG2Bitmap **)gmallocn(numSyms, sizeof(JBIG2Bitmap *)); // (3)
kk = 0;
for (i = 0; i < nRefSegs; ++i) {
  if ((seg = findSegment(refSegs[i]))) {
    if (seg->getType() == jbig2SegSymbolDict) {
      symbolDict = (JBIG2SymbolDict *)seg;
      for (k = 0; k < symbolDict->getSize(); ++k) {
        syms[kk++] = symbolDict->getBitmap(k); // (4)
      }
    }
  }
}

numSyms 是在 (1) 处声明的 32 位整数。通过提供精心构造的引用段,有可能使 (2) 处的重复加法导致 numSyms 溢出到一个受控的小值。

这个较小的值用于 (3) 处的堆分配大小,这意味着 syms 指向一个大小不足的缓冲区。

在最内层循环 (4) 处,JBIG2Bitmap 指针值被写入大小不足的 syms 缓冲区。

如果没有另一个技巧,这个循环会将超过 32GB 的数据写入大小不足的 syms 缓冲区,这肯定会导致崩溃。为了避免这种崩溃,攻击者对堆进行了整理,使得 syms 缓冲区末尾的前几次写入破坏了 GList 后备缓冲区。这个 GList 存储所有已知的段,并被 findSegments 例程用来将 refSegs 中传递的段号映射到 JBIG2Segment 指针。溢出导致 GList 中的 JBIG2Segment 指针在 (4) 处被 JBIG2Bitmap 指针覆盖。

方便的是,由于 JBIG2Bitmap 继承自 JBIG2Segment,即使在启用了指针认证(用于对虚函数调用执行弱类型检查)的设备上,seg->getType() 虚调用也会成功,但返回的类型现在将不等于 jbig2SegSymbolDict,从而导致 (4) 处的进一步写入无法到达,从而限制了内存破坏的范围。

堆溢出发生时内存布局的简化视图,显示了位于 GList 后备缓冲区下方的大小不足缓冲区以及 JBIG2Bitmap

无限解绑

在被破坏的段 GList 之后,攻击者立即整理了表示当前页面的 JBIG2Bitmap 对象(当前绘图命令渲染的位置)。

JBIG2Bitmap 是围绕后备缓冲区的简单包装器,存储缓冲区的宽度和高度(以位为单位)以及一个 line 值,该值定义了每行存储的字节数。

JBIG2Bitmap 对象的内存布局,显示了在溢出期间被破坏的 segnum、w、h 和 line 字段

通过精心构造 refSegs,他们可以在段 GList 缓冲区末尾之后恰好再写入三个 JBIG2Bitmap 指针后停止溢出。这会覆盖表示当前页面的 JBIG2Bitmap 的虚表指针和前四个字段。由于 iOS 地址空间布局的性质,这些指针很可能位于虚拟内存的第二个 4GB 中,地址在 0x100000000 和 0x1ffffffff 之间。由于所有 iOS 硬件都是小端序(这意味着 w 和 line 字段很可能被覆盖为 0x1 —— JBIG2Bitmap 指针的最高有效半部分),而 segNum 和 h 字段很可能被覆盖为此类指针的最低有效半部分,这是一个相当随机的值,取决于堆布局和 ASLR,在 0x100000 和 0xffffffff 之间。

这给了当前目标页面 JBIG2Bitmap 一个未知但非常大的 h 值。由于该 h 值用于边界检查,并且应该反映页面后备缓冲区的分配大小,这具有"解绑"绘图画布的效果。这意味着后续的 JBIG2 段命令可以读取和写入页面后备缓冲区原始边界之外的内存。

堆整理还将当前页面的后备缓冲区放置在大小不足的 syms 缓冲区下方,这样当页面 JBIG2Bitmap 被解绑时,它能够读取和写入自己的字段:


内存布局显示了无边界位图后备缓冲区如何能够引用 JBIG2Bitmap 对象并修改其中的字段,因为它在内存中位于后备缓冲区之后

通过在正确的画布坐标处渲染 4 字节位图,他们可以写入页面 JBIG2Bitmap 的所有字段,并通过仔细选择 w、h 和 line 的新值,他们可以写入页面后备缓冲区的任意偏移量。

此时,如果你知道任意绝对内存地址相对于页面后备缓冲区的偏移量,也可以写入这些地址。但是如何计算这些偏移量呢?到目前为止,这个漏洞利用的进展方式非常类似于"经典"脚本语言漏洞利用,在 JavaScript 中可能最终会得到一个可以访问内存的无边界 ArrayBuffer 对象。但在那些情况下,攻击者能够运行任意 JavaScript,这显然可以用来计算偏移量和执行任意计算。你如何在单次传递的图像解析器中做到这一点呢?

我的另一种压缩格式是图灵完备的!

如前所述,实现 JBIG2 细化的步骤序列非常灵活。细化步骤可以引用输出位图和任何先前创建的段,也可以将输出渲染到当前页面或某个段。通过精心构造依赖于上下文的细化解压缩部分,可以构造出只有细化组合运算符起作用的段序列。

实际上,这意味着可以在当前页面的 JBIG2Bitmap 后备缓冲区的任意偏移量之间对内存区域应用 AND、OR、XOR 和 XNOR 逻辑运算符。而且由于它已经被解绑……可以在任意越界偏移量的内存上执行这些逻辑操作:

显示逻辑运算符如何应用于越界内存的内存布局

当你将其推向最极端的形式时,事情开始变得真正有趣起来。如果不是操作字形大小的子矩形,而是操作单个位呢?

你现在可以提供一系列 JBIG2 段命令作为输入,这些命令实现一系列逻辑位操作以应用于页面。由于页面缓冲区已被解绑,这些位操作可以操作任意内存。

通过一些粗略的涂鸦,你可以确信,仅凭可用的 AND、OR、XOR 和 XNOR 逻辑运算符,你实际上可以计算任何可计算函数——最简单的证明是,你可以通过与 1 进行 XOR 运算来创建逻辑 NOT 运算符,然后在前面放一个 AND 门来形成 NAND 门:

一个 AND 门连接到一个 XOR 门的一个输入。XOR 门的另一个输入连接到常数值 1,从而创建一个 NAND。

NAND 门是通用逻辑门的一个例子;所有其他门都可以由它构建,并且可以 构建一个电路来计算任何可计算函数。

实用电路

JBIG2 没有脚本功能,但当与漏洞结合时,它确实具有模拟任意逻辑门电路操作任意内存的能力。那么,为什么不直接用它来构建你自己的计算机架构并编写脚本呢?这正是这个漏洞利用所做的。他们使用超过 70,000 个定义逻辑位操作的段命令,定义了一个小型计算机架构,具有寄存器和完整的 64 位加法器和比较器等特性,他们用它来搜索内存和执行算术运算。它没有 JavaScript 快,但在计算上是等价的。

用于沙箱逃逸漏洞利用的引导操作被编写为在这个逻辑电路上运行,整个事情都在这个奇怪的、模拟的环境中运行,这个环境是通过对 JBIG2 流进行单次解压缩传递而创建的。这非常不可思议,同时也非常可怕。

在未来的文章(目前正在完成)中,我们将详细研究他们如何逃逸 IMTranscoderAgent 沙箱。