从上次做starCTF的OOB之后,对v8有了非常浅显的认识,于是想回看一下数字经济线下的Chrome一题,却发现自己对于v8还是知之甚少,以至于看不懂exp

故,站在前人的肩膀上,学习一下v8

文章参考:

编译器

v8有两个编译器,一个是Full Compiler, 一个是CRANKSHAFT Compiler

FC

Full Compiler的作用是尽快生成原生代码,以保持页面始终快速运转。它并不会把代码编译成为字节码,而是将其转换为IR

它使用内联缓存(Inline cache,IC)来实现读取、存储、函数调用、二元运算符、一元运算符、比较运算符以及ToBoolean隐操作符。目的是为了解决“一个简单的操作却有上百种运行时情况”的情况

Crankshaft

Crankshaft是v8的优化编译器。当FC产生的代码运行过一段时间之后,v8会挑选出那些被执行次数多的函数,重新使用Crankshaft来编译,使其执行效率提高

对象表示

直接在内存里看一下

用gdb运行d8后

1
2
3
4
5
6
d8> var obj = {a:1,b:2}
undefined
d8> %DebugPrint(obj)
0x1b9ade54dd19 <Object map = 0x13de2b94ab89>
{a: 1, b: 2}
d8> %SystemBreak()

然后用job指令打印一下obj的结构

1
2
3
4
5
6
7
8
9
pwndbg> job 0x1b9ade54dd19
0x1b9ade54dd19: [JS_OBJECT_TYPE]
- map: 0x13de2b94ab89 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x279242542091 <Object map = 0x13de2b940229>
- elements: 0x28b52d880c71 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x28b52d880c71 <FixedArray[0]> {
#a: 1 (const data field 0)
#b: 2 (const data field 1)
}

看一下prototype的结构,发现其中是许多函数,这或许是该对象支持的一些成员函数了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pwndbg> job 0x279242542091
0x279242542091: [JS_OBJECT_TYPE] in OldSpace
- map: 0x13de2b940229 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x28b52d8801d9 <null>
- elements: 0x28b52d880c71 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x279242542fb1 <PropertyArray[9]> {
#constructor: 0x2792425420c9 <JSFunction Object (sfi = 0xde2a0e457e9)> (data field 0)
#__defineGetter__: 0x279242543191 <JSFunction __defineGetter__ (sfi = 0xde2a0e460c9)> (const data field 1)
#__defineSetter__: 0x2792425431c9 <JSFunction __defineSetter__ (sfi = 0xde2a0e46121)> (const data field 2)
#hasOwnProperty: 0x279242543201 <JSFunction hasOwnProperty (sfi = 0xde2a0e46179)> (const data field 3)
#__lookupGetter__: 0x279242543009 <JSFunction __lookupGetter__ (sfi = 0xde2a0e461d1)> (const data field 4) properties[0]
#__lookupSetter__: 0x279242543041 <JSFunction __lookupSetter__ (sfi = 0xde2a0e46229)> (const data field 5) properties[1]
#isPrototypeOf: 0x279242543079 <JSFunction isPrototypeOf (sfi = 0xde2a0e46281)> (const data field 6) properties[2]
#propertyIsEnumerable: 0x2792425430b1 <JSFunction propertyIsEnumerable (sfi = 0xde2a0e462d9)> (const data field 7) properties[3]
#toString: 0x2792425430e9 <JSFunction toString (sfi = 0xde2a0e46339)> (const data field 8) properties[4]
#valueOf: 0x279242543121 <JSFunction valueOf (sfi = 0xde2a0e46371)> (const data field 9) properties[5]
#__proto__: 0x279242542f29 <AccessorPair> (const accessor descriptor)
#toLocaleString: 0x279242543159 <JSFunction toLocaleString (sfi = 0xde2a0e46459)> (const data field 10) properties[6]
}

对于数组,它是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0x7731050f121: [JSArray]
- map: 0x2b3af1082d99 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x28c976451111 <JSArray[0]>
- elements: 0x07731050f0a1 <FixedArray[5]> [PACKED_SMI_ELEMENTS (COW)]
- length: 5
- properties: 0x362609780c71 <FixedArray[0]> {
#length: 0x3453a79001a9 <AccessorInfo> (const accessor descriptor)
}
- elements: 0x07731050f0a1 <FixedArray[5]> {
0: 1
1: 2
2: 3
3: 4
4: 5
}

看一下Elements,发现是数组的成员

1
2
3
4
5
6
7
8
9
pwndbg> job 0x07731050f0a1
0x7731050f0a1: [FixedArray]
- map: 0x362609780851 <Map>
- length: 5
0: 1
1: 2
2: 3
3: 4
4: 5

定义对象的时候,如var obj={a:1},实际上会调用a的toString函数,将a转换成’a’,然后当作属性名

而数组就是以一系列非负连续整数命名的对象

对于大型的对象类型的数组,v8会使用哈希表来存储

In-object slack tracking

v8是怎么知道改为一个对象留多少空间的呢,毕竟我们不希望它频繁地realloc,也不希望为一个小的对象取分配一个大的内存

v8使用了名为In-object slack tracking的过程来查明每个构造函数的实例的适当大小

最初,构造函数分配的对象有大量内存。一旦从同一构造函数中分配了一定数量的对象(,V8通过从初始map遍历过渡树来检查这些初始对象的最大大小。新的对象被分配有足够的内存来存储这个最大数量的属性。最初的对象也使用一个巧妙的技巧调整大小。第一次分配初始对象时,它们的字段将被初始化,以便垃圾收集器认为它们是可用空间。垃圾收集器实际上并不将它们视为可用空间,因为map指定了对象的大小。但是,当In-object slack tracking过程结束时,新的实例大小会写入转换树中的map,因此具有这些map的对象实际上会变小。由于未使用的字段看起来已经像可用空间,因此不需要修改初始对象。

Methods and Prototypes

js没有类,但是它可以用如下的方式来做到类似类的效果

1
2
3
4
5
6
7
8
9
function Point(x, y)
{
this.x = x;
this.y = y;
this.xplusy = function(){return x + y;}
}

var p = new Point(1,2);
p.xplusy();

这是用了匿名函数,如果把xplusy函数当作普通的对象内的字段的话,它会浪费许多的空间,因为每个对象都要花费一些空间来指向相同的东西。

为了避免空间的浪费,js实现了类似于c++的虚表的技术,它用maps来当作虚表

js还提供了共享字段的另一种方法

1
2
3
4
5
6
7
function Point(x, y)
{
this.x = x;
this.y = y;
}

Point.prototype.xplusy = function(){return this.x + this.y}

数组

根据规范,js中的所有数字都是64位浮点数,v8中使用31位有符号整数来表示数字,这将有助于垃圾回收器区分数字和指针

如果索引值超过了数组的最大长度,数组会以字典模式来将其存取

如果要赋值数组,应当避免从后往前赋值,因为这几乎肯定会触发字典模式(为什么?)

因为命名属性和元素是分开存储的,所以即使对象进入数组的字典模式,命名属性已经可以快速地访问

垃圾回收器

基础

垃圾回收器要解决的根本问题是如何找到“死”的区域。一旦识别出死的区域,这些区域就会被重新使用或者返还给操作系统。当一个对象被根对象或者其他存活的对象所引用的话,它就是活的。根对象被定义为活的,他们是一些被v8或者浏览器直接引用的对象。全局对象是根对象,因为他们总是可访问的。浏览器的对象,例如DOM元素,也是根对象,尽管他们有时候只是被弱引用了。

堆组织

v8的堆由一系列区域组成

  • New-space: Most objects are allocated here. New-space is small and is designed to be garbage collected very quickly, independent of other spaces.
  • Old-pointer-space: Contains most objects which may have pointers to other objects. Most objects are moved here after surviving in new-space for a while.
  • Old-data-space: Contains objects which just contain raw data (no pointers to other objects). Strings, boxed numbers, and arrays of unboxed doubles are moved here after surviving in new-space for a while.
  • Large-object-space: This space contains objects which are larger than the size limits of other spaces. Each object gets its own mmap’d region of memory. Large objects are never moved by the garbage collector.
  • Code-space: Code objects, which contain JITed instructions, are allocated here. This is the only space with executable memory (although Codes may be allocated in large-object-space, and those are executable, too).
  • Cell-space, property-cell-space and map-space: These spaces contain Cells, PropertyCells, and Maps, respectively. Each of these spaces contains objects which are all the same size and has some constraints on what kind of objects they point to, which simplifies collection.

每个空间都由一组页组成,通过mmap从操作系统分配。页是内存中连续的内存块,它的大小适中为1MB,且始终以1MB对齐。但在Large-object-space中,页可能会更大

为了存储对象,页包含了一个头部(由各种flag和元数据)和一个标志位图(用来表明哪个对象是活的)

每页都有一个槽缓冲,分配在独立的内存中,形成了一个对象列表,可以指向页上存储的对象,这通常被称为记忆集

发现指针

v8采用标记指针的方法来区分数据和指针,指针的最末位必定为1,而数据的最末位必定为0

分代回收

在绝大多数程序中,大部分对象的存活时间比较短,小部分对象的存活时间则更长

为了利用这种现象,v8将堆分为两代,新生代和老生代

新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)

默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。具体的计算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由两块reserved_semispace_space_组成,每块16MB(64位)或8MB(32位)

新生代

新生代的垃圾回收算法时Scavenge算法,其主要采用了Cheney算法

Cheney算法将新生代空间分为两个“半空间”,一为from空间,一为to空间。分配对象时,先在from空间进行分配。当开始执行垃圾回收算法的时候,先检查from空间对象的存活情况,然后将存活的对象复制到to空间中,而死亡的对象则被直接释放。复制完成后,to空间就变成了from空间,而from空间也就变成了to空间。

这种算法对于新生代来说是极好的,因为新生代很小,且新生代中的绝大多数对象都是非活跃对象,所以其时间效率十分理想。复制的过程采用的是广度优先遍历的思想,从根对象触发,广度优先遍历所有能到达的对象。

很明显它是牺牲空间换取时间的算法

当一个对象进行多次新生代的清理依旧存货,他就会被移动到老生代,这被称为晋升,具体移动的标准有两种:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中
  2. 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代

老生代

老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,而且老生代占用的内存较多

老生代的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合

Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段

在标记阶段,它会遍历堆中的对象,并标记存活的对象。每个页都有一个用来标记对象的位图,位图中的每一位对应内存页的一个字。这个位图需要占据一定的空间(32位下为3.1%,64位为1.6%)。另有两位用来标记对象的状态,状态有三种,黑,白,灰。

  • 白:表示它未被垃圾回收器发现
  • 灰:表示它已经被垃圾回收器发现,但其邻接对象尚未全部处理
  • 黑:表示它不仅被垃圾回收器发现,其临界对象也被处理完毕

其采用深度优先搜索

在清除阶段,它会清楚没有被标记的对象

但是这样会产生很多内存碎片

Mark-Compact(标记整理)

标记整理会清楚内存碎片所带来的问题,它会整理内存碎片,讲活着的对象向内存的一段移动,移动完成后直接清理掉边界外的内存

总结

东西挺多,这几天也就光看了这些东西,看了好几遍,体会比较深

一来,学习了许多思想,这将对我以后开发有所帮助

二来,大概学习了v8的垃圾回收机制,对CTF有帮助