-
-
[原创]4月13日 Chrome爆出的v8漏洞车祸原因分析(Issue 1196683,CVE-2021-21220)
-
2021-5-12 16:00 8588
-
紧接着前文:https://bbs.pediy.com/thread-267128-1.htm#1687388
这里将前文的poc.js命名为poc1.js,因为这篇文章会有其他该poc的简单变种。
poc1.js为:
const _arr = new Uint32Array([2**31]); function foo() { var x = 1; x = (_arr[0] ^ 0) + 1; x = Math.abs(x); //前文在这里发现了x计算的错误 x -= 2147483647; x = Math.max(x, 0); x -= 1;// if(x==-1) x = 0; var cor = new Array(x); cor.shift();//前文这里发现了将-1写进代表长度的内存 var arr = [1.1, 1.2, 1.3]; return [cor, arr]; } console.log("ready !!"); for(i=0;i<0x3000;i++) { foo(); } %SystemBreak(); var x = foo(); var cor=x[0]; var arr=x[1]; %DebugPrint(corr); %DebugPrint(arr); console.log("Analyze Over!") ;
通过前面对v8优化poc1.js后生成指令的逆向分析,我们知道要了解这个漏洞的原因可以分解为以下两个问题:
第一:为什么v8对poc1.js进行优化后生成的指令,对x计算会出现错误,使得随后可以获得长度为1的有效数组对象var cor = new Array(x)。(前面文章,我们通过逆向分析v8对poc1.js进行优化后生成的指令的过程,是在x = Math.abs(x);这一条代码语句对应的指令执行中发现了x计算错误)。
第二:为什么在v8对poc1.js进行优化后,数组对象cor.shift()操作后会直接写入0xFFFFFFFE到代表其数组对象长度的内存位置。
写在前面:
1:v8优化的漏洞利用的核心就是获得可以越界读写的数组。
2:v8优化的漏洞不能直接通过二进制逆向分析寻找漏洞的真正原因,是因为最后能调试的指令只是优化的结果生成的指令,而不是优化的本身,优化的本身是如何生成这些优化指令。所幸google提供了turbofan工具来分析v8优化的各个过程。
3:v8运行poc1.js加上参数 --trace-turbo 会生成一个.json文件的,这个.json文件会记录优化的各个阶段,我们使用turbofan工具时打开该.json文件时,看到的图表只会显示某个优化过程的部分节点。
我们可以先找到显示的要分析的目标节点附近节点,再一步步追踪到我们需要关注的节点(如图 1.1.1和1.1.2操作)。用这操作可以用turbofan分析每个优化阶段的详细过程。
图 1.1.1
图 1.1.2
第一部分,模拟正确JIT优化过程:
既然是车祸原因分析嘛,就先来个简单车祸现场模拟。
如果对计算机和v8符号数的处理机制,以及x64汇编指令有足够的了解的话,可以直接定位错误的位置,这一步可以省略应该,但本人对这些知识不太熟悉,因此用自己的思路解决问题。
这里主要的方式是通过对构造相似的js代码,模拟v8正确优化poc1.js的情况下生成指令,然后对该指令进行逆向分析。
1.1:v8优化错误的指令定位:
车祸分析,最开始的是确定在那个路口出现问题。
在前面动态调试之中,我们是因为在v8在对poc1.js优化后的,其语句x = Math.abs(x); 对应的指令执行时,返回了其参数自身,从而发现其是将参数处理产生错误的。
图1.1.3
如图1.1.3所示的ecx=0x80000001。
这里是我们发现错误的位置,但这不代表这就是错误的开始位置。
这里列举2个可以想到的原因:
a):v8在poc1.js优化后,执行x = Math.abs(x)这一句代码对应的指令之前的部分就已经把他作为无符号数处理了,也就是这句代码的前一句,x = (_arr[0] ^ 0) + 1这句生成的对应的指令就已经是错误的,在往前就没有有代码了。
b):v8在poc1.js优化后,生成的x = Math.abs(x) 这一句代码对应的指令是错误的。
1.2:模拟v8正确的优化:
接下来是对错误指令位置的验证:
这里采用的参照的方法
猜测poc1.js错误的优化和使用0这个自然数有关, 因此可以尝试将常数0用一个Uint32Array变量来存储,让(_arr[0] ^ 0) +1这句代码得到v8正确优化。(也可以尝试别的办法)
因此这里简单修改poc1.js构造poc2.js,获得正确优化情况下的指令:
const _arr = new Uint32Array([2**31]); const _arr_0 = new Uint32Array([0]); function foo() { var x = 1; x = (_arr[0] ^ _arr_0[0]) + 1; x = Math.abs(x); x -= 2147483647; x = Math.max(x, 0); x -= 1;// if(x==-1) x = 0; var cor = new Array(x); cor.shift(); var arr = [1.1, 1.2, 1.3]; return [cor, arr]; } console.log("ready !!"); for(i=0;i<0x3000;i++) { foo(); } %SystemBreak(); var x = foo(); var cor =x[0]; var arr=x[1]; %DebugPrint(corr); %DebugPrint(arr); console.log("Analyze Over!")
v8对poc2.js优化后 x = (_arr[0] ^ _arr_0[0]) + 1;这一句代码对应的指令如下图所示:
图1.2.1
图1.2.2
如图1.2.2所示,v8对poc2.js进行优化以后,代码x = (_arr[0] ^ _arr_0[0]) + 1;这一句代码生成的对应的指令为:
xor ecx, dword ptr ds:[rdi]
movsxd rcx, ecx
add rcx,1
这里我们发现v8(可能是x64架构的别的软件也这么处理)将有符号32位数转移到64位寄存器中,正确的处理指令为movsxd。
处理结果为:FFFFFFFF80000000,带有符号扩展,然后再add rcx,1,这样是得到的正确的结果,我们推测这个是v8期望的优化处理结果。
而在v8在优化poc1.js后,生成的x = (_arr[0] ^ 0) + 1; 这句js对应的指令如下:
图1.2.3
如图1.2.3所示:
生成的指令为
mov ecx, dword ptr ds:[rcx]
add rcx, 1
这里直接把_arr[0] ^ 0优化为一个值,然后传递值是采用了mov这个无符号扩展指令,也没有其他的任何符号处理措施,就进行将32位数ecx扩展为64位rcx进行接下来的add rcx,1操作。
图1.2.4
在这里我们发现车祸真正开始出现问题的路口了。
v8优化poc1.js后,生成的x = (_arr[0] ^ 0) + 1;这句代码对应生成的指令是将的_arr[0]^0优化为固定的结果放入内存中,但取出来进行接下来的操作时出现了问题。这里在传递过程中没有对符号位进行处理就直接进入接下来add rcx, 1运算,导致结果错误出现错误。
换句话说在poc1.js的优化中,v8应该采用movsxd这样带有符号扩展的传值指令,而不是使用无符号扩展的mov指令,这是导致我们看到的,在绝对值操作之后产生错误数值的根本原因。
但是为什么会这样呢,为什么v8优化poc1.js时对x = (_arr[0] ^ 0) + 1;这句代码的_arr[0]^0结果从32位扩展为64位时,选择错误的mov指令,而不是使用正确的movsxd来传递数据呢?这像是探索出现车祸背后的交通规则设计的缺陷。
单靠逆向优化后的指令是无法知晓这个问题的答案的。这里就要就要借助google提供的turbofan,对优化的重要阶段进行分析;
第二部分:对poc1.js优化过程的turbofan进行分析:
这里对poc1.js进行简单的修改,tubofan分析变得简单一点,这里将这新文件命名为poc3.js。
const _arr = new Uint32Array([2**31]); function foo() { var x = 1; x = (_arr[0] ^ 0) + 1; x = Math.abs(x); x -= 2147483647; x = Math.max(x, 0); x -= 1;// if(x==-1) x = 0; var cor = new Array(x); cor.shift(); return cor; } for(i=0;i<0x3000;i++) { foo(); } var cor = foo(); console.log(cor.length);
2.1:TFTyper阶段分析
2.1.1:在我这环境中用turbofan查看v8生成的.json文件是顺着70:Branch[None, NoSafetyCheck]节点往上看,可以找到v8优化后的poc1.js中x = (_arr[0] ^ 0) + 1; 这句代码对应的所有节点。(不同环境可能会不同)
图2.1.1
接着我们从return节点往上找
图2.1.2
2.1.2:图2.1.2 中的162: StoreField[+12]该节点为重要节点,是cor.shift()这句代码优化的重要部分,StoreField[+12]也就是偏移12的位置存放数值,根据对这图表的分析可以知道这节点代表的是往cor数组对象的+12的位置存放数据,而这个内存位置代表的正是数组的长度。
我们需要的是知道是后面阶段是如何优化的,最后为什么会直接写进0xFFFFFFFE这个数值。
图2.1.3
2.1.3:从图2.1.4可以看出,v8优化poc3.js的这个阶段过程是,18:Branch[false,safeCheck]判断为ifFalse时就会进入85:JSCreateArray阶段,由于Call[Code:ArrayShift]然后会有162:StoreField[+12]操作。
也就是说cor.shift()这句的优化过程是先会先创建一个数组对象,然后用StoreField[+12]这个操作对数组大小的内存进行填充改写。
图2.1.4
如图2.1.4所示,162:StoreField[+12]填充数据为161:NumberSubtract运算结果,其输入节点为为136:LoadField[+12],13:NumberConstant[1]。
也就是说在StoreField[+12]这个节点的操作,是取出原来内存的值再减1然后就直接return返回了。
图2.1.5
如图2.1.5所示:163:StoreElement到97:Return之间没有再进行数组合法性检查。
2.2:TFSimplifiedLoweringPhase阶段分析
图2.2.1
v8在这个阶段的优化中对poc1.js 的x = (_arr[0] ^ 0)+1;这句代码的优化,如图2.2.1所示,比上个阶段多插入了一个ChangeIn32ToInt64,也就是将_arr[0]^0结果扩展到64位,然后进行+1操作,接着进行Float64Abs绝对值计算。
图2.2.2
v8在这个阶段的优化中对poc3.js中cor.shift()这句代码的优化,如图2.2.2所示,这里可以看到,后面已经直接将-2(0xFFFFFFFE)通过162: StoreField[+12]写进了数组代表其长度的内存中了。
在这里可以回答开篇的第二个问题了:
即:为什么在v8对poc1.js进行优化后,数组对象cor.shift()操作后会直接写入0xFFFFFFFE到代表其数组对象长度的内存位置
因为在v8运行poc1.js的过程中一直反复生成长度为0的数组对象cor,因此在这个优化阶段,v8已经认为cor的数组一定是无效数组对象,因为认定cor为0,cor.shift()就将原本长度为0的数值再减1,结果就是-1这个固定的值。在优化后就直接将0xFFFFFFFE这个值写进代表数组长度的内存。因为已经认定cor数组是不合法,所以直到返回也没有再进行数组合法性的检查。
也就是说从这个阶段以后,无论前面数组cor怎么变,这个数组的长度都会被判定为0xFFFFFFFFF。这时候如果我们cor合法,我们就可以得到一个大小为0xFFFFFFFFF(内存中的数值0xFFFFFFFFE除以2)的数组。
2.3:TFEarlyOptimization阶段
图2.3.1
v8在这个阶段的优化中对poc1.js 的x = (_arr[0] ^ 0)+1;这句代码的优化,如图2.3.1所示,已经直接跳过了_arr[0]^0这个操作,变成直接取_arr[0],这两个结果数值虽然相同,但这里有个隐含的变化,_arr[0]^0的结果为Int32类型,而我们申请的_arr[0]为UInt32类型。
然后经过206:ChangeInt32ToInt64,扩展为64位再进行+1操作。
图2.3.2
v8在这个阶段的优化中对poc3.js中cor.shift()的处理,如图2.3.2所示,和上一个阶段相比没有什么变化,后面的阶段也没再发生什么变化。
在接下来的阶段,对poc3.js这两句我们最关心的这两句js代码的优化都没发生什么变化,因此这个ChangeInt32ToInt64处理过程就很可能就是漏洞关键。
这里我们已经知道开篇提到的第二个问题的答案,现在要知道第一个问题的答案,也就是为什么选择了mov指令传递数据。
这里的问题似乎是发生在ChangeInt32ToInt64这个处理节点上。(通过前面分析我们知道了经过优化后其输入为_arr[0],是一个UInt32类型)
这个ChangeInt32ToInt64处理过程也就是我们需要研究的导致车祸的具体规章流程。
第三部分:对ChangeInt32ToInt64的探索
3.1 查找ChangeInt32ToInt64的函数实现:
图3.1.1
如图3.1.1,我们可以借助Windbg符号查找的功能,可以看到所有的ChangeInt32ToInt64相关的符号。后面的4,6,8,9断点按英文意思判断是对节点判断的函数,不是我们关心的,把这几个断点排除,接下来利用命名空间和调试可以判断VisitChangeInt32ToInt64是选择指令的函数,也是我们需要分析的ChangeInt32ToInt64的实现。
3.2对VisitChangeInt32ToInt64的研究分析:
WinDbg在这里断点指向了源码的
void InstructionSelector::VisitChangeUint32ToUint64(Node* node)是有点问题的
图3.2.1
3.2.1:由于看这里指令跳转分析源代码会很乱,无法直接借助WinDbg分析代码的执行流程,因此接下来我们使用IDA和x64debug来定位代码执行流程。
用IDA代开v8查看void InstructionSelector::VisitChangeInt32ToInt64(Node* node)
图3.2.2
3.2.2:这里用x64debug运行可以看到程序进入的判断:
图3.2.2
这里也就是我们源代码中的图3.2.3所在蓝色标注的位置:
图3.2.3
3.2.3:如图3.2.3代码所示,因为通过优化以后,我们传递进去的类型为UInt32,所以这里
opCode =load_rep.IsSigned()?kX64Movsxlq:kX64Movl;会返回的是无符号扩展传送指令kX64Movl,而不是kX64Movsxlq,也就是最终会选择了mov指令,而不是movsxd指令。
第四部分:漏洞原因总结:
对开篇问题的回答:
第一:为什么v8对poc1.js进行优化后生成的指令,对x计算会出现错误,使得随后可以获得长度为1的有效数组对象var cor = new Array(x)。
这是因为v8优化poc1.js过程中,会将x = (_arr[0] ^ 0) + 1;这句代码的_arr[0]^0优化为_arr[0],将这里产生的结果从Int32有符号32位整型改变为Uint32无符号32位整型,然后在接下来的运算之中要扩展为64位,然后进行+1操作。
在这里优化过程扩展为64位的指令选择是通过
void InstructionSelector::VisitChangeInt32ToInt64(Node* node)这个函数来判断的,
而这个函数输入的node节点如果为Signed类型,则返回movsxd这个有符号扩展的指令,如果为Unsigned类型,则返回mov这个无符号扩展的指令。因为_arr[0]为UInt32类型,所以最后返回了mov这个无符号扩展的指令作为扩展到64位的操作符,变得没有在扩展过程中对符号有进行任何处理,导致接下来计算错误,最后导致x=1,结果是生成有效的数组。
第二:为什么优化后,在数组对象cor.shift()操作后会直接写入0xFFFFFFFE到其数组对象的长度的内存位置。
因为在v8运行poc1.js的过程中一直反复生成长度为0的数组对象cor,因此优化阶段时,v8已经认为cor的数组一定是无效数组对象,因为认为cor一定为0,cor.shift()就将原本长度为0的数值再减1,结果为-1这个固定的值。在优化后直接将0xFFFFFFFE这个值写进代表数组长度的内存。因为已经认定cor数组是不合法,所以直到返回也没有再进行数组合法性的检查。
当我们意外生成一个有效的cor数组时,实际上就拥有了长度为0xFFFFFFFF的数组。
这就是这个漏洞产生的根本原因。
这里除了官方的打的补丁的位置,还有cor.shift()这个的优化是有问题的,shift()这个操作在优化的时候在断定数组长度为0的情况下还会进行长度-1。
参考:
https://doar-e.github.io/blog/2019/01/28/introduction-to-turbofan/
https://iamelli0t.github.io/2021/04/20/Chromium-Issue-1196683-1195777.html#rca-of-issue-1196683
[2023春季班]《安卓高级研修班(网课)》月薪两万班招生中~