网络攻击,Chrome-V8 CVE-2021-30588

  • 作者:
  • 时间:2022-03-16 09:59:24
简介 网络攻击,Chrome-V8 CVE-2021-30588

issue(https://bugs.chromium.org/p/chromium/issues/detail?id=1195650),这是去年发布的1天,我们来看一下详细情况。

漏洞分析

调试 Poc 以及观察涡轮增压器,我主要是在不合适的地方发现插件无法到达的节点,导致运行时中断。

而这里(https://docs.google.com/presentation/d/1sOEF4MlF7LeO7uq-uThJSulJlTh--wgLeaVibsbb3tc/edit#slide=id.g549957988_0383)有写作。

这个unreachable是和dead node相关的,我们把惊人的发现在简化的Lowing阶段,因为通过查看turbolizer以及查看问题界面的返回信息错误在这个阶段。

所以我先把突突SimplifiedLower中之后在SpeculativeToNumber插入Unreachable的部分。

template Phase T>
  void VisitNode(Node* node, Truncation truncation,
                 SimplifiedLowering* lowering) {
    tick_counter_->TickAndMaybeEnterSafepoint();

    // Unconditionally eliminate unused pure nodes (only relevant if there's
    // a pure operation in between two effectful ones, where the last one
    // is unused).
    // Note: We must not do this for constants, as they are cached and we
    // would thus kill the cached {node} during lowering (i.e. replace all
    // uses with Dead), but at that point some node lowering might have
    // already taken the constant {node} from the cache (while it was not
    // yet killed) and we would afterwards replace that use with Dead as well.
    if (node->op()->ValueInputCount() > 0 T>(node);   //调用的这个函数里面打了patch
    }

    if (lowerT>()) InsertUnreachableIfNecessaryT>(node);   //这里打了patch,且InsertUnreachableIfNecessary里也打了patch,从SpeculativeToNumber变为unreachable就是在这个函数内

    switch (node->opcode()) {
        [ ... ]
      case IrOpcode::kSpeculativeToNumber: {
        NumberOperationParameters const
        switch (p.hint()) {
          case NumberOperationHint::kSignedSmall:
          case NumberOperationHint::kSignedSmallInputs:
            VisitUnopT>(node,
                         CheckedUseInfoAsWord32FromHint(
                             p.hint(), kDistinguishZeros, p.feedback()),
                         MachineRepresentation::kWord32, Type::Signed32());
            break;
          case NumberOperationHint::kNumber:
          case NumberOperationHint::kNumberOrBoolean:
          case NumberOperationHint::kNumberOrOddball:   //这里会将其换为float64
            VisitUnopT>(
                node, CheckedUseInfoAsFloat64FromHint(p.hint(), p.feedback()),
                MachineRepresentation::kFloat64);
            break;
        }
        if (lowerT>()) DeferReplacement(node, node->InputAt(0));
        return;
      }

另通过观察patch发现和DeferReplacement也有关系,有patch。

之所以比较麻烦是因为在运行时直接--trace-representation看不到我想要的过程,所以只能一点点调试。

从调试结果来看,是先插入的unreachable,后经下面转的checkedTaggedToFloat64,从节点顺序来看也是如此,不过这就无法作为插入unreachable的原因了,所以要看patch的其他部分。

经调试发现也不会先进入VisitUnused,此外,在插入unreachable之前,不会到达patch的任何其他段代码处,所以目前锁定问题出现在。

diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc
index 23e0006..a71e627 100644
--- a/src/compiler/simplified-lowering.cc
+++ b/src/compiler/simplified-lowering.cc
@@ -1960,7 +1975,32 @@
       return VisitUnusedT>(node);
     }

-    if (lowerT>()) InsertUnreachableIfNecessaryT>(node);
+    if (lowerT>()) {
+      // Kill non-effectful operations that have a None-type input and are thus
+      // dead code. Otherwise we might end up lowering the operation in a way,
+      // e.g. by replacing it with a constant, that cuts the dependency on a
+      // deopting operation (the producer of the None type), possibly resulting
+      // in a nonsense schedule.
+      if (node->op()->EffectOutputCount() == 0  i  node->op()->ValueInputCount(); i++) {
+          Node* input = node->InputAt(i);
+          if (TypeOf(input).IsNone()) {
+            MachineRepresentation rep = GetInfo(node)->representation();
+            DeferReplacement(
+                node,
+                graph()->NewNode(jsgraph_->common()->DeadValue(rep), input));
+            return;
+          }
+        }
+      } else {
+        InsertUnreachableIfNecessaryT>(node);
+      }
+    }

可以看到是多了一个判断条件,也就是说,原本不该进入InsertUnreachableIfNecessary的情况进入了,所以也就是在不该插入的时候插入了,这个特殊的情况,可以用各种技巧构造出来,作为比较,拿InsertUnreachableIfNecessary来看。

template >
void RepresentationSelector::InsertUnreachableIfNecessaryLOWER>(Node* node) {
  // If the node is effectful and it produces an impossible value, then we
  // insert Unreachable node after it.
  if (node->op()->ValueOutputCount() > 0 

    Node* unreachable =
        graph()->NewNode(common()->Unreachable(), node, control);

    // Insert unreachable node and replace all the effect uses of the {node}
    // with the new unreachable node.
    for (Edge edge : node->use_edges()) {
      if (!NodeProperties::IsEffectEdge(edge)) continue;
      // Make sure to not overwrite the unreachable node's input. That would
      // create a cycle.
      if (edge.from() == unreachable) continue;
      // Avoid messing up the exceptional path.
      if (edge.from()->opcode() == IrOpcode::kIfException) {
        DCHECK(!node->op()->HasProperty(Operator::kNoThrow));
        DCHECK_EQ(NodeProperties::GetControlInput(edge.from()), node);
        continue;
      }

      edge.UpdateTo(unreachable);
    }
  }
}

可以看到,曾经能走入InsertUnreachableIfNecessary并成功插入unreach节点的node,不满足diff中新加的判断,也就是,原本能向内完成插入的节点还会往后走,那么再仔细看下patch,可以看到,是对另外的一些节点,主动将其变为DeadValue,所以猜测是有些节点未及时变为deadvalue而导致的问题,是哪些节点呢,是pure dead operation,除了kDeadValue,kStateValues,kFrameState,kPhi之外的operation。

关于dead code,其实含义很广泛,可以说是不可能执行的代码,或者更恰当的是不可能执行的分支,都是代表这段代码可以被消除的含义,我们可以看下dead-code-elimination.h中如何消除dead value的注释。

// Propagates {Dead} control and {DeadValue} values through the graph and
// thereby removes dead code.
// We detect dead values based on types, replacing uses of nodes with
// {Type::None()} with {DeadValue}. A pure node (other than a phi) using
// {DeadValue} is replaced by {DeadValue}. When {DeadValue} hits the effect
// chain, a crashing {Unreachable} node is inserted and the rest of the effect
// chain is collapsed. We wait for the {EffectControlLinearizer} to connect
// {Unreachable} nodes to the graph end, since this is much easier if there is
// no floating control.
// {DeadValue} has an input, which has to have {Type::None()}. This input is
// important to maintain the dependency on the cause of the unreachable code.
// {Unreachable} has a value output and {Type::None()} so it can be used by
// {DeadValue}.
// {DeadValue} nodes track a {MachineRepresentation} so they can be lowered to a
// value-producing node. {DeadValue} has the runtime semantics of crashing and
// behaves like a constant of its representation so it can be used in gap moves.
// Since phi nodes are the only remaining use of {DeadValue}, this
// representation is only adjusted for uses by phi nodes.
// In contrast to {DeadValue}, {Dead} can never remain in the graph.

可以看到,dead value是不该在effect chain中的,因为如此会导致unreachable节点的插入effect chain中,从而导致crash,effect chain可以理解为对于执行流中的有先后顺序操作的限制顺序。

这个漏洞patch方法是主动将一些节点先行转为dead value,所以可以预料到只要能先把非effect chain上的一些节点转为dead value,就不会造成运行到unreachable的情况,说到这里,我们先要看为什么在这会插入unreachable。

这是在InsertUnreachableIfNecessary内部,而调用这个函数可以说没什么判断,但是在函数内有许多判断需要满足,直观来说就是。

// If the node is effectful and it produces an impossible value, then we
  // insert Unreachable node after it.
  if (node->op()->ValueOutputCount() > 0 
    if (a) y = 1.1;
    return y ? 1 : 0;
  }
  %PrepareFunctionForOptimization(foo);
  print(foo(false));
  %OptimizeFunctionOnNextCall(foo);
  print(foo(false));
})();

其中[]、''之类的是SpeculativeToNumber节点的必须,#74的heapconstant就是这个。

我们看修复之后的版本:

很明显的是虽然还是会生成unreachable以及checkedTaggedToFloat64,但是也可以看到生成了很多的DeadValue,也就是虽然生成Unreachable的分支判断依然可以运行,但是在此之外多出了一些生成dead value的过程,patch也正是改动的这一点,在遍历节点时,主动检查是否应转为dead value。

我们看一下最终生成代码的差别。

右边是patch后的版本。

为了辅助定位触发的int3是哪条代码里的,我修改了一下。

(function() {
  function foo(a) {
    let y = Math.min(Infinity ? [] : Infinity, -0) / 0;
    console.log("hi");
    if (a) y = 1.1;
    return y ? 1 : 0;
  }
  %PrepareFunctionForOptimization(foo);
  print(foo(false));
  %OptimizeFunctionOnNextCall(foo);
  print(foo(false));
})();

显然是在let y = Math.min(Infinity ? [] : Infinity, -0) / 0;中直接break,也就是说确实是插入的那个unreachable起了作用,后发现是其旁边的一个unreachable起了作用。

另外经过一些尝试,我发现patch达到的效果和poc中将除操作删去效果一样,都是在后面加了几个dead value,(min操作没有必要,转为dead value)然后后面的操作就比较正常了。

最终经过阅读最后形成的代码,我发现对于不成功触发的情况,(因为显然是return全变为throw了),最后的结果要么是走到unreachable,要么是deopt,因为unreachable走不到(正常情况下),所以每次都会deopt,从而走正确的流程,下面是patch后的v8运行poc,加了输出deopt的参数。

而未patch的运行poc。

看完未patch版本最终生成的代码后,其整体流程其实也是要么走向unreachbale,要么走向deopt,但是大部分情况都会走向unreachable,且看运行结果也发现是直接走到unreachable了,所以最终导致的结果应该是,本该走向deopt的情况,走向了unreachable。

基本上二者从EffectLinearization开始出现差别。

是因为,左侧patch前的是在和##104节点差距离较远的一个dead value生成的##190 unreachable,右侧patch后的是由和##104算是有点关系的几个dead value生成的unreachable。

生成unreachable在这里。

Node* EffectControlLinearizer::LowerDeadValue(Node* node) {
  Node* input = NodeProperties::GetValueInput(node, 0);
  if (input->opcode() != IrOpcode::kUnreachable) {
    // There is no fundamental reason not to connect to end here, except it
    // integrates into the way the graph is constructed in a simpler way at
    // this point.
    // TODO(jgruber): Connect to end here as well.
    Node* unreachable = __ UnreachableWithoutConnectToEnd();
    NodeProperties::ReplaceValueInput(node, unreachable, 0);   //dead value上会插入unreachable
  }
  return gasm()->AddNode(node);
}

造成的结果就是在最后,##104旁的unreachable有无与effectPhi有直接联系。

而这个effectPhi又是与一个DeoptimizeUnless节点直接关联的,也就是本来会把unreachable安排在deopt后面的,然而因为那些dead avlue没有在正确的地方生成,导致代码组织出了错误,使得从dead value衍生出来的unreachable节点与上面的一个DeoptmizeUnless断了联系,从而越过Deopt直接到达Unreachable,然后crash。

在未patch版中虽然simplifiedLowering阶段也把除0操作变为了dead value,但是还是保留的float64Min节点(原NumberMin)。

导致此处dead value(div0)早一步被消除。

可以看到左侧(patch前),#107 dead value已经没了,右侧倒是还有div0转的dead value。

对应的numberMin变Float64Min逻辑在。

case IrOpcode::kNumberMin: {
        // It is safe to use the feedback types for left and right hand side
        // here, since we can only narrow those types and thus we can only
        // promise a more specific truncation.
        // For NumberMin we generally propagate whether the truncation
        // identifies zeros to the inputs, and we choose to ignore minus
        // zero in those cases.
        Type const lhs_type = TypeOf(node->InputAt(0));
        Type const rhs_type = TypeOf(node->InputAt(1));
[ ... ]
        } else {
          VisitBinopT>(node,
                        UseInfo::TruncatingFloat64(truncation.identify_zeros()),
                        MachineRepresentation::kFloat64);
          if (lowerT>()) {
            // If the left hand side is not NaN, and the right hand side
            // is not NaN (or -0 if the difference between the zeros is
            // observed), we can do a simple floating point comparison here.
            if (lhs_type.Is(Type::OrderedNumber()) 
            } else {
              ChangeOp(node, Float64Op(node));
            }
          }
        }
        return;
      }

对于这次情况就是NumberMin的左是SpeculativeToNumber,右是-0,所以会变为Float64Min,然而patch后的版本会在走到这里之前,先对其进行判断,转为Dead Value。

# 对应这里的判断
+      if (node->op()->EffectOutputCount() == 0 &&
+          node->op()->ControlOutputCount() == 0 &&
+          node->opcode() != IrOpcode::kDeadValue &&
+          node->opcode() != IrOpcode::kStateValues &&
+          node->opcode() != IrOpcode::kFrameState &&
+          node->opcode() != IrOpcode::kPhi) {

从而导致没有在靠近#104 unreachable位置衍生出unreachable(因为这里没有一个dead value),倒是在上面有一个原本应当放在div0化成的dead value后面的一个#110 dead value衍生了unreachable,在右侧对应#109,也就是原本。

这里的#109,但是这个节点因为前面的关系原因,没有联系到下面#104 unreachable这里,所以造成了上面说的最终结果。

虽然没有找到合适的利用链但是不排除能成功利用的情况,在这里(https://bugs.chromium.org/p/chromium/issues/detail?id=1195650#c31),有半任意地址读的poc,但是离构造出越界数组,仍有一段路要走,另外还有一个poc能把本该返回false的改成true,稍加改变就能使得本该true的被turbofan优化成了false,但是此版本消除check bound已经不再能使用(也不一定),并且那个利用参杂着另外一个漏洞点,在作者用的这个版本(https://crrev.com/b2ae9951d4a12b996532022959f44a0cd10184ec)上才能成功。

但是也不是完全没有思路,我们可以看到他是越过DeoptimizeUnless直接走到本该在DeoptimizeUnless后面的unreachable,所以我们如果可以把此处的unreachable换为别的类型混淆利用方式,比如用其他类型对象实现越界读写,那么我们就能造出一个越界数组来,但是这只是理论方面的想法。