Pico Org
2479 字
12 分钟
CVE-2024-2887 Chrome Type Confusion漏洞分析

1. 简介#

原文主要是介绍Pwn2Own的Master of Pwn获奖者Manfred Paul的漏洞发现和漏洞利用过程,该漏洞类型为type confusion,可同时在Chrome和Edge (Chromium)触发。

漏洞存在于V8 JavaScript和WebAssembly引擎中,允许绕过V8内存沙箱(Ubercage),但受限于基于进程隔离的浏览器沙箱。在演示过程中通过使用—no-sandbox移除了这个限制。

2. 漏洞成因#

WebAssembly模块包含一个Type section用来给出自定义heap types,在一开始只用来定义function types/aggregate types/external types三种类型(原文只提到一种类型),但在引入垃圾回收(GC)提案后,可以定义结构体类型,允许在WebAssembly中使用复合的、堆分配的类型。

通常,结构体只能引用其前面声明的结构体。但为了支持相互递归的数据结构,新增了一个特性recursive type groups,该特性支持将递归类型组整体声明而不是每个类型单独声明。在该组中,各个类型允许相互引用。

据此,考虑在v8/src/wasm/module-decoder-impl.h中负责从WebAssembly解析Type section的函数:

// 调整了一下原文缩进
void DecodeTypeSection() { 
    TypeCanonicalizer* type_canon = GetTypeCanonicalizer(); 
    uint32_t types_count = consume_count("types count", kV8MaxWasmTypes); // (1) 
 
    for (uint32_t i = 0; ok() && i < types_count; ++i) {  
        ... 
        uint8_t kind = read_u8<Decoder::FullValidationTag>(pc(), "type kind");  
        size_t initial_size = module_->types.size(); 
        if (kind == kWasmRecursiveTypeGroupCode) { 
            ... 
            uint32_t group_size = 
                    consume_count("recursive group size", kV8MaxWasmTypes); 
            ... 
            if (initial_size + group_size > kV8MaxWasmTypes) { // (2)  
                errorf(pc(), "Type definition count exceeds maximum %zu", 
                    kV8MaxWasmTypes);  
                return; 
            } 
            ... 
            for (uint32_t j = 0; j < group_size; j++) { 
                ... 
                TypeDefinition type = consume_subtype_definition(); 
                module_->types[initial_size + j] = type; 
            } 
            ... 
        } else { 
            ... 
            // Similarly to above, we need to resize types for a group of size 1. 
            module_->types.resize(initial_size + 1); // (3) 
            module_->isorecursive_canonical_type_ids.resize(initial_size + 1); 
            TypeDefinition type = consume_subtype_definition(); 
            if (ok()) {  
                module_->types[initial_size] = type; 
                type_canon->AddRecursiveSingletonGroup(module_.get()); 
            } 
        }  
    } 
    ...  
} 

在(1)处,kV8MaxWasmTypes = 1,000,000限制了Type section的声明总数。在(2)处进一步检查了递归类型组的大小是否超出了限制。

然而在(2)处的检查是不够的,假设在Type section有两种类型声明,一种是恰好包含kV8MaxWasmTypes数量的递归类型组,另一种是非递归类型组的类型。那么(1)处的检查能够通过,因为总数为2,(2)处的检查只有第一种声明会进入,且数值刚好等于kV8MaxWasmTypes。但是在(3)处,另一种类型声明刚好触发resize,值为kV8MaxWasmTypes + 1,被分配到的索引为kV8MaxWasmTypes。如果有更多的非递归类型组类型声明,只会超出限制更多。

3. 漏洞利用#

3.1. 漏洞影响范围#

结合V8处理WebAssembly的heap types的方式,该漏洞可以构造出非常强大的漏洞利用原语,在v8/src/wasm/value-type.h中,heap types的定义如下:

class HeapType { 
    public: 
        enum Representation : uint32_t { kFunc = kV8MaxWasmTypes, 
            kEq, 
            kI31, 
            kStruct, 
            kArray, 
            kAny, 
            kExtern, 
            ... 
            kNone, 
            ... 
        };
}; 

此处,V8假设所有用户定义的堆类型都被分配小于kV8MaxWasmTypes的索引。此时,较大的索引保留给固定的内部类型,导致我们可以使用内部类型对自定义类型定义别名,导致大量的type confusion机会。

3.2. 通用WebAssembly type confusion#

达成type confusion,我们首先考虑struct.new操作,它生成新结构体对象的引用,参数来自于栈上数据。调用者通过传递其类型索引来指定所需的结构类型。对类型索引的检查可以在v8/src/wasm/function-body-decoder-impl.h中找到:

bool Validate(const uint8_t* pc, StructIndexImmediate& imm) {  
    if (!VALIDATE(module_->has_struct(imm.index))) { 
        DecodeError(pc, "invalid struct index: %u", imm.index); 
        return false;  
    } 
    imm.struct_type = module_->struct_type(imm.index); 
    return true;  
} 

接下来的验证逻辑是has_struct()函数在v8/src/wasm/wasm-module.h:

bool has_struct(uint32_t index) const { 
    return index < types.size() && types[index].kind == TypeDefinition::kStruct;  
} 

由于我们可以使types.size() > kV8MaxWasmTypes,因此即使传递大于该值的索引,也可以使检查通过。这允许我们创建任意内部类型的引用,该引用指向我们可以自定义的结构。

另一方面,考虑ref.cast指令的处理:


case kExprRefCast: 
case kExprRefCastNull: {
        ...
        Value obj = Pop();

        HeapType target_type = imm.type;
        ...
        if (V8_UNLIKELY(TypeCheckAlwaysSucceeds(obj, target_type))) {
            if (obj.type.is_nullable() && !null_succeeds) {
                CALL_INTERFACE(AssertNotNullTypecheck, obj, value);
            } else {
                CALL_INTERFACE(Forward, obj, value);
            }
        }
        ...
    }

这里,可通过构造使TypeCheckAlwaysSucceeds函数返回true来避免类型检查,并且该值将被简单地解释为目标类型。函数TypeCheckAlwaysSucceeds最终调用v8/src/wasm/wasm-subtyping.cc中定义的 IsHeapSubtypeOfImpl

V8_NOINLINE V8_EXPORT_PRIVATE bool IsHeapSubtypeOfImpl( 
    HeapType sub_heap, HeapType super_heap, 
    const WasmModule* sub_module, const WasmModule* super_module) { 
    if (IsShared(sub_heap, sub_module) != IsShared(super_heap, super_module)) { 
        return false;  
    } 
    HeapType::Representation sub_repr_non_shared = 
        sub_heap.representation_non_shared(); 
    HeapType::Representation super_repr_non_shared = 
        super_heap.representation_non_shared(); 
    switch (sub_repr_non_shared) {  
        ... 
        case HeapType::kNone: 
            // none is a subtype of every non-func, non-extern and non-exn reference  
            // type under wasm-gc. 
            if (super_heap.is_index()) { 
                return !super_module->has_signature(super_heap.ref_index()); 
            } 
            ... 
    } 
    ...  
}

这意味着,如果我们声明的类型索引为常量HeapType::kNone的别名,那么当我们强制转换为任何非函数、非外部引用时,类型检查将始终被忽略。整体上,我们可以通过以下步骤,将引用类型转换为任意其他引用类型:

  1. Type section中,定义一个只有一个anyref类型字段的结构体,并利用上述漏洞使该结构索引为HeapType::kNone
  2. 将原始类型的非空引用放在栈顶,并调用struct.new并将类型索引设置为HeapType::kNone。(我理解为调用构造函数)
  3. 同时,定义一个只有一个目标类型字段的结构体,该索引小于kV8MaxWasmTypes。使用ref.cast将步骤2中实例化的对象转为新结构体类型。
  4. 最后,通过执行struct.get读存储在结构体中的字段,即步骤2中栈上的引用,该引用已从原始类型转为目标类型。

这种将引用类型转换为任意其他引用类型,然后通过解引用将值类型转换为任意其他值类型 - 因此这是一种通用type confusion

特别是,它直接包含几乎所有常见的JavaScript引擎漏洞利用原语:

  • int转换为int*,然后解引用会导致任意读取。
  • int转换为int*,然后写入该引用会导致任意写入。
  • externref转换为intaddrOf()原语,获取JavaScript对象的地址。
  • int转换为externreffakeObj()原语,强制引擎将任意值视为指向JavaScript对象的指针。

虽然不允许从HeapType::kNone转换为externref,但我们实际上通过包装引用到结构体间接操作完成type confusion

这些任意读/写仍然包含在V8内存沙箱中,因为所有涉及的指向堆分配结构的指针都已标记,是沙箱化的压缩指针,而不是完整的64位原始指针。

3.3. 整数下溢导致V8沙箱逃逸#

在V8沙箱内,我们可操作空间十分有限,诸如WebAssembly实例之类的“受信任”对象尚无法被操作,因此仍需进行V8沙箱逃逸。

JavaScript引擎漏洞利用的一个常用对象是ArrayBuffer及其相应views(即TypedArray),因为它允许直接、无标记地访问某些内存区域。

为了防止访问V8沙箱外部的指针,用沙箱化指针对TypedArray进行访问限制。同时,ArrayBuffer的长度字段始终存在“有限大小访问”限制,本质上是2^53-1。(参考StackOverflow,原文此处有误)

然而,在现代JavaScript中,由于引入了可调整大小的ArrayBuffer(RAB)及其可共享变体、可增长的SharedArrayBuffer(GSAB)TypedArray的处理变得相当复杂。这两种变体都具有在创建对象后更改其长度的能力,并且共享变体被限制为永不缩容。特别是,对于具有此类缓冲区的TypedArray,数组长度永远无法被缓存,并且必须在每次访问时重新计算。

此外,ArrayBuffers还具有一个偏移字段,记录实际底层后备存储中数据的开始,计算长度时必须考虑此偏移。

以下是在Turbofan编译器优化后的,负责获取TypedArray长度的代码,具体在v8/src/compiler/graph-assembler.cc中:

TNode<UintPtrT> BuildLength(TNode<JSArrayBufferView> view, 
                              TNode<Context> context) { 
    ... 
    // 3) Length-tracking backed by RAB (JSArrayBuffer stores the length) 
    auto RabTracking = [&]() { 
        TNode<UintPtrT> byte_length = MachineLoadField<UintPtrT>( 
            AccessBuilder::ForJSArrayBufferByteLength(), buffer, UseInfo::Word()); 
        TNode<UintPtrT> byte_offset = MachineLoadField<UintPtrT>( 
            AccessBuilder::ForJSArrayBufferViewByteOffset(), view, 
            UseInfo::Word()); 
    
        return a
            .MachineSelectIf<UintPtrT>( // (1)  
                a.UintPtrLessThanOrEqual(byte_offset, byte_length)) 
            .Then([&]() { 
                // length = floor((byte_length - byte_offset) / element_size) 
                return a.UintPtrDiv(a.UintPtrSub(byte_length, byte_offset),  
                                    a.ChangeUint32ToUintPtr(element_size)); 
            }) 
            .Else([&]() { return a.UintPtrConstant(0); })  
            .ExpectTrue() 
            .Value();  
    };  
 
     // 4) Length-tracking backed by GSAB (BackingStore stores the length) 
    auto GsabTracking = [&]() { 
        TNode<Number> temp = TNode<Number>::UncheckedCast(a.TypeGuard(  
                TypeCache::Get()->kJSArrayBufferViewByteLengthType, 
                a.JSCallRuntime1(Runtime::kGrowableSharedArrayBufferByteLength, 
                                buffer, context, base::nullopt, 
                                Operator::kNoWrite))); 
            TNode<UintPtrT> byte_length = 
                a.EnterMachineGraph<UintPtrT>(temp, UseInfo::Word()); 
            TNode<UintPtrT> byte_offset = MachineLoadField<UintPtrT>( 
                AccessBuilder::ForJSArrayBufferViewByteOffset(), view, 
        UseInfo::Word());  
        // (2)  
        return a.UintPtrDiv(a.UintPtrSub(byte_length, byte_offset), 
                            a.ChangeUint32ToUintPtr(element_size));  
    };  
    ...  
} 

对于RAB ArrayBuffer数组,我们可以在(1)中看到长度的计算公式为Floor((byte_length - byte_offset) / element_size)。但有一个下溢检查,即若byte_offset > byte_length,则返回0。

但对于GSAB ArrayBuffer数组,缺少相应的下溢检查。因此,如果byte_offset > byte_length,则会发生下溢,并且将返回接近2^64的无符号64位整型值。由于这两个字段都能在攻击者控制的数组对象中找到,因此我们可以使用前面讨论的沙箱任意读/写原语轻松触发此操作,进而导致可访问整个64位地址空间。

3.4. 任意Shellcode执行#

使用上述两个BUG,利用变得相当简单:

  1. 通用WebAssembly type confusion部分中描述的原语直接给出了V8内存沙箱的任意读/写。
  2. 然后,将其用于操作GSAB SharedArrayBuffer使其偏移量大于其长度。
  3. 之后,可以使用JIT编译的读/写函数来访问和覆盖进程地址空间中任何位置的数据。
  4. 最后,WebAssembly模块的编译代码是覆盖的合适目标,因为它在RWX Page中,并且可以用shellcode覆盖。

4. 参考资料#

CVE-2024-2887 Chrome Type Confusion漏洞分析
https://picoorg.github.io/posts/cve-2024-2887/
作者
Pico Org
发布于
2024-05-08
许可协议
CC BY-NC-SA 4.0