深入解析Apple CoreText框架中的OOB写入漏洞与Safari利用挑战

本文详细记录了在Apple CoreText框架的morx表中发现的一个OOB写入漏洞,从攻击面分析到漏洞细节,并探讨了在Safari中利用该漏洞的挑战,包括堆布局控制和vtable覆盖的复杂性。

Apple CoreText - 一次意外失败的探索之旅 | STAR Labs

目录

1. 攻击面

  • 2021年10月1日
  • 2021年10月20日
  • 2021年10月26日

2. 漏洞详情

  • 2021年11月1日
  • 2021年11月3日
  • 2021年11月8日

第3a部分:利用-崩溃

  • 步骤0:触发崩溃
  • 用于调试POC的LLDB设置
  • 崩溃是如何发生的?
  • 所有想法都无效,除了一个

第3b部分:利用-控制栈

  • 尝试1(失败):通过创建多个嵌套HTML元素
  • 尝试2(成功):递归JS函数调用
  • 现在加入POC字体

第3c部分:利用-OOB写入

  • OOB写入代码路径
  • 尝试1(失败):
  • 尝试2(失败):DFG JIT
  • 尝试3(成功):wasm
  • 最终说明

第3d部分:利用-vtable覆盖

  • 一些统计数据
  • 每个核心一个堆
  • 堆中的空洞
  • tfont和glyphs的内存布局
  • CTNativeGlyphStorage结构
  • TFont结构
  • 禁用vtable调用(失败)
  • 利用步骤
  • 在正确的时间中断
  • 其他可探索的想法
  • 参考文献

正文

去年年底,我花了2-3个月的时间专注于研究CoreText框架,特别是与文本整形引擎相关的代码以及负责解析AAT表的代码。在这项研究中,我在morx表中发现了一个OOB(越界)写入漏洞。这一系列文章记录了我的整个过程,从选择这个攻击面到发现漏洞,再到为Safari编写利用代码。我希望这对任何有兴趣开始研究这一领域或想要帮助完成Safari利用(因为还没有完成)的人有所帮助:D

内部结构非常多,我已经反转了那些对这个漏洞和利用有用的结构。我会尽量在这组笔记中详细说明,所以我将它们分成几个部分:

  • 攻击面
  • 漏洞
  • 利用

免责声明:这不是另一个成功的故事/帖子。这些原本是我做这项研究时的笔记,在我的队友Jacob和Frances的帮助下,我们在分享之前进行了整理。这篇文章主要关于我遇到的各种障碍以及我如何找到克服它们的方法。所以,这篇文章中描述了很多“失败的尝试”。也许这里的大部分内容与你所做的无关,但我希望你能从中获得一些灵感来应对你自己的障碍。

在我们开始之前,这个zip文件包含了我在这篇文章中引用的文件。

1. 攻击面

在这一部分,我分享了我如何遇到CoreText框架中这个有趣的组件,以及为什么我决定更深入地探索它。

在因为CoreGraphics/ImageIO研究缺乏结果而悲伤了2个月之后,Jacob建议我复现Peter Nguyễn在CoreText/libFontParser中发现的一个bug。这是一个OOB读取bug。这听起来是个好主意,所以我做了,并写了一篇关于这个过程文章。幸运的是,Apple当时没有完全修复这个bug,还有另一种方法可以触发OOB读取。我在文章中也暗示了这个bug。我报告给了Apple,但自2021年9月16日以来,他们还没有更新我们是否修复了它。

接下来,我决定尝试对CoreText的不同版本进行bindiff,看看是否能找到Apple安全更新列表中列出的已修复bug。特别是,我尝试对macOS 11.4和11.5之间的CoreText进行bindiff,并尝试识别CVE-2021-30789。由于CVE中没有详细的bug描述,我不得不查看所有更改过的代码。

2021年10月1日

在所有更改的代码中,我的直觉告诉我bug可能在CTFontShapeGlyphs中,那里添加了一个额外的长度检查。尽管这里的更改是一个添加的长度检查,但没有检查我无法知道哪里出了问题。那里的所有代码对我来说都没有任何意义,因为使用了许多内部结构。所以,我必须使用LLDB来看看那里发生了什么。我发现WebKit有对CTFontShapeGlyphs的调用,所以我很高兴地根据WebKit传递给它的内容重命名了函数参数。我还将LLDB附加到Safari,在这个函数上设置了一个断点,并通过在Google和Wikipedia上点击开始我的反转工作。快进一下,由于我无法在这里识别出bug,有可能根本没有bug,或者我理解得不够。

在更改中,我还意识到TCombiningEngine::ResolveCombiningMarks也有一个添加的长度检查。同样,为了找出长度检查的目的,我继续反转。为了帮助这一点,我还写了一个简单而慢的命令来在LLDB中收集代码覆盖率(cov)。(你可以参考我的LLDB脚本笔记)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def cmd_cov(debugger, command, result, _dict):
    # 这是一个全局变量,用于在每次`step`或`continue`命令时禁用显示上下文
    # 因为这个命令会多次单步和执行程序
    global CONFIG_NO_CTX

    # 这个命令在函数开头设置一个断点,并会一直单步直到函数返回
    # 我们可以访问这个函数指定的次数
    # 例如,前199次调用不有趣,所以我们想获取第200次调用的覆盖率
    args = command.split(' ')
    if len(args) < 1:
        print('cov <function_name> <times>')
        return

    CONFIG_NO_CTX = 1

    func_name = args[0]
    if len(args) == 2:
        times = int(args[1])
    else:
        times = 1
    
    rip = int(str(get_frame().reg["rip"].value), 16)

    # 删除所有断点
    res = lldb.SBCommandReturnObject()
    lldb.debugger.GetCommandInterpreter().HandleCommand("bpda", res)

    # 在给定函数的开头设置断点
    res = lldb.SBCommandReturnObject()
    lldb.debugger.GetCommandInterpreter().HandleCommand("b " + func_name, res)
    print(res)

    lldb.debugger.GetCommandInterpreter().HandleCommand("c", res)

    rip = int(str(get_frame().reg["rip"].value), 16)
    target_func = resolve_symbol_name(rip)
    print(target_func)

    cur_target = debugger.GetSelectedTarget()
    xinfo = resolve_mem_map(cur_target, rip)
    module_name = xinfo["module_name"]
    module_base = rip - xinfo["abs_offset"]
    print(module_name, hex(module_base))

    # 我们可能想跳过前x次访问这个函数
    for i in range(times):
        with open(f"covs/cov{i}.txt", "w") as out:
            # 单步执行函数直到`ret`
            while True:
                get_process().selected_thread.StepInstruction(False)
                rip = int(str(get_frame().reg["rip"].value), 16)
                xinfo = resolve_mem_map(cur_target, rip)
                out.write(f"{xinfo['module_name']}+0x{xinfo['abs_offset']:x}\n")

                if target_func == resolve_symbol_name(rip) and get_mnemonic(rip) == 'ret':
                    lldb.debugger.GetCommandInterpreter().HandleCommand("c", res)
                    print(f"[+] Written to covs/cov{i}.txt")
                    break
            

    CONFIG_NO_CTX = 0

这些信息对我非常有用,因为我用这些信息来识别可到达的函数和代码路径。然后我可以在这些我知道正在执行的代码位置设置断点,并逆向工程使用的变量和结构。

在这个过程中,我发现泰米尔语文本会有更多的代码覆盖率,所以我创建了一个带有一些短泰米尔语文本的空白网站,并继续反转内部结构以简化事情。这是一个痛苦的过程,我花了1-2周的时间慢慢建立对所有涉及的数据结构的理解。例如:

  • TRunGlue
  • TLine
  • TShapingEngine
  • TKerningEngine
  • TCharStream
  • TUnicodeEncoder
  • TCombiningEngine
  • TGlyphEncoder
  • TGlyphStorage

有趣的事实:在这段时间里我还学了泰米尔语:P 感谢我的印度朋友Akash

一段时间后,我仍然无法看到没有新添加的长度检查会有什么漏洞。我想放弃,因为我不确定一开始是否有漏洞。无论如何,我只是继续反转这个区域的代码,慢慢建立对结构的理解,并希望找到Apple开发人员可能犯的任何愚蠢错误(显然,这并不容易)。我这样做了一段时间(可能1-2周),虽然我确实成功重命名了结构的更多字段,但我没有明确的目标,所以我决定停止。

在反转过程中,有一些有趣的字符串或名称引起了我的注意(最终在一个月后我在这里找到了bug):

  • morx(扩展变形表)
  • AAT(代表Apple高级排版)
  • kerx(扩展字距表)

在这段时间里,我还读了很多文章来获得灵感。特别是P0关于Windows上TTF模糊测试的文章:

2021年10月20日

在这一点上,我想也许我应该尝试模糊测试。所以我写了一个harness,加载一个字体和一些文本,然后调用尽可能多的CTFont*系列函数(查看harness文件夹)。我选择了一个为泰米尔语文本设计的TTF文件,并设置模糊器来突变一个包含一些在线复制的泰米尔语句的文本文件。我只突变文本文件,而不是字体文件,因为一个没有结构意识的突变器可能效率很低。我尝试了AFL_Frida和Jackalope,但最终我觉得Jackalope由于稳定性更好用。

模糊器很可能找不到任何bug,特别是因为字体文件根本没有被突变,而且确实没有。不过,它生成了好的文本测试用例,给了我们尽可能多的覆盖率。加载到IDA Lighthouse上的覆盖率对我的反转过程帮助很大,因为它指示了哪些代码路径被采用了。

当我把模糊器放在那里运行时,我花时间阅读了Apple的字体引擎和所有AAT的东西。有非常多的特殊表格式和许多做不同事情的表格。

在这段时间里,我交替阅读文档和基于模糊测试生成的覆盖率阅读代码。我有时也会去Wikipedia on Safari,附加LLDB,只是点击不同语言的不同页面,尝试到达CoreText中的某些函数。特别是,morx这个名字听起来很有趣,所以我设置断点看看在什么情况下我可以到达这些函数,比如:

  • TAATMorphTable::ShapeGlyphs(SyncState&, bool&, __CFString const*)
  • TAATMorphSubtableMorx::SetChain(MorxChain const*, void const*, void const*)
  • TAATMorphSubtableMorx::NextSubtable()
  • TAATMorphSubtableMorx::Process(TRunGlue&, CFRange)
  • TAATMorphSubtableMorx::InitLigatureState(TAATMorphSubtableMorx::MorxLigatureState&)
  • TAATMorphSubtableMorx::FetchInitialClass(TRunGlue&, CFRange, TRunGlue::TGlyph&, long&, TAATMorphSubtable::GlyphState&)
  • TAATMorphSubtableMorx::InitContextualState(TRunGlue&, TAATMorphSubtableMorx::MorxContextualState&)
  • TAATMorphSubtableMorx::DoContextualSubstitution(TRunGlue&, unsigned short, TRunGlue::TGlyph, char const*, MorphActionResultCode&)

不是所有文本/字形都会触发morx相关代码的执行。我发现立陶宛语和阿拉伯语文本会让CoreText进入这些函数,所以我把它们添加到我的模糊测试语料库中,并让Jackalope继续探索和生成随时间增加覆盖率的文本。

2021年10月26日

大约在这个时候,我已经阅读了Apple字体文档中的大部分内容,即关于:

  • 字体引擎
  • TrueType字体程序/指令集
  • AAT特殊表格式
  • AAT表

我退一步思考我想深入哪个领域。最终,我被AAT表吸引,原因有几个:

  • AAT(Apple高级排版)只存在于Apple设备中,因此,我认为可能没有多少人会研究这个领域。
  • 也许因为它没有被广泛使用,fonttools不支持大多数AAT表,所以很难很好地模糊测试(不写自定义工具)。
  • 它实际上很少被使用。我扫描了一个ttf语料库(我不记得是哪个),几乎没有一个有AAT表(从记忆中,我找不到所以无法验证)。
  • AAT表有这么多复杂的格式,所以可能有一些犯错的空间。

所以,我决定专注于AAT表。由于表格式的复杂性,我觉得最好先手动审查代码,因为很难写一个能很好保持预期格式的突变器。在CoreText中识别AAT相关函数并不太难,因为它们都以TAAT开头,后跟它们的表名,例如:

  • TAATAnkrTable:…
  • TAATBslnEngine:…
  • TAATOpbdTable:…
  • TAATTrakTable:…
  • TAATMorphSubtableMorx:…(除了morx有更长的命名空间)

在第二部分,我写了我如何找到这个漏洞,并分享更多关于它的信息。

AAT表使用许多不同的表/子表格式。这是官方文档: https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6Tables.html

2. 漏洞详情

在这一部分,我分享关于漏洞的细节。这可能是大多数人感兴趣的部分。

2021年11月1日

从我的代码审查开始,我首先选择了一些较小的表来审查,即trak和just表。我在那里没有找到任何bug,而且说实话它们真的很简单,所以没有太多犯错的空间。

2021年11月3日

我觉得更好地理解文本整形引擎对我来说更好,因为我现在正在看Apple的文本整形引擎,而我对它们如何工作一无所知。这里有一些非常有用的资源:

2021年11月8日

我考虑先阅读HarfBuzz的代码,因为它是开源的,应该做与CoreText大致相同的事情。然后我决定也找找HarfBuzz中已知的bug,看看它们是否存在于CoreText中。

我偶然发现了这个问题,并决定看看修复它的补丁。最终,它看起来像是HarfBuzz特有的东西,所以不是很有用。提交基本上说:

[morx] 修复内存访问问题 如果缓冲区被扩大,info会过时。

我决定看看HarfBuzz如何处理morx表,写在src/hb-aat-layout-morx-table.hh中。然后我看到了这个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
template <>
struct LigatureEntry<true>
{
  enum Flags
  {
    SetComponent	= 0x8000,	/* Push this glyph onto the component stack for
					 * eventual processing. */
    DontAdvance		= 0x4000,	/* Leave the glyph pointer at this glyph for the
					   next iteration. */
    PerformAction	= 0x2000,	/* Use the ligActionIndex to process a ligature
					 * group. */
    Reserved		= 0x1FFF,	/* These bits are reserved and should be set to 0. */
  };

然后想:哦,这是什么组件栈?

所以我决定再读一遍morx表的文档(它太复杂了,我不得不读2-3次才能正确理解)。当我感觉对morx表格式 comfortable 后,我开始阅读CoreText中的代码。事实证明,一旦我熟悉了morx格式,开始并不难。我专注于处理连字子表的代码(TAATMorphSubtableMorx::DoLigatureSubtable),因为这是有上述“组件栈”的部分。

为了避免与组件栈混淆,当引用函数的栈帧时,我会明确提到程序栈。

然后我找到了一个基于栈的缓冲区溢出。耶:D 终于在实习结束前4周找到了些东西。

实际上,要理解漏洞,不需要理解整个morx表格式,但这里是官方文档。

对于一些基本的理解,在morx表内,有几个子表。

  • 重排子表
  • 上下文子表
  • 连字子表
  • 非上下文(“花体”)子表
  • 插入子表

连字子表是有bug的那个。它负责告诉引擎如何组合某些字符(或Unicode码点)以显示不同的字形。例如,它可以配置为fi显示一个不同的字形,而不是2个单独的字形f和i。那些使用Fira Code字体的人会熟悉某些字符组合如何显示为特殊字形(例如==, >=, ->)。

总结一下,两个最相关的东西是LigatureEntry和LigatureAction,它们是连字子表的一部分。它们都是32位值,在某些位位置存储设置的标志。这些信息可以在Apple的文档中找到。就位掩码而言,这些是LigatureEntry和LigatureAction的标志(取自harfbuzz/src/hb-aat-layout-morx-table.hh):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct LigatureEntry<true>
{
  enum Flags
  {
    SetComponent	= 0x8000,	/* Push this glyph onto the component stack for
					 * eventual processing. */
    DontAdvance		= 0x4000,	/* Leave the glyph pointer at this glyph for the
					   next iteration. */
    PerformAction	= 0x2000,	/* Use the ligActionIndex to process a ligature
					 * group. */
    Reserved		= 0x1FFF,	/* These bits are reserved and should be set to 0. */
  };
...
enum LigActionFlags
{
    LigActionLast	= 0x800000
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计