CVE-2020-16040原理解析

1.Background

V8是Chromium内核中的JavaScript引擎,负责对JavaScript代码进行解释优化与执行,而CVE-2020-16040是V8优化编译器Turbofan在SimplifiedLowering阶段产生的一个整数溢出漏洞 (源自src/compiler/machine-operator-reducer.cc)

V8引擎CVE-2020-16040原理解析_第1张图片

其中Parser(解析器)、Ignition(解释器)、TurboFan(编译器) 是 V8 中三个主要的工作模块

Parser(解析器):负责通过对源代码进行词法分析得到的token对源代码进行语法错误分析、转换为AST抽象语法树并确定词法作用域

Ignition(解释器):负责将 AST 转换为中间代码即字节码(Bytecode)并逐行解释执行

TurboFan(优化编译器):负责将字节码和一些分析数据作为输入并生成优化的机器代码

接下来将重点讲述TurboFan

TurboFan优化编译器

我们主要关注 Ignition TurboFan 的交互

CVE-2020-16040原理解析_第2张图片

Ignition JavaScript 代码转换为字节码后,代码开始执行,V8 会一直观察 JavaScript 代码的执行情况,并记录执行信息,如每个函数的执行次数、每次调用函数时,传递的参数类型等。如果一个函数被调用的次数超过了内设的阈值,监视器就会将当前函数标记为热点函数(Hot Function,并将该函数的字节码以及执行的相关信息发送给 TurboFanTurboFan 会根据执行信息做出一些进一步优化此代码的假设,在假设的基础上将字节码编译为优化的机器代码。如果假设成立,那么当下一次调用该函数时,就会执行优化编译后的机器代码,以提高代码的执行性能。当某一次调用传入的信息变化时,表示 TurboFan 的假设是错误的,此时优化编译生成的机器代码就不能再使用了,于是进行优化回退,重走原复杂函数逻辑。

Turbofan优化过程

Turbofan优化过程首先会进入SimplifiedLowering阶段,通过正反向遍历确定字节码树(Bytecode Graph Tree)中各节点的输入类型、输出类型、最大取值范围等,其共分为三个⼦阶段PropagateRetypeLower

优化示例

考虑代码

function sum(a,b){
	return a+b;
}

function manysum(x,y){
	for(var i =0;i<0x10000;i++){
		sum(x,y)
	}
}

JavaScript 是基于动态类型的,a b 可以是任意类型数据,当执行 sum 函数时,Ignition 解释器会检查 a b 的数据类型,并相应地执行加法或者连接字符串的操作。

如果 sum 函数被调用多次,每次执行时都要检查参数的数据类型是很浪费时间的。此时 TurboFan 就出场了。它会分析监视器收集的信息,如果以前每次调用 sum 函数时传递的参数类型都是数字,那么 TurboFan 就预设 sum 的参数类型是数字类型,然后将其编译为机器指令。

就可以将manysum函数优化为简单的加法:

add eax,ebx;

2.漏洞解析

 

Looking at the commit message, we see that it states the following:

[compiler] Fix a bug in SimplifiedLowering ​ 
SL's VisitSpeculativeIntegerAdditiveOp was setting Signed32 as restriction type even when relying on a Word32 truncation in order to skip the overflow check. This is not sound.

SL的VisitSpeculativeIntegerAdditiveOp将Signed32设置为限制类型,甚至在依赖Word32截断以跳过溢出检查时也是如此。这是不对的。

所以我们知道补丁修改了一个函数:VisitSpeculativeIntegerAdditiveOp,并且还包含有如下注释

+    // Using Signed32 as restriction type amounts to promising there won't be
+    // signed overflow. This is incompatible with relying on a Word32
+    // truncation in order to skip the overflow check.
使用Signed32作为限制类型等于承诺不会有signed溢出。这与依靠Word32截断来跳过溢出检查是不兼容的。
+    Type const restriction =
+        truncation.IsUsedAsWord32() ? Type::Any() : Type::Signed32();

该bug的影响是这样的:引擎承诺不会发生有符号整数溢出,但实际结果是有符号整数溢出确实发生了。

回到回归测试,我添加了自定义的assertTrue和assertFalse函数,以便我可以实际运行它(我相信ClusterFuzz自动做到这一点)。修改后的poc如下:

// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
​
// Flags: --allow-natives-syntax
​
function assertTrue(c) {
    if (!c) { throw "Assertion failed"; }
}
​
function assertFalse(c) {
    assertTrue(!c);
}
​
function foo(a) {
  var y = 0x7fffffff;  // 2^31 - 1 (INT_MAX)
​
  // Widen the static type of y (this condition never holds).
  if (a == NaN) y = NaN;
​
  // The next condition holds only in the warmup run. It leads to Smi
  // (SignedSmall) feedback being collected for the addition below.
  if (a) y = -1;
​
  const z = (y + 1)|0;
  return z < 0;
}
​
%PrepareFunctionForOptimization(foo);
assertFalse(foo(true));
%OptimizeFunctionOnNextCall(foo);
assertTrue(foo(false));

关于function foo:

1.变量y被设置为INT_MAX

2.一些TurboFan的特定操作可以触发bug

3.变量z设置为(y+1)|0 与0进行按位或

4.return语句应该在第一次调用foo时返回false(参数a为true,因此y将被设置为-1,这将导致z被设置为y+1 == -1+1 == 0)。

5.对于foo的第二次调用,a将为false,因此z将被设置为y+1 == 0x7fffffff+1 == 0x80000000。根据回归测试,这个加法似乎应该产生负数-2147483648,这应该导致函数返回true,因为这个负数小于0。

然而,当运行这个回归测试时,最终的assertTrue将失败。==>我们可以得出结论,该bug可能导致引擎错误地假设整数溢出没有发生,而实际上它已经发生了

Some question:

  • The “static type” of y is widened initially. The question is, how does this work? What is the “static type”, and why does it need to be widened here?y的“静态类型”开始扩大。问题是,这是如何运作的?什么是“静态类型”,为什么这里需要扩大它?

  • In the warmup run (i.e the initial call to foo), SignedSmall feedback is collected by setting y to -1. Again, why is this required here? How does this specific line of code collect SignedSmall feedback?在对foo的初始调用中,通过设置y为-1收集SignedSmall反馈。为什么这里需要这个?这一行代码是如何收集SignedSmall反馈的?

  • Why is z set to (y + 1)|0? More specifically, why is it bitwise OR’d with 0?为什么z被设为(y + 1)|0?更具体地说,为什么要用0进行位或?

Turbofan优化过程首先会进入Escape Analysis阶段,之后进入SimplifiedLowering阶段,通过正反向遍历确定字节码树(Bytecode Graph Tree)中各节点的输入类型、输出类型、最大取值范围等,其共分为三个⼦阶段Propagate、Retype、Lower。

Unpatched vs Patched Turbolizer graphs

比较一下V8版本和未打补丁版本之间的Turbolizer图,看看补丁对引擎到底有什么影响

当我查看这些图时,我注意到在Escape Analysis阶段(在简化降低阶段Simplified Lowering Phase之前运行)中,这些图看起来是完全相同的。我只会在这里展示相关的部分。

Escape Analysis Phase:

CVE-2020-16040原理解析_第3张图片

 

图中的差异只显示在简化降低阶段。以下是两个版本的简化降低阶段:

Unpatched Simplified Lowering Phase:

CVE-2020-16040原理解析_第4张图片

 

Patched Simplified Lowering Phase:

CVE-2020-16040原理解析_第5张图片

 

显而易见的是,从Escape分析阶段的NumberLessThan节点已经在未补丁版本的简化降低阶段改为Uint32LessThan节点,而它已经在补丁版本的简化降低阶段改为Int32LessThan节点。

该节点用于最终的返回z < 0比较。很大概率,Uint32LessThan节点意味着TurboFan没有注意到整数溢出发生在加法期间,因为它试图将两个数字作为无符号32位整数进行比较。

相比之下,补丁版本的Simplified lower Phase将正确地使用Int32LessThan节点将两个数字作为带符号的32位整数进行比较。这是正确的方法,因为加法确实产生一个负数。

The Simplified Lowering Phase

Simplified Lowering Phase的代码可以在src/compiler/simplified-lowering.cc中发现,Simplified Lowering Phase 在 Escape Analysis Phase逸出分析阶段之后运行

bool PipelineImpl::OptimizeGraph(Linkage* linkage) {
  // [ ... ]
​
  if (FLAG_turbo_escape) {
    Run();
    // [ ... ]
  }
​
  // Perform simplified lowering. This has to run w/o the Typer decorator,
  // because we cannot compute meaningful types anyways, and the computed types
  // might even conflict with the representation/truncation logic.
  //执行简单的降低。这必须在没有Typer装饰器的情况下运行,因为无论如何我们都不能计算有意义的类型,并且计算的类型甚至可能与表示/截断逻辑冲突
  Run(linkage);
​
  // [ ... ]
}

这将调用 SimplifiedLowering::Run,它实际上有三个独立的子阶段:

void Run(SimplifiedLowering* lowering) {
    GenerateTraversal();
    RunPropagatePhase();
    RunRetypePhase();
    RunLowerPhase(lowering);
  }

GenerateTraversal首先将当前图中的每个节点放入一个名为traversal_nodes_的向量中。它是通过从End节点开始执行pre-order遍历,并在访问每个节点时将它们推入临时堆栈来实现这一点的。在访问完所有节点之前,节点不会被推入traversal_nodes__ 向量中

简而言之,这基本上意味着可以从头到尾迭代遍历traversal_nodes_向量,本质上与从上到下遍历Turbolizer图相同

The Propagation Phase传播阶段

在此阶段,traversal_nodes_向量将被反向遍历(即访问的第一个节点将是End节点),而“截断”则在图中传播。

截断实际上可以被认为是一个附加到节点上的标签。根据特定的条件(如节点的类型等),截断可能会也可能不会传播到其他节点。The truncations signify what “representation” a node should be restricted to.

可以在TruncationKind枚举中找到定义的截断列表:

enum class TruncationKind : uint8_t {
    kNone,
    kBool,
    kWord32,
    kWord64,
    kOddballAndBigIntToNumber,
    kAny
  };

可以在MachineRepresentation枚举中找到表示的列表:

enum class MachineRepresentation : uint8_t {
  kNone,
  kBit,
  kWord8,
  kWord16,
  kWord32,
  kWord64,
  kTaggedSigned,       // (uncompressed) Smi
  kTaggedPointer,      // (uncompressed) HeapObject
  kTagged,             // (uncompressed) Object (Smi or HeapObject)
  kCompressedPointer,  // (compressed) HeapObject
  kCompressed,         // (compressed) Object (Smi or HeapObject)
  // FP representations must be last, and in order of increasing size.
  kFloat32,
  kFloat64,
  kSimd128,
  kFirstFPRepresentation = kFloat32,
  kLastRepresentation = kSimd128
};

关于节点的表示、截断等信息存储在NodeInfo对象中。每个节点都与一个NodeInfo对象相关联,该对象可以通过在任何节点上调用GetInfo(node)来访问:


当简化降低阶段开始时,上面的所有字段将被设置为图中每个节点的默认值(即表示将是kNone,截断将是truncation::None(),etc)。当每个子阶段完成时,字段将被更新。

接下来看一个A simplified example:

We’ll trace the SpeculativeSafeIntegerAdd node as that is the one that contains the bug (based on the patch)

Running d8 with --trace-representation, we get the following output:


--{Propagate phase}--
 [ ... ]
 visit #45: SpeculativeNumberBitwiseOr (trunc: truncate-to-word32)
  initial #43: truncate-to-word32
  initial #44: truncate-to-word32
  initial #43: truncate-to-word32
  initial #36: no-value-use
 visit #43: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32)
  initial #39: no-truncation (but identify zeros)
  initial #42: no-truncation (but identify zeros)
  initial #22: no-value-use
  initial #36: no-value-use
 visit #42: NumberConstant (trunc: no-truncation (but identify zeros))
 visit #39: Phi (trunc: no-truncation (but identify zeros))
  initial #32: no-truncation (but identify zeros)
  initial #38: no-truncation (but identify zeros)
  initial #36: no-value-use
 [ ... ]

可以看到,SpeculativeNumberBitwiseOr节点将向它的第一个输入(#43,SpeculativeSafeIntegerAdd节点)传播一个Word32截断,而它将不向前两个输入传播截断。

RunPropagatePhase将向后迭代遍历traversal_nodes_向量,并在每个节点上调用PropagateTruncation,然后在所遍历的每个节点上调用VisitNode。

对于SpeculativeSafeIntegerAdd节点,VisitNode调用VisitSpeculativeIntegerAdditiveOp:

  template 
  void VisitNode(Node* node, Truncation truncation,
                 SimplifiedLowering* lowering) {
    // [ ... ]
    switch (node->opcode()) {
      // [ ... ]
      case IrOpcode::kSpeculativeSafeIntegerAdd:
      case IrOpcode::kSpeculativeSafeIntegerSubtract:
        return VisitSpeculativeIntegerAdditiveOp(node, truncation, lowering);
      // [ ... ]
    }
  }

VisitSpeculativeIntegerAdditiveOp是一个有点长的函数,所以我将只显示相关部分

  template 
  void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,
                                         SimplifiedLowering* lowering) {
    Type left_upper = GetUpperBound(node->InputAt(0));
    Type right_upper = GetUpperBound(node->InputAt(1));
​
    // [ 1 ]
    if (left_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero) &&
        right_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero)) {
      // [ ... ]
    }
​
    // [ ... ]
    return;
  }

首先,left_upper和right_upper变量本质上就是Turbolizer图上看到的前两个输入节点的类型。在这种情况下,它们是:

CVE-2020-16040原理解析_第6张图片

 

所以我们有:

left_upper - Phi节点的类型,是NaN | Range(-1, 2147483647)的UnionType。

ight_upper - NumberConstant[1]节点的类型,它是Range(1,1)的RangeType。

接下来,注释[1]处的if分支将不会被接受,因为第一次检查将失败。type_cache_->kAdditiveSafeIntegerOrMinusZero是一个介于Type::MinusZero()和Range(-4503599627370496, 4503599627370496)之间的UnionType,不包括NaN。这将导致left_upper.Is(…)调用返回false。

这实际上回答了第一个问题。通过将y的静态类型设置为NaN, y的静态类型就被扩大了。由于第一个if语句就是因为这个原因而被跳过的,我们可以推断这样做是为了通过if语句(函数在if分支中返回,不会再进一步)。这是由补丁进一步确认的,因为补丁对这个函数的唯一修改是在这个if分支之后。

我们还可以推断什么是“静态类型”。在本例中,静态类型似乎就是Turbolizer图中显示的类型(即先前由Typer Phase键入的类型)。另一方面,反馈类型(在NodeInfo类中跟踪)似乎是一个特定于简化降低阶段的东西。

接下来,我们将看到以下代码,它有助于回答我们的第二个问题:

  template 
  void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,
                                         SimplifiedLowering* lowering) {
    // [ ... ]
    NumberOperationHint hint = NumberOperationHintOf(node->op());
    DCHECK(hint == NumberOperationHint::kSignedSmall ||
           hint == NumberOperationHint::kSigned32);
​
    // [ ... ]
    return;
  }

我们看到SpeculativeSafeIntegerAdd节点的NumberOperationHint被存储到hint中,DCHECK确保这个提示要么是kSignedSmall,要么是kSigned32。这告诉我们,为了真正达到这个代码,我们需要SignedSmall或Signed32反馈.

我们可以通过首先修改poc以删除收集SignedSmall反馈的行来验证这一点,然后跟踪代码。首先,如果你查看修改后的poc的Turbolizer图,你会看到一个SpeculativeNumberAdd节点(与SpeculativeSafeIntegerAdd节点不同)将在graph Builder阶段插入:

CVE-2020-16040原理解析_第7张图片

 

这将使bug不可能触发,因为在简化降低阶段Simplified Lowering Phase,SpeculativeNumberAdd节点没有调用VisitSpeculativeIntegerAdditiveOp。

决定是否插入一个SpeculativeSafeIntegerAdd节点或一个SpeculativeNumberAdd节点的代码可以在JS Type Hint lower reducer中找到.这在图形构建器阶段Graph Builder Phase运行:

 const Operator* SpeculativeNumberOp(NumberOperationHint hint) {
    switch (op_->opcode()) {
      case IrOpcode::kJSAdd:
        if (hint == NumberOperationHint::kSignedSmall ||
            hint == NumberOperationHint::kSigned32) {
          return simplified()->SpeculativeSafeIntegerAdd(hint);
        } else {
          return simplified()->SpeculativeNumberAdd(hint);
        }
      // [ ... ]
    }
    UNREACHABLE();
  }

我们可以看到Ignition正在收集这些反馈:

TNode BinaryOpAssembler::Generate_AddWithFeedback(
    TNode context, TNode lhs, TNode rhs,
    TNode slot_id, TNode maybe_feedback_vector,
    bool rhs_known_smi) {
  // [ ... ]
​
    {
      // [ ... ]
      {
        var_type_feedback = SmiConstant(BinaryOperationFeedback::kSignedSmall); // This
        UpdateFeedback(var_type_feedback.value(), maybe_feedback_vector,
                       slot_id);
        var_result = smi_result;
        Goto(&end);
      }
​
      // [ ... ]
    }
  }
  // [ ... ] 
  

因此,总结一下,为了将SpeculativeSafeIntegerAdd节点插入到图中,需要收集SignedSmall反馈。这是在简化降低阶段触发脆弱函数VisitSpeculativeIntegerAdditiveOp的节点。

接下来查看VisitSpeculativeIntegerAdditiveOp的下一部分:

  template 
  void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation,
                                         SimplifiedLowering* lowering) {
    // [ ... ]
​
    Type left_constraint_type =
        node->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd
            ? Type::Signed32OrMinusZero()
            : Type::Signed32();
    if (left_upper.Is(left_constraint_type) && /*[ ... ]*/) { // [ 1 ]
      // [ ... ]
    } else {
      IdentifyZeros left_identify_zeros = truncation.identify_zeros();
      if (node->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd && // [ 2 ]
          !right_feedback_type.Maybe(Type::MinusZero())) {
        left_identify_zeros = kIdentifyZeros;
      }
      UseInfo left_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(),
                                                        left_identify_zeros);
      UseInfo right_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(),
                                                         kIdentifyZeros);
      VisitBinop(node, left_use, right_use, MachineRepresentation::kWord32,
                    Type::Signed32());
    }
​
    // [ ... ]
    return;
  }

1]的if分支将不会被接受,因为left_upper.Is(Type::Signed32OrMinusZero())将返回false,原因与上面相同(left_upper中有NaN类型)。然后我们到达else分支。

在继续之前,我将简要解释“识别零”和“区分零”(“identifying zeros” vs “distinguishing zeros” )的含义。本质上,任何截断都可以具有这两种性质中的一种——识别零或区分零。

  • Identify Zeros - This is the “default” option for most truncations.大多数截断的“默认”选项

  • Distinguish zeros - This option seems to be used any time a truncation is being done on a node where distinguishing between 0 and -0 is important.用于区分0和-0

传递给这个函数的truncation参数是当前设置的SpeculativeSafeIntegerAdd节点的截断。记住,这是第一次访问节点,因此截断将是TruncationKind::kNone。这又意味着不管是否使用[2]上的if分支,left_identify_zeros都将被设置为kIdentifyZeros。

然后,它将使用SignedSmall提示为第一个和第二个输入节点创建UseInfo对象。对于两个输入,CheckedUseInfoAsWord32FromHint都会调用CheckSignedSmallAsWord32:

  static UseInfo CheckedSignedSmallAsWord32(IdentifyZeros identify_zeros,
                                            const FeedbackSource& feedback) {
    return UseInfo(MachineRepresentation::kWord32,
                   Truncation::Any(identify_zeros), TypeCheckKind::kSignedSmall,
                   feedback);
  }

可以看到,这两个输入的UseInfo都将截断设置为Truncation::Any(identify_zeros),其中identify_zeros在这两种情况下都将是kIdentifyZeros。这确实与前面的--trace-representation的输出相匹配:SpeculativeSafeIntegerAdd节点的前两个输入都没有被截断。

最后,调用VisitBinop。具体情况如下:

 template 
  void VisitBinop(Node* node, UseInfo left_use, UseInfo right_use,
                  MachineRepresentation output,
                  Type restriction_type = Type::Any()) {
    DCHECK_EQ(2, node->op()->ValueInputCount());
    ProcessInput(node, 0, left_use);
    ProcessInput(node, 1, right_use);
    for (int i = 2; i < node->InputCount(); i++) {
      EnqueueInput(node, i);
    }
    SetOutput(node, output, restriction_type);
  }

前两个输入节点使用left_use和right_use参数进行处理。ProcessInput将调用EnqueueInput,它将检查输入之前是否被访问过。如果它们没有(这里就是这种情况),那么它们将被标记为在left_use和right_use中分别设置的截断(Truncation::Any())。如果它们以前被访问过,那么它们可能会也可能不会被添加到“重访队列revisit queue”中,以便以后重新访问(这里不适用)。

最后,任何其他现有输入都调用EnqueueInput(如果没有提供第三个参数,则设置为UseInfo::None())。SetOutput然后将SpeculativeSafeIntegerAdd节点的输出表示设置为kWord32,其限制类型设置为type::Signed32()。

注意,这个对VisitBinop的调用是补丁修改过的行之一。具体来说,传递给它的限制类型(Typed::Signed32())被更改了。我们将在下一个子阶段中看到这个错误是如何显示的。

在传播阶段,图中的每个节点都以与SpeculativeSafeIntegerAdd节点相同的方式访问。一旦完成此操作,如果“重新访问队列”不是空的,那么它将遍历一次。在此之后,传播阶段结束,重新输入阶段开始。

The Retype Phase

在此阶段,正向遍历traversal_nodes_向量。对于所访问的每个节点,将使用当前节点的输入节点的类型创建一个新类型。最后,这个新类型与当前节点的限制类型(在传播阶段设置的)相交,然后用这个新类型更新当前节点的反馈类型(可以在其NodeInfo对象中找到)。

再次以SpeculativeSafeIntegerAdd节点为例。The --trace-representation output tells us the following:

--{Retype phase}--
[ ... ]
#39:Phi[kRepTagged](#32:Phi, #38:NumberConstant, #36:Merge)  [Static type: (NaN | Range(-1, 2147483647))]
 visit #39: Phi                                                                 
  ==> output kRepFloat64                                                        
 visit #42: NumberConstant                                                      
  ==> output kRepTaggedSigned                                                   
#43:SpeculativeSafeIntegerAdd[SignedSmall](#39:Phi, #42:NumberConstant, #22:SpeculativeNumberEqual, #36:Merge)  [Static type: Range(0, 2147483648), Feedback type: Range(0, 2147483647)]
 visit #43: SpeculativeSafeIntegerAdd                                           
  ==> output kRepWord32
[ ... ]

可以看到,SpeculativeSafeIntegerAdd的前两个输入(即Phi和NumberConstant节点)都已经被retyped.这里需要注意的一点是,在--trace-representation输出中显示的Feedback类型实际上意味着在该特定节点的Retype阶段生成了与原始静态类型不同的新类型。

这是什么意思呢?我们以节点为例。我们看到在上面的--trace-representation输出中只显示了一个静态类型,但这有点误导人,因为它使它看起来像是没有生成任何反馈类型。然而,这是不正确的。Phi节点已经被retyped,它的NodeInfo对象将其feedback_type_字段更新为NaN | Range(-1, 2147483647)。它只是没有显示在输出中,因为新重新类型化的类型与原始静态类型相同。

我们还注意到Phi节点的输出表示被设置为kRepFloat64(这是由于NaN类型),而NumberConstant节点的输出表示被设置为kRepTaggedSigned(它被视为未压缩的Smi)。

让我们看看如何retype SpeculativeSafeIntegerAdd节点,以及它的反馈类型与静态类型不同的原因。请记住,这个节点的限制类型被设置为type::Signed32(),它的输出表示形式是kWord32。

RunRetypePhase函数调用RetypeNode的方法如下:

  void RunRetypePhase() {
    TRACE("--{Retype phase}--\n");
    ResetNodeInfoState();
    DCHECK(revisit_queue_.empty());
​
    for (auto it = traversal_nodes_.cbegin(); it != traversal_nodes_.cend();
         ++it) {
      Node* node = *it;
      if (!RetypeNode(node)) continue;
​
      // Maybe revisit nodes if needed, not important
      // [ ... ]
    }
  }
​
  bool RetypeNode(Node* node) {
    NodeInfo* info = GetInfo(node);
    info->set_visited();
    bool updated = UpdateFeedbackType(node);
    TRACE(" visit #%d: %s\n", node->id(), node->op()->mnemonic());
    VisitNode(node, info->truncation(), nullptr);
    TRACE("  ==> output %s\n", MachineReprToString(info->representation()));
    return updated;
  }

RetypeNode首先将节点标记为“visited”。然后,它将尝试使用UpdateFeedbackType更新节点的反馈类型。让我们看看它是如何工作的:

  bool UpdateFeedbackType(Node* node) {
    if (node->op()->ValueOutputCount() == 0) return false;
​
    if (node->opcode() != IrOpcode::kPhi) { // [ 1 ]
      // [ ... ]
    }
​
    NodeInfo* info = GetInfo(node);
    Type type = info->feedback_type();
    Type new_type = NodeProperties::GetType(node);
​
    // We preload these values here to avoid increasing the binary size too
    // much, which happens if we inline the calls into the macros below.
    Type input0_type;
    if (node->InputCount() > 0) input0_type = FeedbackTypeOf(node->InputAt(0));
    Type input1_type;
    if (node->InputCount() > 1) input1_type = FeedbackTypeOf(node->InputAt(1));
​
    switch (node->opcode()) {
      // [ ... ]
#define DECLARE_CASE(Name)                                               \
  case IrOpcode::k##Name: {                                              \
    new_type = Type::Intersect(op_typer_.Name(input0_type, input1_type), \
                               info->restriction_type(), graph_zone());  \
    break;                                                               \
  }
      SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST(DECLARE_CASE)
      SIMPLIFIED_SPECULATIVE_BIGINT_BINOP_LIST(DECLARE_CASE)
#undef DECLARE_CASE
​
      // [ ... ]
    }
    new_type = Type::Intersect(GetUpperBound(node), new_type, graph_zone());
​
    if (!type.IsInvalid() && new_type.Is(type)) return false;
    GetInfo(node)->set_feedback_type(new_type);
    if (FLAG_trace_representation) {
      PrintNodeFeedbackType(node);
    }
    return true;
  }

[1]上的第一个if分支本质上确保了这个节点的每个输入都已经被重新类型化了,除非这个节点是Phi节点

接下来,我们有两个Type变量。type是节点的当前反馈类型(它不存在,因为这是在Retype阶段第一次访问节点),而new_type是节点的当前静态类型(在Turbolizer图中是Range(0, 2147483648))。

接下来,这两个输入的反馈类型存储在input0_type和input1_type中。在本例中,它们如下:

  • input0_type: NaN | Range(-1, 2147483647) - The Phi node

  • input1_type: Range(1, 1) - The NumberConstant[1] node

之后,我们进入一个巨大的switch语句,它大量使用宏。在我们的例子中,我们感兴趣的是处理SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST的代码。在我们的例子中,new_type =…宏行基本上只是翻译为以下内容:

new_type =   Type::Intersect(OperationTyper::SpeculativeSafeIntegerAdd(input0_type,   input1_type), info->restriction_type(), graph_zone());

这里,new_type本质上被设置为一个新的交集类型,它是当前节点的限制类型(在传播阶段设置的type::Signed32())和OperationTyper::SpeculativeSafeIntegerAdd返回的类型之间的交集。

OperationTyper::SpeculativeSafeIntegerAdd很简单。它将忽略input0_type中的NaN类型,并简单地将范围的Min和Max相加,并返回一个新的RangeType。实际上,这将返回Range(-1+1, 2147483647+1) == Range(0, 2147483648)。

最后,当这个新范围与限制类型相交时,返回range(0, 2147483647)的最终类型,这就是new_type被设置为的值。简而言之:Type::Signed32()(即限制类型)可以被视为Range(-2147483648, 2147483647)。当与Range(0, 2147483648)相交时,返回最大值和最小值,得到Range(0, 2147483647)。

在函数的末尾,最终的Type::Intersect不会影响new_type(同样,可以通过跟踪代码进行验证)。然后,当前节点的反馈类型被设置为这个新范围(new_type.Is(type)将返回false,因为type是反馈类型,而此时不存在)。注意,在这种情况下,反馈类型将在--trace-representation输出中打印出来,因为它不同于原始的静态类型。

这就是bug的表现形式反馈类型设置为Range(0, 2147483647),这显然是不正确的。input0_type的类型是Range(-1, 2147483647)。如果它是可能的最高值(在本例中是poc中的0x7fffffff),那么给它加1将使它变成2147483648,也就是-2147483648。这在被设置为节点反馈类型的最终计算范围中没有涉及。

这可以通过从poc中返回和打印z来看到:

function foo(a) {
  // [ ... ]
  return z;
}
​
%PrepareFunctionForOptimization(foo);
foo(true);
%OptimizeFunctionOnNextCall(foo);
print(foo(false));
$ ./d8 --allow-natives-syntax poc.js 
-2147483648

这背后的根本原因是限制类型被设置为Type::Signed32()。事实上,如果你看一下这个补丁,你会注意到它通过在VisitSpeculativeIntegerAdditiveOp中设置type::Any()来解决这个问题,即截断设置为kWord32。

完成此操作后,返回RetypeNode,可能调用也可能不调用VisitNode。在本例中会进行调用,但是VisitNode只是将当前节点的输出表示设置为kWord32,所以实际上没有什么新的事情发生。

一旦每个节点都被retyped,有些节点可能需要重新访问(类似于传播阶段)。完成之后,Retype阶段结束,Lowering阶段开始。

The Lowering Phase降低阶段

实际上,RunLowerPhase将从头到尾遍历遍历traversal_nodes_向量,并在每个节点上调用VisitNode。这将基于在前两个子阶段中收集的所有信息将节点降低到较低级别的节点。

例如,我们的SpeculativeSafeIntegerAdd节点将被错误地降为Int32Add,而实际上由于带符号整数溢出,它应该降为CheckedInt32Add。

这也解释了为什么NumberLessThan节点被Uint32LessThan节点在未修补版本的turboizer图的简化降低阶段替换。由于引擎认为不可能发生溢出,所以它不会对有符号整数进行比较。这实际上可以在VisitNode的巨大switch语句中看到:

      case IrOpcode::kNumberLessThan:
      case IrOpcode::kNumberLessThanOrEqual: {
        Type const lhs_type = TypeOf(node->InputAt(0));
        Type const rhs_type = TypeOf(node->InputAt(1));
        // Regular number comparisons in JavaScript generally identify zeros,
        // so we always pass kIdentifyZeros for the inputs, and in addition
        // we can truncate -0 to 0 for otherwise Unsigned32 or Signed32 inputs.
        if (lhs_type.Is(Type::Unsigned32OrMinusZero()) &&
            rhs_type.Is(Type::Unsigned32OrMinusZero())) {
          // => unsigned Int32Cmp
          VisitBinop(node, UseInfo::TruncatingWord32(),
                        MachineRepresentation::kBit);
          if (lower()) NodeProperties::ChangeOp(node, Uint32Op(node));
        } else if (/*[ ... ]*/) {
            // [ ... ]
        }
        return;
      }

在这里,lhs将是SpeculativeNumberBitwiseOr节点的反馈类型,它将是Range(0,2147483647),而rhs将是Range(0,0)。因为这两种类型都适合Unsigned32,所以第一个if分支将被执行。在降低阶段,if (lower())将返回true,当前节点将更改为其Uint32版本。

如上一段所述,SpeculativeNumberBitwiseOr节点的反馈类型将是Range(0, 2147483647)。如果将其与简化降低阶段的Turbolizer图进行比较,将看到该图实际上显示了降低后的Word32Or节点的Range(INT_MIN, INT_MAX)。之所以会出现这种情况,是因为节点的静态类型永远不会更新为新的反馈类型,所以Turbolizer图上的类型永远不会更新。这进一步证明了节点的静态类型确实是我们在Turbolizer图中看到的。

这种非视觉反馈类型很快就会让人感到困惑,但是使用GDB可以很快地解决这个问题。例如,当在降低阶段访问NumberLessThan节点时,要计算出SpeculativeNumberBitwiseOr节点的反馈类型,可以执行以下操作:

  1. 在RunLowerPhase上设置一个断点,运行poc到该断点。

  2. 在Type const lhs_type = ... 行后设置断点,运行到断点处。

现在,知道lhs_type将是一个RangeType,所以可以在GDB中打印它,如下所示:

gef➤  p *(RangeType *) lhs_type
$2 = {
   = {
    kind_ = v8::internal::compiler::TypeBase::kRange
  }, 
  members of v8::internal::compiler::RangeType: 
  bitset_ = 0x402, 
  limits_ = {
    min = 0, 
    max = 2147483647
  }
}

我们还没有回答第三个问题:为什么一开始就要求执行按位OR ?

为了回答这个问题,让我们修改poc以删除按位的OR。运行poc,将看到对foo的第二次调用的输出仍然返回false,这意味着bug被成功触发,所以发生了什么变化呢?

再次检查Turbolizer图。可以看到,在Typed Lowering阶段,SpeculativeNumberLessThan节点被HeapConstant[false]替换了:

CVE-2020-16040原理解析_第8张图片

 

这意味着bug仍然会被触发。它会导致引擎认为前面的添加永远不会产生一个小于0的数字,因此Constant Folding reducer会将节点常量折叠成一个假的HeapConstant。

发生这种情况的核心原因与这样一个事实有关,即在Typer阶段将SpeculativeNumberLessThan节点(即添加节点)的第一个输入节点类型为Range(0, 2147483648)。当Typer随后尝试键入SpeculativeNumberLessThan节点时,Typer::Visitor::NumberCompareTyper被调用:

Typer::Visitor::ComparisonOutcome Typer::Visitor::NumberCompareTyper(Type lhs,
                                                                     Type rhs,
                                                                     Typer* t) {
  DCHECK(lhs.Is(Type::Number()));
  DCHECK(rhs.Is(Type::Number()));
​
  if (lhs.IsNone() || rhs.IsNone()) return {};
​
  // Shortcut for NaNs.
  if (lhs.Is(Type::NaN()) || rhs.Is(Type::NaN())) return kComparisonUndefined;
​
  ComparisonOutcome result;
  if (lhs.IsHeapConstant() && rhs.Is(lhs)) {
    // Types are equal and are inhabited only by a single semantic value.
    result = kComparisonFalse;
  } else if (lhs.Min() >= rhs.Max()) {
    result = kComparisonFalse;
  } else if (lhs.Max() < rhs.Min()) {
    result = kComparisonTrue;
  } else {
    return ComparisonOutcome(kComparisonTrue) |
           ComparisonOutcome(kComparisonFalse) |
           ComparisonOutcome(kComparisonUndefined);
  }
  // Add the undefined if we could see NaN.
  if (lhs.Maybe(Type::NaN()) || rhs.Maybe(Type::NaN())) {
    result |= kComparisonUndefined;
  }
  return result;
}

我们有lhs == Range(0,2147483648)(这是添加节点的类型),和rhs == Range(0,0)。看看if-else if分支,很明显,将采用lhs. min () >= rhs. max()分支,这将把结果设置为kComparisonFalse。这就是导致引擎认为比较永远不会返回true的原因。

把这个与将SpeculativeNumberBitwiseOr节点插入到原始poc时进行比较。按位或将会把SpeculativeSafeIntegerAdd节点的范围扩大到range (INT_MIN, INT_MAX)(因为这就是用0进行按位或的效果)。当这种情况发生时,lhs.Min() >= rhs.Max()将失败。lhs.Max() < rhs.Min()也将失败,因此返回的最终结果是true、false和undefined的组合。这意味着引擎不能确定每次比较都会返回false,因此Constant Folding reducer不能再用false HeapConstant替换SpeculativeNumberLessThan节点。

因此,我们可以得出这样的结论:触发bug确实不需要按位或。只有当你的利用方法依赖于NOT constant时,它才真正重要。 划掉的不太正确。考虑以下修改后的poc的输出:

function foo(a) {
  // [ ... ]
​
  const z = y + 1;
​
  return z;
}
​
%PrepareFunctionForOptimization(foo);
foo(true);
%OptimizeFunctionOnNextCall(foo);
print(foo(false));
$ ./d8 --allow-natives-syntax poc.js
2147483648

上述情况没有发生带符号整数溢出。然而,这并不意味着bug不会被触发

记住,在Retype阶段重新键入节点之后,SpeculativeSafeIntegerAdd节点的反馈类型将被设置为Range(0, 2147483647)。注意,最终返回的值2147483648仍然不在这个范围内!bug仍然存在,只是触发的方式有点不同。

这就解释了为什么将SpeculativeNumberLessThan节点替换为false HeapConstant,因为这个加法只会产生一个更大的正数,没有整数溢出。按位OR实际上用于将z的类型限制为带符号的32位值,这允许发生带符号整数溢出。

Exploitability利用

由于错误发生在简化降低(Simplified Lowering)阶段,我们实际上可以使用错误类型的SpeculativeSafeIntegerAdd节点在Retype阶段将更多错误类型传播给后续节点

--{Propagate phase}-- 
[ ... ]
visit #48: End (trunc: no-value-use)  
 initial #47: no-value-use 
visit #47: Return (trunc: no-value-use)  
 initial #44: truncate-to-word32  
 initial #55: no-truncation (but distinguish zeros)  
 initial #45: no-value-use  
 initial #36: no-value-use 
visit #55: NumberLessThan (trunc: no-truncation (but distinguish zeros))  
 initial #45: truncate-to-word32  
 initial #44: truncate-to-word32 
visit #45: SpeculativeNumberBitwiseOr (trunc: truncate-to-word32)  
 initial #43: truncate-to-word32  
 initial #44: truncate-to-word32  
 initial #43: truncate-to-word32  
 initial #36: no-value-use 
visit #43: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32)  
 initial #39: no-truncation (but identify zeros)  
 initial #42: no-truncation (but identify zeros)  
 initial #22: no-value-use  
 initial #36: no-value-use  
[ ... ]
--{Retype phase}-- 
visit #5: HeapConstant  ==> output kRepTaggedPointer 
visit #0: Start  ==> output kRepTagged 
visit #7: OsrValue  ==> output kRepTagged 
visit #20: StateValues  ==> output kRepTagged 
visit #21: StateValues  ==> output kRepTagged 
visit #22: HeapConstant  ==> output kRepTaggedPointer
--{Lower phase}-- 
visit #5: HeapConstant 
visit #0: Start 
visit #7: OsrValue 
visit #20: StateValues 
visit #21: StateValues 
visit #22: HeapConstant 
visit #6: OsrValue 
visit #23: Parameter 
visit #58: FrameState 
visit #70: HeapConstant 
visit #24: FrameState 
visit #146: Checkpoint 
visit #139: LoadFieldchange: #139:LoadField(@0 #70:HeapConstant) from kRepTaggedPointer to kRepTagged:no-truncation (but distinguish zeros)
case IrOpcode::kUint32LessThan: {      Uint32BinopMatcher m(node);if (m.left().Is(kMaxUInt32)) return ReplaceBool(false);  // M < x => false
                                if (m.right().Is(0)) return ReplaceBool(false);          // x < 0 => false

你可能感兴趣的:(网络安全,node.js,后端,运维,cve)