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
的别名,那么当我们强制转换为任何非函数、非外部引用时,类型检查将始终被忽略。整体上,我们可以通过以下步骤,将引用类型转换为任意其他引用类型:
- 在
Type section
中,定义一个只有一个anyref
类型字段的结构体,并利用上述漏洞使该结构索引为HeapType::kNone
。 - 将原始类型的非空引用放在栈顶,并调用
struct.new
并将类型索引设置为HeapType::kNone
。(我理解为调用构造函数) - 同时,定义一个只有一个目标类型字段的结构体,该索引小于
kV8MaxWasmTypes
。使用ref.cast
将步骤2中实例化的对象转为新结构体类型。 - 最后,通过执行
struct.get
读存储在结构体中的字段,即步骤2中栈上的引用,该引用已从原始类型转为目标类型。
这种将引用类型转换为任意其他引用类型,然后通过解引用将值类型转换为任意其他值类型 - 因此这是一种通用type confusion
。
特别是,它直接包含几乎所有常见的JavaScript
引擎漏洞利用原语:
- 将
int
转换为int*
,然后解引用会导致任意读取。 - 将
int
转换为int*
,然后写入该引用会导致任意写入。 - 将
externref
转换为int
是addrOf()
原语,获取JavaScript
对象的地址。 - 将
int
转换为externref
是fakeObj()
原语,强制引擎将任意值视为指向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,利用变得相当简单:
- 通用
WebAssembly type confusion
部分中描述的原语直接给出了V8内存沙箱的任意读/写。 - 然后,将其用于操作
GSAB SharedArrayBuffer
使其偏移量大于其长度。 - 之后,可以使用
JIT
编译的读/写函数来访问和覆盖进程地址空间中任何位置的数据。 - 最后,
WebAssembly
模块的编译代码是覆盖的合适目标,因为它在RWX Page
中,并且可以用shellcode
覆盖。
4. 参考资料
- CVE-2024-2887 Detail - NVD
- CVE-2024-2887: A PWN2OWN WINNING BUG IN GOOGLE CHROME
- v8/v8: The official mirror of the V8 Git repository
- V8 Sandbox - High-Level Design Doc
- WebAssembly Specification: Release 2.0 + tail calls + function references + gc (Draft 2024-04-28)
- javascript - Do ArrayBuffers have a maximum length? - Stack Overflow