V8-pwn入门(3)——Inline-Cache、GC、JIT
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 Handler
和Map
加入缓存,之后第三次访问obj.X
速度会大大提升。
Monomorphic(单态)
此时需要缓存的只有一种map,也就是说一个object被定义后,不会添加/删除property,也就不会改变Hidden Class(Map)
举个例子🌰
1 | // 利用--print-bytecode可以看字节码 |
可以看到生成的字节码很简单,就是用一个
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 Handler
和Map
加入缓存,这个836就是IC-Hit Handler
(Handler是V8封装的指针)
- IC-Hit Handler 是指当 Inline Cache机制匹配上之前缓存的信息时,直接触发执行的处理逻辑
- 如果当前操作模式和缓存的信息匹配(IC hit),V8 会直接使用缓存的 Handler 来执行该操作,而不是重新进行属性查找或类型判断。
触发JIT的情况
把循环次数加大到一定数量,就会触发JIT优化,会将hot code直接转换成汇编
1 | let o = {sloth : 1440}; |
具体汇编逻辑类似
1 | mov ecx, "sloth" |
polymorphic(多态)
简单来说就是上面的getSloth函数被多次调用,但每次传入的obj的properties都和上次不一致(对标2-4个Map的情况),下面的例子是map1->map2->map1->map2,那么就要生成一系列的map check比较,以判断选择哪个map的Inline Cache
1 | let o = {sloth : 1440}; |
生成的汇编逻辑类似
1 | mov ecx, "sloth" |
Megamorphic(超态)
超过了4个Map(或引擎设计的多态缓存的容量),V8懒得管理了,此时缓存机制切换为通用查找,而非特定类型优化
一些特殊情况——访问一个继承的property
术语补充:
- Receiver 是执行属性访问或方法调用时,实际的目标对象
- Holder 是实际存储目标属性的对象。它表示属性访问时的查找终点
举个例子,下面的js code中,以访问obj2.X
为例,实际进⾏属性访问的对象obj2是receiver,⽽真正持有属性X的对象obj是holder。
1 | let obj = {X : 1}; |
💡学过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 | graph LR |
根据Object的存活时间,分为Young/Old Generation,除此之外的Other区域不属于任何一个Generation,老版本中Other区域其实是Large Object Space,放一些大小超过一定阈值的Object
Young Generation
结构如下
1 | graph TD |
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
2To: obj-a | obj-b | obj-c | ... | obj-end |
From: Empty显然To Space放不下了,因此把To Space的内容和From Space交换(因为From Space是空的,相当于清空ToSpace),然后在From Space中依次判断每个Object是否存活,比如:
1
2To: 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
2To: 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 | graph TD |
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算法,但后续利用中不涉及,感兴趣可以看
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
的”晋升”🔝)
我们也可以用下面的流程图来表示这个过程
1 | graph TD |
🤓👆🏻那么现在还有一个问题,我们该怎么形成这个记录集🤔,这里就又要引入一个写入屏障(Write Barrier)的概念,在更新对象间的指针时候(发生引用关系变化,比如执行obj.x=...
),就会触发write_barrier,其实现的代码逻辑如下
1 | write_barrier(obj, 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 | graph TD |
js是动态类型语言,变量只有在运行时才能确定类型,因此需要做很多类型检查(如下图),JIT会参与其中的优化。
什么时候优化?
当某行代码被重复执行(或执行次数达到一定阈值)后,代码会被传给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
- 再进行一些其他优化(比如冗余节点消除、循环不变量外提等)
Turbolizer
V8的IR是一种图IR,这个工具可以将其可视化
V8的图IR结合的控制流与数据流,有多种edge
value edges,比如可以表示函数传参关系,类似数据流分析中DFG的edge
control edges,类似控制流分析中CFG的edge
effect edges比较特殊,表示“副作用”,以下图为例,虚线部分就是之前说的inline Cache,在检查是否可以命中缓存中的Map
副作用操作(如内存读写)之间可能有依赖关系。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 进行许可。