FuzzJIT:论文阅读

FuzzJIT: Oracle-Enhanced Fuzzing for JavaScript Engine JIT Compiler

摘要

我们提出了一种新颖的模糊测试技术 FuzzJIT,用于揭示 JavaScript 引擎中的 JIT 编译器错误。该技术基于我们的洞察:JIT 编译器应仅加速执行,而绝不更改 JavaScript 代码的执行结果。 FuzzJIT 可以为每个测试用例激活 JIT 编译器,并敏锐地捕获由 JIT 编译器引起的任何执行差异。成功的关键在于输入包装模板的设计,该模板主动激活 JIT 编译器,并使生成的样本自身具备 oracle 感知能力,并且在执行期间自发地测试该 oracle。我们还设计了一组变异策略,以强调在揭示 JIT 编译器错误方面有希望的程序元素。 FuzzJIT 精确探测 JIT 编译器,同时保持了模糊测试的高效率。我们已经实现了该设计,并应用该原型在四个主流 JavaScript 引擎中发现了新的 JIT 编译器错误。在一个月内,分别在 JavaScriptCore、V8、SpiderMonkey 和 ChakraCore 中发现了 10 个、5 个、2 个和 16 个新漏洞,其中 3 个被证明是可利用的。

1 介绍

由于执行图灵完备语言的固有复杂性,JavaScript 引擎成为浏览器的安全弱点,并被揭示为包含大多数浏览器漏洞。 JavaScript 引擎负责解析、解释、编译和执行 JavaScript 代码,其基本工作流程如图 1 所示。解析器和字节码生成器在流水线中工作,将 JavaScript 代码转换为抽象语法树 (AST),然后转换为字节码。 字节码可以直接由解释器执行,也可以由 JIT 编译器编译。 JIT 编译器是一个可选模块,当某些 JavaScript 代码或函数变得热门时(即被调用了足够的次数)可以激活它。 它会即时工作,将函数编译成汇编代码,并对其进行优化以加速执行。 有时,JIT 编译器采用多层设计,其中编译和优化会随着执行次数的增加而逐渐升级。 JIT 编译器的工作机制,尤其是优化组件,非常复杂。 因此,在其实现中,错误是不可避免的。 由于 JavaScript 是一种弱类型和动态类型的语言,因此直接编译是不现实的,因为变量的类型在很多地方都是模棱两可的。 JIT 编译器不会生成 JavaScript 代码的完整编译,而是主要坚持根据解释器收集的运行时分析信息,根据历史观察到的变量类型。 为了正确性,编译结果必须受到类型检查的保护,并且只能用于类型合规性。 基于编译,优化器旨在减少完成功能所需的指令数量。 常用优化包括控制流图简化、公共子表达式消除和死代码消除。 通常,需要严格的控制和数据流分析才能安全地消除不必要的代码和检查。 当某些必要的安全检查被错误地删除时,后果会变得很严重。

JIT 编译器应仅加速执行;对 JavaScript 代码的执行逻辑或执行结果造成的任何更改都表明存在 JIT 编译器错误。 像逻辑错误一样,许多 JIT 编译器错误不会导致程序崩溃,因此它们很容易被使用崩溃作为唯一 oracle 的模糊器错过,但这些静默错误仍然提供了大量的利用原语。 例如,V8 JIT 编译器中的 off-by-one 错误被利用来远程执行代码而不会触发任何崩溃。 更多细节在第 2.3 节中讨论。 除了增强测试 oracle 外,要使用模糊测试自动发现 JIT 编译器错误,我们必须生成可以通过语法和语义检查、被执行、激活 JIT 编译器并有意揭示其中错误的测试用例。 据我们所知,唯一初步尝试模糊测试 JIT 编译器的是 Mozilla Security 的一位研究人员,他扩展了 Jsfunfuzz(一种由语法规则指导的基于生成的模糊器),增加了一个模块来检查在激活/未激活 JIT 的情况下执行测试用例时的打印输出。 它被应用于模糊测试 Spidermonkey(Mozilla 的 JavaScript 引擎),其 JIT 激活被设计为使用预定义的参数来控制,并在当时检测到 13 个 JIT 错误。 然而,它缺乏通用的 JIT 编译触发机制,并且对程序最终状态的粗略比较会导致许多错误无法捕获。 此外,随机生成的测试用例被限制为由语法规则和概率定义的有限搜索空间,并且无法彻底地执行 JIT 编译器。 最近,通过将变异应用于 AST 表示、类型丰富的 AST 表示或新的中间表示,在生成语法和语义上有效的样本方面取得了一些进展,后者支持对控制流和数据流进行语义变异,同时保持语义有效性。 然而,这些进展都不是专门为测试 JIT 编译器而设计的,导致它们长时间处于激活不足和测试不足的状态。 因此,一种专门为测试 JIT 编译器而系统设计的有效模糊测试工具仍有待开发。

在这项工作中,我们提出了 FuzzJIT,一种 JIT 编译器模糊测试技术,它通过更精确的测试 oracle 得到增强:一段 JavaScript 代码在 JIT 编译前后应产生一致的执行结果,否则错误由 JIT 编译器引起。 FuzzJIT 的特点是主动激活 JIT 编译器,有目的地生成可能使 JIT 编译器失败的有希望的输入,并敏锐地捕获那些隐藏的且非崩溃的 JIT 编译器错误以及崩溃错误。 成功的关键在于输入包装模板的设计,该模板使生成的样本本身具有 JIT 编译器激活和 oracle 感知能力,更重要的是,oracle 在执行期间自发地进行测试。 此外,我们启发式地识别出五种易于出错的程序元素,供 JIT 编译器处理,并强调将它们纳入生成的测试用例中。 因此,FuzzJIT 能够精确探测 JIT 编译器,在那里释放其力量,同时保持模糊测试的高效率。 我们在四个主流 JavaScript 引擎上评估 FuzzJIT,并将其与四个最先进的模糊器进行比较。 FuzzJIT 在检测 JIT 编译器错误方面表现出色,分别在 JavaScriptCore、V8、SpiderMonkey 和 ChakraCore 中发现了 10 个、5 个、2 个和 16 个新错误。 与其他基线相比,它还保持了更高的覆盖率和吞吐量。

总而言之,我们的主要贡献包括:

  • 通过研究大量的 JIT 错误语料库,了解了 JIT 错误的常见根本原因。
  • 一种有利的测试用例包装技术,用于触发 JIT 编译。
  • 测试用例生成策略,倾向于与 JIT 错误的根本原因相关的程序元素。
  • 一种新颖的技术,专门用于检测 JavaScript 引擎的非崩溃和崩溃 JIT 编译器错误。
  • 我们方法的原型实现 FuzzJIT,可在 https://github.com/SpaceNaN/fuzzjit 公开访问。
  • 对主流 JavaScript 引擎的评估,其中 FuzzJIT 揭示了 JIT 编译器中的 33 个新错误,并显示出比最先进的模糊器更好的性能和错误查找能力。

2 准备工作

2.1 JIT推测编译

传统的编译器牺牲编译时间来生成在运行时快速执行的汇编代码。 对于动态类型语言(例如 JavaScript),由于缺乏类型信息,高性能编译技术无法直接应用,类型信息指导编译器为指令发出汇编代码,并为输入和输出分配寄存器。 在性能战中,浏览器竞相开发更快的 JavaScript 引擎,推测性编译开始发挥作用,以使动态语言运行得更快。 推测性编译器利用了以下见解:在特定执行期间,如果一个语句的执行操作数是某些类型多次,那么它很可能在未来更多次以相同的类型执行。 因此,值得通过类型信息来有条件地将该语句编译成更有效的汇编代码,以加速执行。 我们还说汇编代码受到推测性保护的保护。 之后,当再次执行该语句时,JavaScript 引擎将找到汇编代码并检查操作数的运行时类型是否与推测性保护匹配,并在符合条件的情况下执行汇编代码。 如果不匹配,引擎将回滚到解释器或较低级别的 JIT 编译器进行执行,也称为 bailout。 直观地讲,推测性编译为频繁出现的输入类型提供了快速通道。

为语句更频繁执行的类型生成编译往往会带来更大的执行效率提升。 为了识别这些类型,当开始执行 JavaScript 代码时,解释器还负责收集变量的运行时分析信息,例如,对象的形状、变量的类型及其值。 一旦函数或其函数体的某一部分被执行了足够的次数(根据 JIT 编译器设置的阈值),引擎将基于类型敏感语句的频繁出现的类型,为此函数启动 JIT 编译和优化。 值得注意的是,分析的值和类型信息在 JIT 优化期间也是必不可少的。

现在,我们用一个简单的例子来演示推测性编译。 图 2a 显示了两个变量的 JavaScript 加法运算,没有任何类型指示。 图 2b 和图 2c 分别说明了 JavaScript 引擎在有和没有推测性编译的情况下处理加法运算的逻辑。 为了便于演示,我们概念性地将实现某些功能的字节码片段命名为操作,例如 Int32Add 和 isInt32。 在没有推测性编译的情况下,由于加法运算对类型敏感,因此 JavaScript 解释器必须考虑 a 和 b 具有各种运行时类型的不同场景。 它可能是整数加法、双精度加法、字符串连接或任意用户定义的效果,因为 JavaScript 允许重写继承的函数。 因此,解释器会生成昂贵的控制流逻辑来处理不同的情况,如图 2c 所示。 但是,如果我们观察到该操作在执行期间经常使用两个整数执行,则可以创建执行整数加法的快捷方式。 通过将代码转换为如图 2b 所示的推测性编译,要执行的指令数量大大减少。 它首先根据推测性保护验证运行时的变量类型。 speculateGuard 操作主要包含一个有条件跳转到解释器或较低级别 JIT 编译器的指令,以防条件不成立。 如果 a 和 b 确实是整数,则会采用专门用于整数加法的快速通道。 否则,JavaScript 引擎会丢弃已编译的代码并进行 bailout。

image-20250404160310538

2.2 JIT优化

基于推测性编译,可以进行许多优化,以进一步提高执行效率。 由于实现复杂性,这三个优化——边界检查消除、冗余消除和公共子表达式消除——如今已成为浏览器漏洞的主要中心,无论是在数量上还是在质量上。

边界检查消除。 JavaScript 引擎在解释或编译期间对数组索引操作应用边界检查。 边界检查消除旨在识别和删除不必要的检查。 关键思想是对指示索引或数组长度的整数变量执行值范围分析 ,并确定它们的范围。 如果索引始终在数组大小的范围内,则可以安全地删除检查以减少要执行的指令数量。 如果索引的范围被低估或数组大小的范围被高估,则会发生错误。 由于此类错误,边界检查可能会被错误地消除,并导致安全威胁。 此类别下的 JIT 编译器漏洞包括 CVE-2015-0817、CVE-2015-2712、CVE-2017-2547、CVE-2017-0234、CVE-2018-0769 和 String.lastIndexOf off-by-one 错误。

冗余消除。 冗余消除是为了删除特定控制流图路径上的重复安全保护措施(例如,类型验证),并且仅保留第一个。 当准确捕获了保留的保护和删除的保护之间的操作的副作用,并证明是无副作用时,这样做是安全的。 换句话说,安全保护中的变量永远不会被它们之间的操作修改。 精确地建模副作用很难实现,例如,隐蔽的副作用可能会在函数调用期间故意引起。 当一个操作被假定为无副作用但实际上不是时,就会发生错误。 CVE-2018-4233 和 CVE-2017-11802 是由不适当的冗余消除引起的典型漏洞。

公共子表达式消除。 公共子表达式消除与冗余消除的精神相似,但旨在避免多次计算相同的表达式。 它仅保留第一个,并将其余的替换为直接副本。 同样,只有当它们之间的操作对表达式变量没有副作用时,这样做才是安全的。 请注意,消除表达式还会放弃其附带的安全检查(如果有)。 CVE-2020-9802 和 CVE-2020-9983 是不正确的公共子表达式消除导致删除基本整数溢出检查并进一步导致越界访问的实例。

2.3 安全影响

JIT 编译器漏洞比解析器和解释器中的漏洞更易于利用。 要成功进行利用,一个重要的步骤是通过内存分配和释放,在适当的地址准备具有所需内容的内存布局。 之后,当出现任何内存损坏错误(例如,缓冲区溢出或释放后使用)时,准备好的内存内容可能会被另一个进程意外读取或执行,其执行将受到影响或劫持。 当涉及到 JavaScript 引擎漏洞的利用时,如果漏洞是在 JavaScript 代码已被执行后触发的(即在解释或 JIT 编译阶段),则制作内存布局变得更加实际。 我们可以方便地在 JavaScript 代码中创建变量分配/释放语句,并且它们很容易在到达错误点之前执行。 但是,对于解析器错误,这是不可能的。 另一方面,由于对操作有效性的密集安全检查(例如,边界和类型检查),解释器错误也很难利用。 然而,为了执行效率,其中一些检查将被 JIT 优化器消除,从而留下一个安全漏洞,因此更易于利用。

image-20250404171807027

JIT 编译器漏洞也经常被利用于安全竞赛(例如 Pwn2Own1 和 Tianfu Cup2)以及实际攻击中以获取远程代码执行。 表 1 列出了过去三年 Pwn2Own 中用于控制浏览器目标的所有漏洞。 在八个成功的演示中,其中六个利用了 JIT 编译器中的五个漏洞来获得远程代码执行权限。 我们还研究了 Google Project Zero 8 从 2016 年到 2021 年报告的 JavaScript 引擎错误(遵循与 23 中相同的设置),并在图 3 中分别绘制了位于解析器/解释器和 JIT 编译器中的错误数量。 显然,近年来人们越来越关注和努力发现 JIT 编译器错误,并且在过去四年中,它们的数量约为解析器/解释器错误的四倍。

image-20250404171741231

3 动机

JIT 编译器错误可能存在于推测性编译器或优化器中。 它们往往会在一开始引起极其微妙的错误,并且需要激活 JIT 编译器,有时还需要满足应用于特定缺陷优化的前提条件。 它们通常不会使 JavaScript 引擎崩溃,因此很容易被使用崩溃作为唯一 oracle 的模糊器错过。 然而,忽视这些错误会让 JavaScript 引擎处于危险之中,因为它们可能被利用,甚至控制引擎。 接下来,我们将展示 V8 中的一个边界检查消除错误如何传播到更明显和更具威胁性的越界访问错误。

导致此错误的概念验证 (PoC) 如图 4a 所示。 函数 opt 执行两次,一次在 JIT 编译器激活之前,一次在激活之后。 感兴趣的优化行是第 9 行,其中对数组 buf 执行索引操作。 出于安全原因,JavaScript 解释器将在解释期间检查边界。 当 JIT 编译器被激活时,它会测试通过计算 i 的范围并将其与 buf 的大小进行比较来跳过检查是否安全。 值范围分析遵循 i 的数据流,并更新修改它的每个计算操作的范围。 我们用代码注释突出显示每个语句执行后 i 的值范围,并报告真实范围以及 JIT 编译器计算的范围。

image-20250404171834124

我们可以看到 i 在第 4 行通过调用 String 对象的 lastIndexOf(toSearch) 进行初始化。 它返回字符串 toSearch 最后一次出现的索引,如果未找到,则返回 -1。 这里它在 s 上搜索空字符串,s 是 JavaScript 中允许的最长字符串,并填充了字符“A”。 当搜索空字符串时,它将在 maxLen 的索引处匹配,因为根据定义,JavaScript 中的所有字符串都以空字符串结尾。 这意味着,从理论上讲,任何对 lastIndexOf 的调用返回值范围为 -1 到 String::KMaxLength。 然而,如图 4b 所示,JIT 编译器中的值范围分析错误地估计了上限为 String::KMaxLength-1。 为了利用此错误,i 被初始化为 String::KMaxLength,使其超出编译器的预期。 在第 6 行添加 1 后,i 的估计范围变为 [0, maxLen],并且认为使用 i 索引长度为 maxLen+1 的数组始终是安全的。 因此,它可以被优化,并且编译器删除了边界检查。 然而,在添加之后,i 的实际值变为 maxLen+1,并使用它来索引一个 maxLen+1 的数组会导致越界访问。

这个错误从两个方面激励了我们。 首先,JIT 编译器错误更易于利用,具有非凡的威胁性,并且在它们在实际攻击中被利用之前检测到它们具有重要意义。 其次,使用崩溃作为唯一 oracle 的当前模糊测试方法很容易错过 JIT 编译器错误。 就像 String.lastIndexOf off-by-one 错误一样,它们通常在一开始表现为非常微妙的错误,并且需要精心设计才能使它们传播并逐渐冒泡成为可观察的错误甚至崩溃。 在这个例子中,为了使这个越界访问发生,s 和 buf 的长度、lastIndexOf 的调用及其参数,以及第 6 行对 i 的倾斜调整必须以这种方式精确地呈现。 请注意,即使是越界访问也不一定会导致任何崩溃。 因此,迫切需要一种更有效的模糊测试方法来检测 JIT 编译器错误,特别是那些不触发崩溃的错误。

4 方法

为了在模糊测试期间发现 JIT 编译器错误,在设计该方法时必须克服三个挑战:保证生成的样本能够调用 JIT 编译器,扩大在那里发现错误的可能性,以及一旦触发,准确地捕获错误,不遗漏非崩溃错误并降低误报率。 总的来说,我们的目标是增强生成的样本的语义,使其与 JIT 编译器更相关,并提高模糊测试工具对 JIT 编译器错误的敏感性。 接下来,我们首先概述我们的方法,然后在第 4.2 节、第 4.3 节和第 4.4 节中分别介绍针对这三个挑战的缓解措施。

4.1 概述

FuzzJIT 是第一个系统设计的模糊测试工具,旨在触发 JIT 编译器,揭示并捕获其中的错误,并且旨在实现上述所有期望的属性。 成功的关键是一个测试用例模板,包含三个主要组件——一个用于一般测试目的的 JavaScript 代码片段、一个 JIT 编译器触发器和一个基于执行一致性的、具有 oracle 感知的验证器。 使用此模板,我们可以自动将任何 JavaScript 测试用例与触发器及其验证器一起封装起来,并使用它来测试 JIT 编译器。 如图 6 所示,这样一个生成的测试用例,其中初始 JavaScript 测试代码包括第 6 行到第 14 行,并且显然在直接输入 JavaScript 引擎时不会调用 JIT 编译器。 接下来,我们将解释如何包装它以触发 JIT 编译器,并基于 oracle 本身准确地验证执行一致性。

image-20250405142527833

为了方便多次调用以激活 JIT 编译器,我们将代码片段包装到一个函数中,命名为 opt3,如第 5 行所示。 它的参数专门用于测试安全保护验证机制,将在第 4.3 节中详细说明。 它的返回值用于检测非崩溃错误,稍后将进行描述。 在第 22 行到第 24 行,使用 for 循环结构显式调用该函数 N 次以触发 JIT 编译。 在这里,N 可以根据特定 JIT 编译器的激活阈值自定义为任何值。

为了方便观察函数在执行后的最终状态,opt 返回一个由代码修改的变量组成的数组。 稍后,进行深度比较以检查在 JIT 编译之前和之后达到的最终执行状态是否相同。 如果发现差异,我们将报告找到一个 JIT 编译器错误。 在该模板中,opt 在第 21 行在没有 JIT 编译器的情况下执行,然后在 JIT 编译器被激活并在第 22 行到第 24 行期间工作以编译(和优化)汇编代码之后,在第 25 行再次执行。 关于错误捕获设计的更多细节可以在第 4.4 节中找到。

FuzzJIT 的整体工作流程如图 5 所示。 除了典型的模糊测试步骤之外,它还具有突变后的代码包装阶段。 FuzzJIT 专门设计了突变模块,以偏向 JIT 编译感兴趣的元素,详细信息将在第 4.3 节中介绍。 然后,该样本用精心设计的代码模板包装,以触发 JIT 编译器执行并捕获非崩溃 JIT 错误,同时消除由样本本身固有的随机性引起的误报; 详细信息将在第 4.2 节中介绍。 之后,当执行包装好的测试用例时,JIT 编译器将被自动触发并使用增强的 oracle 进行测试。 每当发生崩溃或执行不一致时,就会发出警报。 最后,无论是否触发警报,触发新代码覆盖率的样本都将被修剪并保存到语料库中,以进行下一轮模糊测试。 将 JIT 编译器激活和 oracle 检查包装到 JavaScript 测试用例中的精巧设计使其成为一个独立的模块,可以轻松添加到任何基本主机模糊器。

4.2 触发JIT编译器

不同 JavaScript 引擎的触发条件略有不同。 在这里,我们研究了四种主流 JavaScript 引擎:JavaScriptCore、V8、SpiderMonkey 和 ChakraCore,它们被终端用户广泛采用,并广泛应用 JIT 编译以追求更快的执行速度。 它们的架构如图 7 所示。 每个引擎都包含一个解析器、一个解释器和一个或多个 JIT 编译器层。 当存在多个 JIT 编译器层时,它们会随着代码变得越来越热而逐步激活。 每一层都与一个特定的执行计数阈值相关联,超过该阈值就会激活; 后面的层往往比前面的层具有更高的阈值,并生成具有更深层编译和优化的汇编代码。 每层都有许多优化方法,并且它们会根据 JIT 层被触发时的分析信息选择性地激活和交织。 错误可能存在于任何层中,并且整体测试方法应该能够深入到每一层。

不同引擎中每个 JIT 编译器层的激活阈值通常是可配置的。 值得一提的是,阈值不能设置得太小,因为某些优化需要观察到最少的执行次数才能做出决策。 太小的阈值可能会导致在引擎的默认设置下无法重现的误报; 较大的阈值则会牺牲更多的测试效率。 接下来,我们将报告每个引擎的原始阈值设置,以及如何基于反复试验和一些行业经验来重新配置它们。 这些配置在我们的实验中运行良好,并且不会导致误报。

在 JavaScriptCore 中,有三个 JIT 编译器层。 如果一个函数分别被调用超过 6 次和 66 次,则基线 JIT 编译器和 DFG(数据流图)JIT 编译器将被激活以编译和优化该函数。 如果任何函数在现代 CPU 上运行超过 10 毫秒,则 FTL(超光速)JIT 编译器将开始编译。 在我们的实验中,我们将阈值统一自定义为对函数的 10 次、50 次和 100 次调用。 在 SpiderMonkey 中,JIT 编译器需要 1,000 次函数调用才能触发,我们将其设置为 50。 在 ChakraCore 中,默认情况下,Simple JIT 和 Full JIT 分别需要 25 次和 20,000 次迭代才能触发。 我们通过实验将它们设置为 10 次和 100 次。

当使用单层的 JIT 编译器模块包装测试用例以触发时,可以直接使用图 6 中的模板。 如果有多于一层,则每个层的触发结构(类似于图 6 中的第 21 行到第 28 行),从前到后,将依次在第 20 行的 if 语句的 true 分支下进行流水线处理。 为了确保在模糊测试期间以 100% 的概率触发 JIT 编译器层,opt 函数将被调用(超过)其阈值的两倍。 也就是说,给定一个阈值 τ,图 6 中第 22 行的 N 将被设置为 2*τ。

V8 Turbofan JIT 编译器的激活机制与其他三个不同。 它不是监控函数被调用的次数,而是尝试通过估计执行其未优化版本所花费的时间并猜测将来要执行多少次来预测优化函数的好处 32。 为了方便测试,V8 提供了一个内置的本机语法,通过对要编译的函数调用 %OptimizeFunctionOnNextCall 来强制进行 JIT 编译和优化。 它的维护团队承认以这种方式发现的错误,无论它们是否可以在原始激活设置下重现。 因此,当模糊测试 V8 时,我们将 N 设置为 1,并在第 22 行 for 循环之后立即显式调用 %OptimizeFunctionOnNextCall(opt)

4.3 揭露JIT编译器漏洞

为了使模糊测试过程更有效并暴露更多错误,我们的目标是生成能够实质性地挑战 JIT 编译器在编译和优化正确性方面的测试用例。 期望为敏感操作正确生成必要的推测保护,并且在优化期间不会不适当地消除它们。 根据错误类型,应开发有针对性的输入突变和生成策略。 在这里,我们讨论了用于模糊测试三种最流行的错误驻留地点的 bug-leading 程序元素和结构,即边界检查消除、冗余消除和公共子表达式消除。 我们针对数组、对象、子表达式、有趣数字和条件变量重新赋值提出了五种启发式突变策略,因为它们是这三种消除过程中被分析的主要目标。

为了确认这种关联,特别是对于数组、对象和有趣数字,我们对已知的 JIT 错误及其利用进行了小型实证研究。 我们从 Google Project Zero 错误报告列表和 JavaScript 引擎 CVE 的 GitHub 存储库中收集了 164 个不同的 JIT 编译器错误及其 PoC,并手动分析了这三种类型的元素的存在。 两位作者独立分析了所有 164 个 PoC,讨论了他们的发现并达成了一致。 在 164 个错误 PoC 中,数组出现在 112 个中,对象出现在 115 个中,而 50 个需要特殊数字。 值得一提的是,可能需要多个因素才能触发一个错误。

接下来,我们将详细说明如何受到启发和设计有关每个元素的代码生成策略。 请注意,可以通过分析它们的根本原因来扩展该策略集,以检测其他 JIT 编译器错误。

Arrays. 如果错误地认为索引始终在数组边界内,则数组索引操作处的边界检查会被错误地消除。 当在范围分析期间错误地估计了索引的范围或数组的大小时,就会发生这种情况。 范围分析更有可能在某些极端情况下出错,这些情况可以通过有趣的数字来练习。 数组的大小受到对其进行操作的 API 的影响,因此对不同 API 对大小范围分析的影响进行建模和传播非常重要。 JavaScript 中有 12 种类型化数组,包括 Int8Array、Uint8Array、Int16Array 等。 对于每一种,都有超过 24 个 API,例如 Array.concat()、Array.copy()、Array.reverse(),其中一些需要复杂的范围计算。 例如,在分析数组连接操作 Array.concat() 时,应正确执行范围加法。 在收集的 164 个错误中,有 6 个(包括 CVE-2014-3176、CVE-20161646 和 CVE-2017-5030)主要是由 Array.concat() 的不正确建模引起的。

Objects. 在冗余消除期间,当变量类型被认为自上次类型检查以来从未更改时,将删除对变量的类型检查。 然而,修改变量类型可以以一种极其隐蔽的方式进行,特别是对于 JavaScript 对象,它们是类型混淆错误的主要贡献者。 JavaScript 对象的类型由其属性的数量和类型决定。 添加、删除或更改对象的属性将更改其类型。 JavaScript 允许更改所有对象属性,即使是魔术属性,例如 proto、constructor 和 prototype。 更改这些魔术属性允许覆盖或污染其基本对象的原型。 如果基本对象也被其他对象继承,则污染将通过原型链传播。 通过这种隐蔽的修改对象类型的方式,类型验证算法很容易出错。

Subexpressions. 公共子表达式的存在是进行公共子表达式消除优化的必要条件。 然而,这在随机突变期间很难或只能稀疏地实现。 在这里,我们故意使一些子表达式在测试样本中重复出现,并尝试通过混合不同的操作(例如乘法、除法、幂、根等)使它们复杂化。 通过将这些子表达式散布在程序的各个位置,我们在分析任何两个公共子表达式之间的操作的副作用方面挑战 JIT 编译器。 如果后者子表达式的估值在后台被更改,并且被优化器忽略,它将被错误地替换为过时的值,并导致不一致的执行结果。

Interesting numbers. 有趣的数字在测试 JIT 编译器容易出错的极端情况下非常有效,尤其是在范围分析和类型检查期间。 例如,268,435,440,字符串大小的上限,用于触发我们的动机示例中的错误 29。 2.3023e-320 是一种特殊的浮点数,它被错误地视为指向对象的指针,并在 CVE-2017-11802、CVE-2018-0840、CVE-2018-8556、CVE-2018-0835、CVE-2018-0953、CVE2018-8466 和 CVE-2018-8542 中触发类型混淆错误。 此外,-5.3049894784e314 等于 0x8000000280000002,是常量 JavascriptNativeFloatArray::MissingItem,用于在 CVE-2018-0953 中引起类型混淆。

Conditioned variable reassignments. 为了测试 JIT 编译器是否正确生成类型和边界检查,在优化期间永远不会错误地删除它们,并在运行时正确地验证它们,我们设计了一种新的程序语义来有条件地更改某些变量的类型和值。 当触发 JIT 编译器时,我们关闭更改,并测试是否可以生成正确的检查,而无需在分析期间在更改路径上看到运行时变量类型和值,并且如果变量用于敏感操作,则可以通过优化。 此设计在图 6 的模板中进行了说明。 opt 函数接受一个参数,该参数控制是否执行第 9 行的 if 语句的主体。 在其主体内部,一些变量被更改为不同的类型和值。 在此示例中,v0 从数组更改为对象。 触发 JIT 优化时,我们小心地控制传递给 opt 的参数为 false(参见第 23 行),这样在运行时永远不会观察到 v0 的更改类型。 在第 13 行对 v0 执行数组索引操作,JIT 编译器应为此生成边界检查和类型检查,并且在优化期间不应删除它们,因为 if 主体的副作用可以流到第 13 行。 如果错误地删除了类型检查,并且在启用更改的情况下调用了 opt(参见第 25 行),则对对象的索引操作将导致类型混淆错误。 否则,类型检查失败,JIT 编译器应退出到较低级别的编译器或解释器以进行更安全的处理。

Controlling syntax complexity. 在突变期间生成 opt 函数体时,我们倾向于生成变量声明和赋值语句,以及与各种数据类型关联的 JavaScript 内置 API 调用语句。 特别是,我们增加了生成数组及其相关内置 API 调用以及生成对象及其类型更改操作的机会。 为了创建重复的子表达式,我们维护一个现有子表达式池,并允许在突变期间重复插入它们。 此外,我们从 164 个收集的 PoC 中提取一组有趣的数字,并让模糊器从中选择,而不是在需要时生成随机数。 对于有条件的变量重新赋值,我们插入一个以 opt 的参数为条件的 if 语句,并随机生成其主体。 为了进一步提高 JIT 错误揭示元素的密度,我们禁用了复杂语句的生成,例如函数声明、类声明、try/catch 语句、switch/case 语句。 我们还避免在 opt 的函数体内部生成循环结构,因为它本身嵌入在循环中,并且太多的循环结构也会阻碍模糊测试期间的执行效率。 通过保持语法简单和纯粹,我们还可以提高生成的测试输入的语义正确率。

4.4 捕获JIT编译器漏洞

通过返回 opt 函数的最终执行状态,我们可以从外部观察其行为,并确保任何相关代码都不会在优化期间作为死代码被消除。 代码的测试能力也得到了最大化,因为每个语句都在测试期间计数,并且执行中的任何微小错误都会触发警报。 像典型的差异测试一样,我们可以分别执行代码片段及其 JIT 版本,记录它们的最终状态并进行比较。 这样,它们的调用上下文保证是相同的,任何差异都是由于解释和 JIT 编译/优化之间的差异造成的。 但是,这将最终启动 JavaScript引擎两次,这非常耗时,并导致模糊测试过程出现严重的滞后。

在这里,我们提出了一种新颖的想法,通过将这两个执行集成到一次运行中,并在其自身中包含比较逻辑,使测试用例能够自我感知执行的一致性。 测试代码(即 opt 函数)首先仅使用解释器执行,然后在 JIT 编译器启动后执行(参见图 6 中的第 21 行到第 25 行),并进一步比较它们的最终状态(参见第 26 行到第 28 行)。 这种设计依赖于一个重要的事实,即 JavaScript 测试用例不仅是 JavaScript 引擎的程序输入,而且也是一些要执行的代码。 通过仔细设计包装代码的生成过程,只要 opt 函数没有语法和语义错误,升级后的测试用例就可以成功执行。

现在,我们解释如何检查两个最终状态,即两个数组是否相同。 JavaScript 中有八种内置数据类型,即 undefined、null、bigint、symbol、boolean、string、number 和 object。 除了 object 之外,所有其他的都是原始类型,我们可以使用“===”运算符检查它们是否严格相等。 对于 object 类型的变量,它是对对象的引用/指针,该对象通常与一组属性相关联,属性的形式为键值对,以及一个方法列表。 在这里,键是原始类型,而值可以是任何类型,无论是原始类型还是非原始类型。 彻底比较两个对象的成本很高。 鉴于比较包含在测试用例中,复杂的计算也会降低模糊测试速度。 在这里,我们专注于比较键值对,因为它们是在程序中进行操作的主要特征。

deepEquals() 函数(参见第 1 行)深度且递归地比较任意类型的两个变量。 不同类型的变量永远不会相同。 用于比较相同数据类型的变量的归纳规则如表 2 所示。 请注意,这些规则不是代码,并且所有比较语句都意味着必须进行比较检查。 仅当递归执行期间遇到的每个比较都返回 true 时,两个变量才相同。 原始类型的规则都是基本情况,不需要递归调用。 特别是,考虑了数字类型的两个特殊情况,其中“===”未能区分 0 和 -0,并将任何涉及 NaN (Not-A-Number) 的比较视为不相等。 区分 0 和 -0 在某些数学计算中很重要,例如除法和 atan2,并且一些 JIT 编译器错误是由它们的误用引起的,这意味着捕获它是一个有意义的不一致; Object.is() 能够区分它们。 NaN 表示数学计算失败,并且它与“===”的比较规则会产生误报,即不必要的不一致,其中两个 NaN 被认为不相等。 在这里,我们将其修正为相等。 对于对象类型,我们考虑常用的类,并根据它们是否可以使用相同的规则进行比较对它们进行分组。 主要包装原始类型变量的类,例如 Date 和 String,可以基于它们的 toString() 值进行比较。 在这里,我们使用 valueOf() 函数将 Number 对象精确地转换为其对应的原始类型,并将比较委托给原始数字的规则。 否则,如果两个对象共享相同的属性列表(由 Object.keys() 返回),并且每个属性的值完全相同,则这两个对象是相同的。 为了平衡性能,deepEquals() 函数考虑了大多数常见错误,而不是尝试捕获所有可能的不一致之处。

消除误报。 即使使用相同的参数,在一次运行中两次执行同一函数也并不一定会产生相同的执行结果,因为它们的调用上下文可能会因某些执行副作用而异,例如,更改函数也使用的全局变量。导致不同执行结果的其他因素包括生成随机数(例如,Math.random())、读取当前时间(例如,Date.now())和并发。在审核 JIT 编译器的正确性时,不应将此类不一致计算在内。我们消除了这些烦人的影响以避免误报。可以通过各种 JavaScript 引擎的命令标志禁用并发功能。对于其他因素,我们提出了一种双管齐下的方法来有效且可靠地解决它们。我们创建一个令人不安的 API 黑名单,并在突变期间阻止它们的生成。另一方面,我们通过连续执行该函数几次来预先检查这些因素是否存在,并查看最终状态是否存在任何差异(参见图 6 中的第 17 到 20 行)。只有通过预先检查的测试用例才会被转发以测试 JIT 编译器。黑名单提高了获得有效测试用例的成功率并确保了模糊测试效率。拥有完整的黑名单并非易事,预先检查是为了有效地切断不合格的测试用例。我们还小心地控制执行迭代次数以使其较小,并避免激活 JIT 编译器。

5 评估

测试对象。 我们选择了四个主流 JavaScript 引擎,即 Safari 中的 JavaScriptCore (JSC)、Chrome 中的 V8、Firefox 中的 SpiderMonkey (SM) 以及 Edge 中的 ChakraCore (CH)(在 2021 年 3 月之前,目前处于维护模式),并使用它们 2021 年 12 月的最新版本(当我们开始实验时)作为测试对象来评估我们的方法。 这些 JavaScript 引擎都具有大型代码库,其中 JIT 模块做出了重大贡献。 在表 3 中,我们列出了每个对象中的代码总行数和函数数量,以及其 JIT 模块中的代码总行数和函数数量,并且我们还显示了 JIT 模块的贡献百分比。 就代码行数而言,四个对象中 JIT 模块实现的占比范围从 16.67% 到 28.91%。 平均而言,JIT 模块占所有源代码行的近四分之一,这表明 JIT 编译器的复杂性和重要性。 探索 JIT 编译器并在其中发现错误具有重要的需求。 这些 JavaScript 引擎都经过了其质量保证团队和野生安全研究人员的详尽的手动审核和测试。 FuzzJIT 检测到的任何新错误都是从所有早期检查中逃脱的,这证明了我们方法的有效性。

实现和设置。 我们基于 Fuzzilli实现了 FuzzJIT。 Fuzzilli 是一个用于 JavaScript 引擎的覆盖引导模糊器,它基于自定义中间语言 FuzzIL,可以对其进行突变并将其转换为 JavaScript。 FuzzIL 不是突变 AST 或程序的其他语法元素,而是方便地对程序的控制和数据流进行突变。 FuzzIL 程序包含指令列表,并且可以提升为 JavaScript 程序以进行测试。 据报道,Fuzzilli 在六个 JavaScript 引擎中发现了 51 个错误 9,并且被学术研究人员和行业从业者广泛采用来构建强大的 JavaScript 模糊器。 我们利用其基本模糊测试工具,包括代码覆盖反馈和执行结果分析,并自定义输入突变模块以生成我们的 JIT 错误揭示元素并添加新的输入包装模块(参见图 5)。 我们的评估环境是一个 Ubuntu 20.04 系统,运行在具有 64GB RAM 的 i9-10900K CPU 上。


源码分析

FuzzJIT是在fuzzilli的基础上改的,并且应该是在0.9.2或此之前的某个commit上改的。最直接的修改就是修改了各个测试引擎的Profile,以V8为例:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 获取object的准确类型
function classOf(object) {
var string = Object.prototype.toString.call(object);
return string.substring(8, string.length - 1);
}
// 深入比较两个对象,包括它们的属性和值,确保两个对象完全一致。
function deepObjectEquals(a, b) {
// 获取a和b的所有属性键
var aProps = Object.keys(a);
aProps.sort();
var bProps = Object.keys(b);
bProps.sort();
// 比较a和b的所有属性键
if (!deepEquals(aProps, bProps)) {
return false;
}
// 比较a和b的所有属性值
for (var i = 0; i < aProps.length; i++) {
if (!deepEquals(a[aProps[i]], b[aProps[i]])) {
return false;
}
}
return true;
}

function deepEquals(a, b) {
// 若a和b是相同的基本类型和值,那么a和b是严格相等的
if (a === b) {
if (a === 0) return (1 / a) === (1 / b);
return true;
}
// 如果类型不同那么a和b是不相等的
if (typeof a != typeof b) return false;
// 如果是数字,额外处理NaN的特殊情况
if (typeof a == 'number') return (isNaN(a) && isNaN(b)) || (a===b);
if (typeof a !== 'object' && typeof a !== 'function' && typeof a !== 'symbol') return false;
// 获取a的类型
var objectClass = classOf(a);
// 如果是数组,那么递归比较a和b的每个数组元素是否严格相等。
if (objectClass === 'Array') {
if (a.length != b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i])) return false;
}
return true;
}
if (objectClass !== classOf(b)) return false;
// 如果是正则表达式类型,那么转换成字符串比较
if (objectClass === 'RegExp') {
return (a.toString() === b.toString());
}
// 函数类型,则认为是严格相等
if (objectClass === 'Function') return true;
// 如果是数据类型,那么比较它们的值。
if (objectClass == 'String' || objectClass == 'Number' ||
objectClass == 'Boolean' || objectClass == 'Date') {
if (a.valueOf() !== b.valueOf()) return false;
}
// 如果以上类型都不是,则进一步调用deepObjectEquals()比较a和b,将他们的键值分别进行比较。
return deepObjectEquals(a, b);
}
// 生成的opt函数
function opt(opt_param){
// ....
}
let jit_a0 = opt(false);
opt(true);
let jit_a0_0 = opt(false);
%PrepareFunctionForOptimization(opt);
let jit_a1 = opt(true);
%OptimizeFunctionOnNextCall(opt);
let jit_a2 = opt(false);
if (jit_a0 === undefined && jit_a2 === undefined) {
opt(true);
} else {
if (jit_a0_0===jit_a0 && !deepEquals(jit_a0, jit_a2)) {
fuzzilli('FUZZILLI_CRASH', 0);
}
}

关于opt函数,是FuzzJIT生成/变异出来的,那么关于参数truefalse显然是控制是否触发JIT优化的开关。(这个truefalse可能是为了触发函数调用时带一个参数以触发带参数的函数调用吧,我强行解释一下O.o)那么紧接着看看变异器它是怎么做的。先看看变异器启动了哪些:

1
2
3
4
5
6
7
8
9
10
11
12
let mutators = WeightedList([
//(ExplorationMutator(), 3),
//(CodeGenMutator(), 2),
//(SpliceMutator(), 2),
(InputMutator(isTypeAware: false), 2),
(InputMutator(isTypeAware: true), 1),
// Can be enabled for experimental use, ConcatMutator is a limited version of CombineMutator
// (ConcatMutator(), 1),
(OperationMutator(), 1),
(CombineMutator(), 1),
(JITStressMutator(), 1),
])

它注释掉了ExplorationMutatorCodeGenMutatorSpliceMutator,我感觉不用注释掉啊,作者注释掉这些变异器是因为生成测试用例的时候,很有可能会生成没有return的种子,且由于这三种变异器发现”interesting”的种子能力更强,那么种子池中就会出现大量的由这些Mutator生成的种子,那么对于Profile中的模板就没有意义了。

但是,直观的能让人感觉这个FuzzJIT是无法达到很高的代码覆盖率的,起码和fuzzilli对比是做不到的,因为fuzzilli的HybridEngine不仅仅会用到ProgramTemplate,还有用到MutationEngin进行覆盖率提升导向的变异

所以,笔者有疑问:

  1. 以覆盖率为导向的JIT编译器fuzz是否是有效的呢?给我的直观感受是,由于插桩针对的是整个V8,因此覆盖率导向是以整个V8来说的,那么我想要测试JIT部分的话,甚至会出现不触发JIT优化而interesting的种子。那么能量总会集中在这些不触发JIT的种子。所以fuzzilli有一个Profile+ProgramTemplate来触发JIT优化,并且每个Template都是一种特定的特性。
  2. 倘若不对整个引擎插桩编译,而只针对于JIT优化进行插桩呢?以当前浏览器引擎的复杂程度,这是一个复杂而漫长的工作。
  3. 如果只对JIT编译部分进行插桩的话,随着fuzzing进行,覆盖率和BUG触发率的关系是否紧密呢?

其实核心问题就是:我要挖的洞是JIT的,但是覆盖率引导的方向是整个引擎的。

那么就类似于FuzzJIT,针对某一个优化设计出来的模板,能够发现优化的特定漏洞,但是显然覆盖率就低。覆盖率与漏洞触发率没有必然关系了。

那为什么用户态程序它的BUG发生率和覆盖率结合比较紧密呢?因为它的变异器是比较精细的,在字节层级,甚至位层级进行的变异操作,只要覆盖到路径了,那么就有可能触发潜在bug。但是这里的变异器是十分粗糙的,触发bug的原因是因为特定的程序结构。

那么在后面的fuzzilli维护更新中针对于这类优化漏洞是有模板实现的:

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
fileprivate let FastApiCallFuzzer = ProgramTemplate("FastApiCallFuzzer") { b in
b.buildPrefix()
b.build(n: 20)
let parameterCount = probability(0.5) ? 0 : Int.random(in: 1...4)

let f = b.buildPlainFunction(with: .parameters(n: parameterCount)) { args in
b.build(n: 10)
let target = fastCallables.randomElement()!
let apiObj = b.findOrGenerateType(target.group)
let functionSig = chooseUniform(from: b.methodSignatures(of: target.method, on: target.group))
let apiCall = b.callMethod(target.method, on: apiObj, withArgs: b.findOrGenerateArguments(forSignature: functionSig), guard: true)
b.doReturn(apiCall)
}

let args = b.randomVariables(n: Int.random(in: 0...5))
b.callFunction(f, withArgs: args)

b.eval("%PrepareFunctionForOptimization(%@)", with: [f]);

b.callFunction(f, withArgs: args)
b.callFunction(f, withArgs: args)

b.eval("%OptimizeFunctionOnNextCall(%@)", with: [f]);

b.callFunction(f, withArgs: args)

b.build(n: 10)
}

FuzzJIT仅针对于文章提及的四个浏览器引擎进行了测试,其他的引擎没有进行测试….给我的感觉是先有的漏洞才有的文章。而且对fuzzilli是没有改进的,是用fuzzilli的Profile模块挖了洞。

每次的return

1
2
3
4
5
6
if returnArr.count != 0 && (w.getCurrentIndention() == 0 ){
//for item in returnArr{
// w.emitComment(" v\(item.0) : \(item.1)")
//}
w.emit("return v\(returnArr[returnArr.count-1].0);")
}

它会返回生成的变量v列表中的最后一个,这块儿感觉很拖fuzz的效率,因为fuzzilli生成的变量并不是串接的,而是随机选择一个v然后类型推断再进行一些属性赋值或方法调用等操作。那么最后一个v就是随机的,而整个模板是对最后一个v进行JIT结果判定的,所以势必会浪费很多时间在这期间。


FuzzJIT:论文阅读
https://loboq1ng.github.io/2025/04/18/FuzzJIT-论文阅读/
作者
Lobo Q1ng
发布于
2025年4月18日
许可协议