V8远程代码执行漏洞
一、漏洞信息
1、漏洞简述
- 漏洞名称:V8远程代码执行漏洞
- 漏洞编号:CVE-2021-21220
- 漏洞类型:JIT优化导致可构造超长数组进行任意地址读写
- 漏洞影响:远程代码执行
- CVSS3.0:N/A
- CVSS2.0:N/A
- 漏洞危害等级:严重
2、组件和漏洞概述
V8是Google使用C++编写的开源高性能JavaScript和WebAssembly引擎。它被广泛用于用于Chrome和Node.js等场景中。它实现了ECMAScript和WebAssembly功能。V8既可以在Windows 7及以上版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux等操作系统上运行。也可以独立运行,还可以嵌入到任何C ++应用程序中。
3、相关链接
issue 1196683
4、解决方案
Chrome最新版本已经修复,请用户及时更新至最新版本。
二、漏洞复现
1、环境搭建
安装89.0.4389.90的Chrome浏览器
2、复现过程
1、在Chrome快捷方式->目标后面加上”–no-sandbox”,并使用该快捷方式启动Chrome,用来创建一个关闭沙箱的Chrome进程。
2、将漏洞文件拖入浏览器中执行
三、漏洞分析
1、基本信息
- 漏洞文件:instruction-selector-x64.cc
- 漏洞函数:InstructionSelector::VisitChangeInt32ToInt64
2、背景知识
(1) js中array-shift实现。
v8源码版本: V8 version 9.1.0 (candidate)
(1)array-shift.tq
1 | // Copyright 2019 the V8 project authors. All rights reserved. |
shift函数首先会判断数组的长度是否为0,如果为0,则不会对该数组进行修改;如果不为0,则会将最前面的数组元素pop,之后对数组长度减一。当然这里会有是否进行快速shift的选择[7],由于不管是否进行快速shift都不会影响这个操作结果,所以具体快速shift如何对速度进行优化不在我们的考虑范围内。
(2)smi
smi在js中为小整数,详见[5],链接中讲述了v8中不同数据类型的实现。
smi类型中的-1,0,1在内存中的表示如下
1 | pwndbg> job 0x3c6c0808846d |
可以看出-1在smi中表示为0xfffffffe,而0xfffffffe在十六进制有符号表示为-2,这块需要注意。
(3)常见数据结构
浮点型数组
1 | pwndbg> job 0x56008144335 |
根据上面的内存可以看出,浮点数的属性值是按照四字节存储的,elements在0x8的偏移,0xc的位置上存放length。elements对象的第一个元素的偏移为0x8。
“smi数组”
1 | DebugPrint: 0x367b082a9c29: [JSArray] |
这是个伪smi数组,是由于本漏洞构造的超长数组的现场。
根据上面的内存可以看出,浮点数的属性值是按照四字节存储的,elements在0x8的偏移,0xc的位置上存放length。elements对象的第一个元素的偏移为0x8。(和上面的浮点型数组内存构造是相同的)
在本漏洞利用中,可以通过相对地址写覆盖浮点型数组的长度伪超长,使浮点型数组也具有相对地址读写的能力。
(4)指针压缩
参考[89]。64位v8程序中,堆指针高32位地址值是相同的,可以看下面某次v8的调试信息,高32位的的地址值为0xdf2,这个信息存储在寄存器R13位置处。
1 | pwndbg> vmmap |
现代CPU中的分支预测器非常好,并且代码大小(尤其是执行路径长度)对性能的影响更大。
具体的实现可以参考
1 | v8 / src / common / ptr-compr.h |
3、补丁对比
根据issue里面的补丁比较链接
可以看出该bugfix的函数为ChangeInt32ToInt64,将32位整形数向64位进行拓展,修复之前的代码为判断传入的32位整型数是否为有符号从而选择movsx和mov,而修复后强制使用movsx进行有符号拓展。
4、漏洞分析
(1)POC分析
POC与执行结果如下。
1 | print = console.log; |
1 | root@ubuntu:~/v8/v8/out/x64.release# ./d8 test_jscode/d8_poc.js |
我们对POC进行分析,首先分析arr数组元素
arr[0]是unsigned int32 = 2**31
= 2147483648 = 0x8000 0000
arr[0] ^ 0会转成signed int32 = 2**31^0 = 0x8000 0000 = -2147483648,至于为什么无符号操作数与0异或变为有符号,参考[4]。
(arr[0] ^ 0) + 1会转成signed int64,按理说是先符号拓展,得到0xFFFF FFFF 8000 0000,然后再加一,得到0xFFFF FFFF 8000 0001 = -2147483647
之后会解释器执行打印函数返回值以及JIT编译执行打印函数返回值
可以看到,在经过JIT优化前与优化后,foo的返回值是不相同的;这个从上面的补丁分析中我们也已经了解了,JIT在处理代码的时候将本该有符号拓展的数进行了无符号拓展。
(2)执行流分析
SimplifiedLowering
在该阶段,通过#45 LoadTypedElement可以知道arr[0]的类型 Unsigned32,之后#31 Word32Xor处理之后类型为Signed32,之后+1需要做int32到int64的转换,调用了#58 ChangeInt32ToInt64,并将返回值与#59 Int64Constant[1]作为参数交由#50 ChangeInt32ToInt64处理。这段处理逻辑是没有问题的。
MachineOperatorOptimization
而在该阶段,将arr[0] ^ 0通过JIT在#81 Load处获取运算所得的结果,此时该结果的类型为kRepWord32[kTypeUint32],为无符号,此时仍然经过#58 ChangeInt32ToInt64进行处理。
而ChangeInt32ToInt64的处理如下
1 | ... |
由于操作数时无符号,所以这里进行了无符号拓展,此时(arr[0] ^ 0) + 1的值为0 x 0000 0000 8000 0000 + 1,为2147483649。
(3)动态验证
通过gdb验证下我们上述分析的过程,动态分析的思路为通过%DebugPrint输出得到arr元素的内存地址,然后下内存读断点,追踪到JIT处理foo函数的位置。
定位流程
1 | root@ubuntu:~/v8/v8/out/x64.release# gdb d8 |
验证静态分析结果
1 | pwndbg> c |
观察到在执行完0x4e6001c432e处指令后,RCX 的值为 0x80000000,并没有进行有符号拓展,而执行完0x4e6001c4330后,RCX 的值为 0x80000001,验证了我们得到的结果。
(4)EXP构造
(1)构造超长数组
从ChangeInt32ToInt64到将数组长度设置为-1,需要用到一种针对于JIT常用的利用技术:typer bug[6],简单的理解就是在JavaScript函数的前几次调用期间,解释器记录各种操作的类型信息,例如参数访问和属性加载。如果以后选择该函数进行JIT编译,则V8的最新编译器TurboFan会假定在所有后续调用中都将使用观察到的类型,并使用从解释器中得出的规则集将类型信息传播到JIT。
我们将poc构造如下
1 | function foo(a) { |
在LoadElimination阶段,可以看到将-1存储到#185 StoreElement,并传递给#184 StoreField 之后继续传递到#192 EffedtPhi,而调用#190 ArrayShift也会经由#192 EffectPhi处理得到的参数。
调试验证结果
1 | pwndbg> r |
(2)addressOf和fakeObject实现
之后确定arr和cor的偏移,方便通过对arr的相对地址读写进行addressOf和fakeObject的实现。
1 | var arr = x[0]; |
简单介绍下上述代码,arr[idx+10]为cor的length属性,可以直接修改该值为大于原长度的值,这样cor数组也可以进行越界读写了,cor与arr的区别在于cor的相对读写可以操作八个字节。然后是addressOf与fakeObject原语的构造,在这里,由于arr和cor可以通过不同的索引值访问相同的空间,这样通过不同类型的数组读取出来的值的类型是不一样的(元素靠数组的map来确定类型),这样很容易就可以实现类型混淆。前面确定了cor与arr的内存相对偏移,便可以进行addressOf和fakeObject的实现。
调试来验证结果
1 | pwndbg> r |
(3)任意读写原语实现
相关代码如下
1 | var float_array_map = f2big(cor[3]); |
具体相关的点都已经在代码注释中标注出来了,唯一需要注意的可能就是arr2[1]和fake[0]的换算关系如何得出,这个可以通过下面的内存状态尝试理解并编写相应的原语。
1 | 00:0000│ 0x1fa7082aaea0 ◂— 0x804222d082439f1 ; arr_map |
个人对上述的推理过程如下:
[0x28] == (2n << 32n) + addr - 8n
arr2[1] -> [0x0+0x8]+0x8+0x8 -> 0x18+0x8+0x8 -> 0x28
fake[0] -> [0x20+0x8]+0x8 -> (2n << 32n) + addr - 8n+0x8 -> (2n << 32n) + addr
这样就可以通过操作fake[0]来进行任意地址读写。
(4)wasm利用
这里就是因为wasm会生成一块rwx空间,通过向该空间写入shellcode并调用达到命令执行的目的。
代码如下
1 | //msfvenom -p linux/x64/exec CMD=whoami -f num |
最终复现如下
3、漏洞修复
issue 1196683
四、总结与思考
该漏洞分析是个人的第一个Chrome的真实漏洞分析,所以过程会比较详细,但是详细不意味着面面俱到。我始终相信,二进制漏洞分析真实的调试一定是必不可少的,我想要表达的一些东西都存在于调试信息中了,自然就没有必要进行过多的文字陈述。
由于JIT优化机制的存在,出现类似于这种类型的漏洞一定还是存在的,但是这种漏洞是无法绕过沙箱的,所以用户开启沙箱使用浏览器可以在很大程度上防止恶意代码的攻击,但仍有一些漏洞是可以绕过沙箱对宿主机进行攻击,这就需要我们对不明链接保持谨慎的习惯,这样就可以很好的避免0day浏览器漏洞对我们的损害。
该文章的成型参考了很多篇优秀的文章[123],感谢师傅们的开源精神,我也会将更多我的漏洞分析过程公开出来,为技术分享的氛围贡献一点绵薄之力。
五、参考
[1]. https://mp.weixin.qq.com/s/O81Kw-ujcbjY_1S6dFKpxQ
[2]. https://github.com/r4j0x00/exploits/tree/master/chrome-0day
[3]. https://mp.weixin.qq.com/s/yeu9IZSNrp1f_lK5oIdL9A
[4]. https://www.ecma-international.org/publications-and-standards/standards/ecma-262/
[5]. https://v8.dev/blog/elements-kinds
[6]. https://googleprojectzero.blogspot.com/2021/01/in-wild-series-chrome-infinity-bug.html
[7]. https://bugs.chromium.org/p/v8/issues/detail?id=6380
[8]. https://blog.infosectcbr.com.au/2020/02/pointer-compression-in-v8.html