-
-
[原创]从POC到EXP:从0基础到v8 CVE-2021-38003复现
-
发表于: 2天前 1232
-
此文章首发于奇安信攻防社区b83K9s2c8@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的方式是这样的
那我们就要在编译参数上尽可能相同,在此基础上添加部分调试参数进行编译
所以写成build.sh脚本是这样的,由于我是在docker中编译的,因此很多路径都是绝对路径,需要进行修改
如果不按照官方给的参数编译的话,有可能POC无法跑通,就直接影响后续的漏洞利用
同时,经过多次尝试,我建议在运行ubuntu 20.04或者ubuntu 22.04且运行python 3.9或者python 3.10的系统环境中构建,过高或者过低的系统/python版本都会导致编译出错。编译完之后的目录是这样子的

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

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

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

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

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

回到刚刚的程序
JSArray结构体用示意图来表示是这样的

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

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

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

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

这是c对象的信息

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

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

我们拿到的浮点数是什么呢?诶,这就是对象的地址,v8漏洞利用中我们就可以通过这个方式来泄露对象的地址。我们将这个流程封装成函数addressOf,可以这么调用
将一个存储浮点数的JSArray结构体的Map修改为对象数组对应的Map,那么我读取这个结构体的时候就能返回一个对象,我们可以通过这个功能构造一个fake Object,将这个流程封装成函数fakeObj(),可以这样调用
fake Object有什么用呢,我们可以通过这个fake Object来达到任意地址读和任意地址写的效果
获得addressOf和fakeObj原语,基本就是靠我们上一块所讲的修改浮点数JSArray的length字段以达到越界写来实现的
由于在v8漏洞中主要利用的还是浮点数的存储,因此需要一些工具函数用于大整数与浮点数之间的互转,函数定义如上,可以直接拿着用
首先我们要通过漏洞实现addressOf和fakeObj原语,同时已经泄露出了浮点数JSArray的Map值,将其定义为DOUBLE_MAP常量,随后定义or修改浮点数对象如下:
此时内存中是这样存储的

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

我们可以将其封装成read64函数
这里的addr就是我们想泄露的地址,那么写到fake_object中就应该是addr-0x10+1
这个1的产生就是我们之前说过的v8存储地址和普通程序的差异
任意地址写和任意地址读差不多,无非就是最后的从fake_object获取值改成了修改fake_object的存储的值
由于低版本v8中会给WASM一个可读可写可执行的段,因此我们可以考虑通过shellcode替换原有的WASM内容以达到执行shellcode的效果

当执行到断点时,vmmap就可以看到出现了一个可读可写可执行段,我们只需要想办法把shellcode写入这个段的开始地址,也就是0x11d80365f000,随后执行f()就可以触发shellcode
需要注意的是,在较高版本的v8中,WASM段已经不是可读可写可执行了,而是变成了可读可执行,因此就没有办法通过这个方式来进行利用了
我们回头看看之前的任意地址写,如果通过之前的方式写入shellcode会导致以下两个问题
因此我们需要一种向某个对象中写入数据不需要经过map和length的方式来实现任意地址写
调试结果如下

可以看到,本质上来说setFloat64是在向JSArrayBuffer的backing_store指向的内存中写入内容,那么我们只要通过原有的任意地址写write64控制这个字段为可读可写可执行段的开始地址,就可以通过setFloat64方法向内存中无限制写入数据
讲到这里,v8漏洞利用就差不多了,可以开始具体分析题目了,因为addressOf和fakeObj原语都和具体题目有关,不同的题目获得原语的方式也不同。获得了这两个原语才能再写read64函数和write64函数
这个CVE的POC可以从谷歌纰漏漏洞的网站找到44eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6A6M7%4y4#2k6i4y4Q4x3X3g2U0K9s2u0G2L8h3W2#2L8g2)9J5k6h3!0J5k6#2)9J5c8X3W2K6M7%4g2W2M7#2)9J5c8U0b7H3x3o6f1%4y4K6p5H3
关于漏洞产生的原理本文不过多赘述,我们关注于漏洞点的利用,也就是已知CVE如何利用漏洞
我们将最后的循环删掉,然后打印一下map.size,看看POC有没有生效

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

JSMap在底层是通过OrderedHashMap实现的,因此我们重点需要分析OrderedHashMap这个结构体,这个结构体的原理可以看这篇文章f65K9s2c8@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中
当触发map.size == -1的漏洞时,我们看一下此时新建键值对会对内存产生什么影响


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