首页
论坛
课程
招聘
[原创]4月13日 Chrome爆出的v8漏洞车祸原因分析(Issue 1196683,CVE-2021-21220)
2021-5-12 16:00 5697

[原创]4月13日 Chrome爆出的v8漏洞车祸原因分析(Issue 1196683,CVE-2021-21220)

2021-5-12 16:00
5697

紧接着前文: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后生成指令的逆向分析,我们知道要了解这个漏洞的原因可以分解为以下两个问题:

第一:为什么v8poc1.js进行优化后生成的指令,对x计算会出现错误,使得随后可以获得长度为1的有效数组对象var cor = new Array(x)。(前面文章,我们通过逆向分析v8poc1.js进行优化后生成的指令的过程,是在x = Math.abs(x);这一条代码语句对应的指令执行中发现了x计算错误)。

第二:为什么在v8poc1.js进行优化后,数组对象cor.shift()操作后会直接写入0xFFFFFFFE到代表其数组对象长度的内存位置。

 

写在前面:

1v8优化的漏洞利用的核心就是获得可以越界读写的数组。

2v8优化的漏洞不能直接通过二进制逆向分析寻找漏洞的真正原因,是因为最后能调试的指令只是优化的结果生成的指令,而不是优化的本身,优化的本身是如何生成这些优化指令。所幸google提供了turbofan工具来分析v8优化的各个过程。

3v8运行poc1.js加上参数 --trace-turbo 会生成一个.json文件的,这个.json文件会记录优化的各个阶段,我们使用turbofan工具时打开该.json文件时,看到的图表只会显示某个优化过程的部分节点。

我们可以先找到显示的要分析的目标节点附近节点,再一步步追踪到我们需要关注的节点(如图 1.1.11.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):v8poc1.js优化后,执行x = Math.abs(x)这一句代码对应的指令之前的部分就已经把他作为无符号数处理了,也就是这句代码的前一句,x = (_arr[0] ^ 0) + 1这句生成的对应的指令就已经是错误的,在往前就没有有代码了。

       b):v8poc1.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!")

v8poc2.js优化后  x = (_arr[0] ^ _arr_0[0]) + 1;这一句代码对应的指令如下图所示                                                       

                                                                                 图1.2.1

                                                                                图1.2.2

如图1.2.2所示,v8poc2.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扩展为64rcx进行接下来的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.jsx = (_arr[0] ^ 0) + 1; 这句代码对应的所有节点。(不同环境可能会不同)

                                                                          图2.1.1

接着我们从return节点往上找

                                                                       图2.1.2                 

2.1.22.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:StoreElement97: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.jscor.shift()这句代码的优化,如图2.2.2所示,这里可以看到,后面已经直接将-2(0xFFFFFFFE)通过162: StoreField[+12]写进了数组代表其长度的内存中了

在这里可以回答开篇的第二个问题了:

即:为什么在v8对poc1.js进行优化后,数组对象cor.shift()操作后会直接写入0xFFFFFFFE到代表其数组对象长度的内存位置

因为在v8运行poc1.js的过程中一直反复生成长度为0的数组对象cor,因此在这个优化阶段,v8已经认为cor的数组一定是无效数组对象,因为认定cor0cor.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类型

然后经过206ChangeInt32ToInt64,扩展为64位再进行+1操作。

                                                                                 图2.3.2

v8在这个阶段的优化中对poc3.jscor.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相关的符号。后面的4689断点按英文意思判断是对节点判断的函数,不是我们关心的,把这几个断点排除,接下来利用命名空间和调试可以判断VisitChangeInt32ToInt64是选择指令的函数,也是我们需要分析的ChangeInt32ToInt64的实现。

3.2对VisitChangeInt32ToInt64的研究分析:

WinDbg在这里断点指向了源码的

void InstructionSelector::VisitChangeUint32ToUint64(Node* node)是有点问题的

                                                                    图3.2.1

3.2.1:由于看这里指令跳转分析源代码会很乱,无法直接借助WinDbg分析代码的执行流程,因此接下来我们使用IDAx64debug来定位代码执行流程。

用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指令

第四部分:漏洞原因总结:

对开篇问题的回答:

第一:为什么v8poc1.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一定为0cor.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








 







第五届安全开发者峰会(SDC 2021)10月23日上海召开!限时2.5折门票(含自助午餐1份)

最后于 2021-5-13 10:08 被苏啊树编辑 ,原因:
收藏
点赞1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回