V8-pwn入门(3)——Inline-Cache、GC、JIT

Sl0th Lv4

0x00 Inline Cache🗺️

概念

Inline Cache 是一种基于历史访问模式的缓存技术,主要用于加速以下操作:

  • 属性访问(如 obj.prop
  • 方法调用(如 obj.method()
  • 属性设置(如 obj.prop = value
  • 全局变量访问

那么这种缓存是怎么实现的?🤔其实就是之前提到的Hidden Class(也就是Map),以属性访问为例,对于一个obj的map,如果想访问obj.X,我们可以缓存obj的Map,而obj.X作为一个name property在Map中properties array的偏移是固定的,因此可以直接取出,不用再用X这个key去Map中的properties array检索,从而加速属性访问

分类

  • 0 uninitialized:未初始化
  • . premonomorphic:单态之前
  • 1 monomorphic:单态
  • ^ recompute handler:重新计算处理器
  • P polymorphic:多态
  • N megamorphic:巨型缓存状态
  • G generic:通用

基本过程

具体可参考https://mp.weixin.qq.com/s/mbJJAiGz0OAd2IOc8K5Mkg ?

简单来说,只有在第二次访问obj.X时(第二次产生IC-Miss),会编译生成IC-Hit Handler,把IC-Hit HandlerMap加入缓存,之后第三次访问obj.X速度会大大提升。

image-20241117180023224
image-20241117180023224

Monomorphic(单态)

此时需要缓存的只有一种map,也就是说一个object被定义后,不会添加/删除property,也就不会改变Hidden Class(Map)

举个例子🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 利用--print-bytecode可以看字节码
let o = {sloth : 1440};
function getSloth(obj) {
return obj.sloth;
}
for(let i=0;i<20;i++)//触发inline Cache
getSloth(o);
%DebugPrint(getSloth);
%SystemBreak();
//output
[generated bytecode for function: getSloth]
Parameter count 2
Register count 0
Frame size 0
41 E> 0x1ebf58a1fb42 @ 0 : a5 StackCheck
61 S> 0x1ebf58a1fb43 @ 1 : 28 02 00 00 LdaNamedProperty a0, [0], [0]
67 S> 0x1ebf58a1fb47 @ 5 : a9 Return
Constant pool (size = 1)
0x1ebf58a1faf1: [FixedArray] in OldSpace
- map: 0x336a62180801 <Map>
- length: 1
0: 0x1ebf58a1f229 <String[#5]: sloth>
Handler Table (size = 0)
0x1ebf58a1f611 <JSFunction getSloth (sfi = 0x1ebf58a1f371)>

可以看到生成的字节码很简单,就是用一个LdaNamedProperty

在V8源码src/interpreter/interpreter-gengerator.cc有解释LdaNamedProperty的使用规范

1
2
3
4
// LdaNamedProperty <object> <name_index> <slot>
//
// Calls the LoadIC at FeedBackVector slot <slot> for <object> and the name at
// constant pool entry <name_index>.

具体来说第一个参数就是要访问的object,第二个是name object的name在常量池中的index,第三个参数是个slot,主要是指明调用 FeedBackVector中的哪个LoadIC

我们可以跟进一下看看这个FeedBackVector

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
pwndbg> job 0x1ebf58a1f611 //先看看Function
0x1ebf58a1f611: [Function] in OldSpace
- map: 0x0058089003b9 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1ebf58a02109 <JSFunction (sfi = 0x76000683b29)>
- elements: 0x336a62180c71 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype:
- initial_map:
- shared_info: 0x1ebf58a1f371 <SharedFunctionInfo getSloth>
- name: 0x1ebf58a1f241 <String[#8]: getSloth>
- formal_parameter_count: 1
- kind: NormalFunction
- context: 0x1ebf58a1f5d9 <ScriptContext[5]>
- code: 0x176fcf304101 <Code BUILTIN InterpreterEntryTrampoline>
- interpreted
- bytecode: 0x1ebf58a1fb09 <BytecodeArray[6]>
- source code: (obj) {
return obj.sloth;
}
- properties: 0x336a62180c71 <FixedArray[0]> {
#length: 0x0760006804b9 <AccessorInfo> (const accessor descriptor)
#name: 0x076000680449 <AccessorInfo> (const accessor descriptor)
#arguments: 0x076000680369 <AccessorInfo> (const accessor descriptor)
#caller: 0x0760006803d9 <AccessorInfo> (const accessor descriptor)
#prototype: 0x076000680529 <AccessorInfo> (const accessor descriptor)
}

- feedback vector: 0x1ebf58a1fb79: [FeedbackVector] in OldSpace //🌟跟进
- map: 0x336a62180c11 <Map>
- length: 2
- shared function info: 0x1ebf58a1f371 <SharedFunctionInfo getSloth>
- optimized code/marker: OptimizationMarker::kNone
- invocation count: 20
- profiler ticks: 0
- slot #0 LoadProperty MONOMORPHIC {
[0]: [weak] 0x00580890ab39 <Map(HOLEY_ELEMENTS)>
[1]: 836
}
pwndbg> job 0x1ebf58a1fb79 //看看FeedbackVector
0x1ebf58a1fb79: [FeedbackVector] in OldSpace
- map: 0x336a62180c11 <Map>
- length: 2
- shared function info: 0x1ebf58a1f371 <SharedFunctionInfo getSloth>
- optimized code/marker: OptimizationMarker::kNone
- invocation count: 20
- profiler ticks: 0
- slot #0 LoadProperty MONOMORPHIC {
[0]: [weak] 0x00580890ab39 <Map(HOLEY_ELEMENTS)> //最后被调用的LoadIC,其实是个Map
[1]: 836
}
pwndbg> job 0x00580890ab39 //看看Map,可以发现是某个Object的map
0x580890ab39: [Map]
- type: JS_OBJECT_TYPE
- instance size: 32
- inobject properties: 1
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x00580890aae9 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x076000680609 <Cell value= 1>
- instance descriptors (own) #1: 0x14305458ddf9 <DescriptorArray[1]> 🌟
- layout descriptor: (nil)
- prototype: 0x1ebf58a02091 <Object map = 0x5808900229>
- constructor: 0x1ebf58a020c9 <JSFunction Object (sfi = 0x760006857e9)>
- dependent code: 0x336a621802c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
pwndbg> job 0x14305458ddf9 //看看descriptors ,也就是存property的地方
0x14305458ddf9: [DescriptorArray]
- map: 0x336a62180271 <Map>
- enum_cache: empty
- nof slack descriptors: 0
- nof descriptors: 1
- raw marked descriptors: mc epoch 0, marked 0
[0]: #sloth (const data field 0:s, p: 0, attrs: [WEC]) @ Any //就是我们访问的那个obj,属性是sloth
  • 可以发现FeedBackVector的slot中其实就是存储了一个个缓存的Map,LdaNamedProperty 调用slot中的[0],也就对应了触发Inline Cache的obj.sloth的这个obj的Map
  • 同时FeedBackVector的slot中还有一个元素slto[1]:836,前面说到触发Inline Cache后,会把IC-Hit HandlerMap加入缓存,这个836就是IC-Hit Handler(Handler是V8封装的指针)
    • IC-Hit Handler 是指当 Inline Cache机制匹配上之前缓存的信息时,直接触发执行的处理逻辑
    • 如果当前操作模式和缓存的信息匹配(IC hit),V8 会直接使用缓存的 Handler 来执行该操作,而不是重新进行属性查找或类型判断。

触发JIT的情况

把循环次数加大到一定数量,就会触发JIT优化,会将hot code直接转换成汇编

1
2
3
4
5
6
let o = {sloth : 1440};
function getSloth(obj) {
return obj.sloth;
}
for(let i=0;i<100000;i++)//触发inline Cache & JIT
getSloth(o);

具体汇编逻辑类似

1
2
3
4
5
6
7
8
9
10
      mov ecx, "sloth"
mov eax, obj
cmp [eax + kMapOffset], <cached map of obj> # 首先比较传入函数的object的Map是否与Cache中的Map一致
jne miss
mov eax, [eax + kPropertiesOffset]
mov eax, [eax + <cached offset of property sloth>] #直接根据offset访问property
jmp done
miss:
call IC_Miss
done:

polymorphic(多态)

简单来说就是上面的getSloth函数被多次调用,但每次传入的obj的properties都和上次不一致(对标2-4个Map的情况),下面的例子是map1->map2->map1->map2,那么就要生成一系列的map check比较,以判断选择哪个map的Inline Cache

1
2
3
4
5
6
7
8
9
let o = {sloth : 1440};
let o1 = {passion:1, sloth : 1440};
function getSloth(obj) {
return obj.sloth;
}
for(let i=0;i<100000;i++){//触发inline Cache & JIT
getSloth(o);
getSloth(o1);
}

生成的汇编逻辑类似

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
    mov ecx, "sloth"
mov eax, obj
call IC_Polymorphic_sloth

IC_Monomorphic_sloth: #单态情况
cmp [eax], <map>
jne miss
mov eax, [eax + 4]
mov eax, [eax + <sloth_offset>]
ret

miss:
jmp IC_Miss

IC_Polymorphic_sloth: #多态情况
cmp [eax], <map1> #先比较map1
jne check2
mov eax, [eax + 4] #32位下property对应的偏移
mov eax, [eax + <sloth_offset1>]
ret

check2:
cmp [eax], <map2> #再比较map2
jne miss
mov eax, [eax + 4]
mov eax, [eax + <sloth_offset2>]
ret

miss:
jmp IC_Miss

Megamorphic(超态)

超过了4个Map(或引擎设计的多态缓存的容量),V8懒得管理了,此时缓存机制切换为通用查找,而非特定类型优化

一些特殊情况——访问一个继承的property

术语补充:

  • Receiver 是执行属性访问或方法调用时,实际的目标对象
  • Holder 是实际存储目标属性的对象。它表示属性访问时的查找终点

举个例子,下面的js code中,以访问obj2.X为例,实际进⾏属性访问的对象obj2是receiver,⽽真正持有属性X的对象objholder

1
2
3
4
5
6
7
8
let obj = {X : 1};
let obj1 = {};
let obj2 = {};
obj2.__proto__ = obj1;
obj1.__proto__ = obj;
print(obj2.X);
//output: 1
//protochain:obj->obj1->obj2

💡学过Web的js原型连污染的应该对这个很熟悉了,js中可以通过设置对象的 __proto__ 属性来动态调整对象的原型链,从而设置或更改一个对象的“父对象”(即它的原型对象)。

因此如果访问obj2的X属性,实际上需要顺着obj2的原型链向上一直找到obj这个holder,而inline Cache也得遵循这个逻辑,所以obj2的inline Cache必须沿着protochain,对链上的每个map都进⾏check。

Inline Cache相关CVE

CVE-2022-1134

https://github.blog/2022-06-29-the-chromium-super-inline-cache-type-confusion/

CVE-2021-38001

https://bugs.chromium.org/p/chromium/issues/detail?id=1260577

https://github.com/vngkv123/articles/blob/main/CVE-2021-38001.md

漏洞根因:Inline Cache的处理程序(也就是IC Handler)在创建时会基于某些假设(例如对象结构Map、原型链状态等)生成优化的代码,因此有机会造成一些类型混淆漏洞。(JIT中也有类似)

🧠TODO:先挖个坑,后面有时间再进行调试分析

0x01 垃圾回收(GC)🗑️

概述

V8中的垃圾回收(GC)与Java的GC类似,都是通过自动内存管理来回收不再使用的对象(也就是HeapObject)。

相较于传统的glibc中的heap管理方式(malloc、free)不一样,V8使⽤mmap来拓展堆空间

GC空间划分

主要是3块

1
2
3
4
graph LR
A["Young Generation"]
B["Old Generation"]
C["Other"]

根据Object的存活时间,分为Young/Old Generation,除此之外的Other区域不属于任何一个Generation,老版本中Other区域其实是Large Object Space,放一些大小超过一定阈值的Object

Young Generation

结构如下

1
2
3
4
5
graph TD
A[Young Generation] --> B[New Space]
B --> C[To Space]
B --> D[From Space]

  • New Space

    • 新创建的object都会被放入这里面(除了code object,map object和large object)并受GC管理
  • 这块区域使用的GC算法是Cheney’s algorithm(实际上是Cheney’s GC复制算法的改版,From/To Space的功能和原版不一样,源码里相关函数叫Scavenge),需要使用到To Space和From Space,其实这个算法很好理解:

    • 每一个新创建的Object最开始都会被放入To Space

    • 假设出现memory exhaustion(空间耗尽):To Space已经满了,现在新创建了一个object-sloth,To Space内部如下

      1
      2
      To: obj-a | obj-b | obj-c | ... | obj-end |        
      From: Empty
    • 显然To Space放不下了,因此把To Space的内容和From Space交换(因为From Space是空的,相当于清空ToSpace),然后在From Space中依次判断每个Object是否存活,比如:

      1
      2
      To: Empty
      From: obj-a(alive,root) | obj-b(dead❌) | obj-c(alive,noroot) | ... | obj-end(alive) |
    • 把From Space中alive的Object复制到To Space中,清空From space,然后再重新分配刚刚创建的obj-sloth

      1
      2
      To: obj-a | obj-c | ... | obj-end | obj-sloth |     
      From: Empty

怎么判断object是否是alive?🤔

  • 首先root objects肯定是alive的,主要包括global objects, built-in objects, local objects within the scope of living等
  • 从Old Space(这块在Old Generation中,后面会提到)中可以访问的object也是alive的

Old Generation

结构如下

1
2
3
4
5
graph TD
A[Old Generation] --> B[Old Space]
A --> C[Code Space]
A --> D[Map Space]

  • Old Space主要用来存放长期alive的Object

    • 在Young Generation的New Space中,经过两次GC仍保留的object就会存到old space中
    • old space发生GC的频率比new space小很多,因此后面构造exp的时候用于内存布局的object可以先放到old space中(之前写*CTF oob exp中先进行两次GC的原因)
  • Code Space

    • 主要是放code object的,Code Object 是 V8 中用于存储生成的机器代码的数据结构
    • 可能新手看到这里会有疑问🤔,为什么V8要设计一个存储机器代码的object,这个主要涉及JIT优化,V8会将JavaScript函数(一般是hot code)编译为字节码,优化为机器码后(JIT优化),这些代码片段会被存储为 Code Object,之后再遇到该函数调用可以直接运行机器码,速度更快。
    • 这里面的code object主要是JIT优化产生的,由于是JIT优化都是运行时的,所以对应的code object所在内存区域也需要有rwx权限
  • Map Space

    • Map Object就是之前提到的Hidden Class(Map)

Old generation中GC使用的是Mark-Sweep-Compact算法,但后续利用中不涉及,感兴趣可以看

https://fa1lr4in.com/2021/12/09/%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AE%97%E6%B3%95%E4%B8%8E%E5%AE%9E%E7%8E%B0/

Other

里面是一个Large Object Space,放600KB及以上的object

这些Large Object有以下特点

  • 不是通过Heap分配的,而是mmap直接分配的
  • 内存位置是固定的,在垃圾回收(GC)过程中,普通Object可能会被复制或移动到新的内存位置以进行整理,而Large Object由于体积庞大,因此它们不参与移动
  • 如果 Large Object Space 中需要存放多个对象,这些内存块会使用链表进行管理

Write Barrier & Remember Set

🤔想象这样一种情况,我们已经知道Old Generation中放的是长期存活的Object(为了方便,后面就称这类object为old-obj),但如果我们现在触发了GC,需要回收在Young Generation的一个young-obj,而恰好有一个old-obj引用了这个young-obj

那么为了防止悬挂指针(Dangling Pointer),也就是young-obj已经被回收了,而那个old-obj的引用指针仍然指向已被清理的内存空间,因此需要更新old-obj的引用指针。

🧠这里可能有个疑问,Old Generation中不都是经过2轮GC存活下来的object吗,怎么还会指向新创建的在Young Generation中的新object,这里其实就是一个跨代引用

跨代引用

在 JavaScript 程序中,运行时可能会修改对象的属性或结构,导致 Old Generation 的对象持有 Young Generation 的引用。例如:

1
2
3
4
let oldObj = {}; 
//....some code 导致 oldObj 最终被提升到 Old Generation
let youngObj = { key: 'value' }; // youngObj 位于 Young Generation
oldObj.ref = youngObj; // Old Generation 的对象引用了 Young Generation 的对象

除了回收这个young-obj的情况需要更新对应old-obj的引用指针外,其实只要发生GC,也可能移动这个young-obj(可能是简单的From->To,也可能是发生了”晋升”),导致改变其实际内存地址。

💡”晋升”🔝是指在Young Generation中的young-obj经历过的GC轮数达到设定的阈值,可以被认为是长期存活的object,因此移入Old Generation

那么针对上面说的这类现象,我们在对Young Generation进行GC回收对象时,还需要访问Old Generation来更新old-obj的引用指针,这似乎违背了这种分代设计的初衷,🤔之所以要分成Young Generation和Old Generation就是为了减少object的扫描次数从而加快GC效率,因此为了解决跨代引用更新的问题,引入了一个叫记录集(Remember Set)的概念。

什么是记录集?可以简单理解为,记录集内记录了所有old-obj指向young-obj的情况,具体实现中记录集内就是一组指向有跨代引用的old-obj的指针。因此我们在对Young Generation进行GC时,可以通过访问记录集来快速修改引用情况,下图是一个更新跨代引用的示例(同时发生了young-obj的”晋升”🔝)

image-20241117175832676
image-20241117175832676

我们也可以用下面的流程图来表示这个过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
graph TD
A[触发新生代GC] --> B[扫描记录集]
B --> C[将记录集中的老年代对象作为根对象]
C --> D[检查老年代对象引用的新生代对象]
D --> E{目标对象存活状态}

E -->|存活| F[复制到新生代的To-Space]
E -->|已晋升| G[更新到老年代]
E -->|未存活| H[回收目标对象]

F --> I[更新老年代对象的引用指针]
H --> I
G --> I
I --> J[完成引用关系更新]
J --> K[回收新生代的未引用对象]

🤓👆🏻那么现在还有一个问题,我们该怎么形成这个记录集🤔,这里就又要引入一个写入屏障(Write Barrier)的概念,在更新对象间的指针时候(发生引用关系变化,比如执行obj.x=...),就会触发write_barrier,其实现的代码逻辑如下

1
2
3
4
5
6
7
8
write_barrier(obj, field, new_obj){
if(obj >= $old_start && new_obj < $old_start && obj.remembered == FALSE){
$rs[$rs_index] = obj
$rs_index++
obj.remembered = TRUE
}
*field = new_obj
}

参数 obj 是发出引⽤的对象,obj 内存在要更新的指针,⽽ field 指的就是 obj 内的域,new_obj 是在指针更新后成为引⽤⽬标的对象。

三个判断条件

  • 发出引⽤的对象是不是old-obj
  • 指针更新后的引⽤的⽬标对象是不是young-obj
  • 发出引⽤的对象是否还没有被记录到记录集中

如果这些条件都满⾜,就将old-obj写⼊到记录集⾥,最后*field = new_obj更新指针

💡这里可以发现这个指针更新的操作是一定发生的(在if条件外),因此有个比较典型的漏洞,在更新指针的时候,错误的消除了Write Barrier,即只更新指针,不检查是否该把old-obj写⼊到记录集⾥(这样就会导致后面发生GC时不更新这个old-obj的引用关系,发生UAF)。

https://bugs.chromium.org/p/chromium/issues/detail?id=791245

其实就是JIT的锅,因为V8中Object还有smi的存在,所以JIT认为smi不用写屏障(因为它就没有property,也不能进行obj.x=...的操作)

1
2
3
4
5
6
7
8
9
10
let arr = [{}];
var v; // should be var
for (var i = 0; i < 700000; i++) {
arr[0] = 1;
v = i + -0;
arr[0] = v;//更新arr这个obj的引用关系,但是JIT把这里的写屏障消除了
}
print(arr[0] === v) // true
gc();//gc之后arr[0]->v的引用丢失了,原来指的那个v已经被移动了,但没更新arr对v的引用
print(arr[0] === v) // false

0x02 编译优化⏩

JIT概述

参考https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/ 以类型保护为例,V8中Js code的执行流程图类似下图

1
2
3
4
5
6
7
8
9
10
graph TD
A[JavaScript Source Code] --> B[Parser]
B --> C[Abstract Syntax Tree]
C --> D[Interpreter Ignition]
E["Compiler TurboFan(JIT)🌟"]
D --> F[Bytecode]
E --> G[Optimized Machine Code]
D -.-> E
G -.-> F

js是动态类型语言,变量只有在运行时才能确定类型,因此需要做很多类型检查(如下图),JIT会参与其中的优化。

image-20241117175902132
image-20241117175902132

什么时候优化?

当某行代码被重复执行(或执行次数达到一定阈值)后,代码会被传给JIT compiler进行优化。

怎么优化?

JIT会对这些变量的类型做出假设,删除冗余的类型检查,只保留与假设对应的,同时也上了“保险🔒”(类型保护),假设不成立的话就打回到优化前的版本(可能是解释器/其他baseline compiler)

怎么做合理的假设?

与“概率”有关,JIT通过对多次执行的代码的编译结果的存储,来实现对变量类型的合理推测,原文⬇️

The optimizing compiler uses the information the monitor has gathered by watching code execution to make these judgments. If something has been true for all previous passes through a loop, it assumes it will continue to be true.

🤔也许使用循环可能可以“欺骗”JIT做出一些优化,我猜测FuzzJIT(Security’23)的作者灵感也来源于此。

TurboFan Pipeline(V8中的JIT 承担主要的编译优化工作)

如下图

  • 首先构造AST、Bytecode,然后inline它们的JavaScript Graph
  • 简化得到的Graph,同时进行一个静态的类型分析,包括类型推断等
  • 进行类型优化,再简化Graph
  • 再进行一些其他优化(比如冗余节点消除、循环不变量外提等)

image-20241117175913086
image-20241117175913086

Turbolizer

V8的IR是一种图IR,这个工具可以将其可视化

image-20241117175920222
image-20241117175920222

V8的图IR结合的控制流与数据流,有多种edge

  • value edges,比如可以表示函数传参关系,类似数据流分析中DFG的edge

  • control edges,类似控制流分析中CFG的edge

  • effect edges比较特殊,表示“副作用”,以下图为例,虚线部分就是之前说的inline Cache,在检查是否可以命中缓存中的Map

    image-20241117175928650
    image-20241117175928650

副作用操作(如内存读写)之间可能有依赖关系。Effect Edges 表示这些依赖,帮助编译器识别哪些操作可以重排、哪些不能。

  • 标题: V8-pwn入门(3)——Inline-Cache、GC、JIT
  • 作者: Sl0th
  • 创建于 : 2024-11-17 22:18:16
  • 更新于 : 2024-11-21 16:10:48
  • 链接: http://sl0th.top/2024/11/17/V8-pwn入门-3-——Inline-Cache、GC、JIT/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论