CVE-2024-2887:Google Chrome 中的 Pwn2own 获胜错误
CVE-2024-2887:Google Chrome 中的 Pwn2own 获胜错误
Ots安全 2024-05-04 13:15
在 Pwn 大师获得者 Manfred Paul 的客座博客中,他详细介绍了 CVE-2024-2887——Google Chrome 和 Microsoft Edge (Chromium) 中发生的类型混淆错误。他利用这个漏洞作为他获胜漏洞的一部分,导致代码在两个浏览器的渲染器中执行。谷歌和微软很快就修复了这个错误。曼弗雷德慷慨地提供了有关该漏洞以及他如何在比赛中利用该漏洞的详细文章。
在这篇博客中,我描述了一种利用 V8 JavaScript 和 WebAssembly 引擎在渲染器进程内执行任意 shellcode 的方法。这包括绕过 V8 内存沙箱 ( Ubercage ),尽管代码执行仍然受到基于进程隔离的浏览器沙箱的限制。出于演示目的,可以通过使用该–no- sandbox标志运行浏览器来消除此限制。
WebAssembly 通用类型混乱的根本原因
WebAssembly 模块可能包含type定义自定义“堆类型”列表的部分。在基本规范中,这仅用于声明函数类型,但随着垃圾收集(GC)提案[PDF]的采用,本节可以另外定义结构类型,允许在网络组装。
通常,本节中声明的结构只能引用其前面的结构(具有较低类型索引的结构)。为了支持相互递归的数据结构,可以使用称为递归类型组的功能。不是将(可能)相互递归的类型声明为类型节中的单独条目,而是将递归组声明为单个类型节条目。在该组中,声明了各个类型,从而允许相互引用。
type考虑到这一点,考虑负责解析二进制 WebAssembly 格式部分的函数v8/src/wasm/module-decoder-impl.h:
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());
}
}
}
...
}
CVE-2024-2887-0.cpp
在 (1) 处,限制kV8MaxWasmTypes(当前等于 1,000,000)作为最大值传递给consume_count(),确保最多从该type部分读取这么多条目。当添加递归类型组时,此检查变得不够。虽然此代码只允许读取kV8MaxWasmTypes该部分的条目type,但其中每个条目都可能是包含多个单独类型定义的递归类型组。
在进行此更改时,我们清楚地注意到了这种不足,因为与递归类型组一起在 (2) 处添加了第二个检查。这里,对于每个递归类型组,检查构成类型的相加不会超过限制kV8MaxWasmTypes。
然而,第二次检查仍然不够。虽然它保护在递归组内分配的每种类型的索引,但这些组的存在也会对该组外声明的类型产生影响,因为每个递归组都会增加声明类型的总数。
为了使这一点更清楚,想象一个由两个条目组成的类型部分:一个包含kV8MaxWasmTypes条目的递归组,以及该组后面的一个非递归类型。(1) 处的检查已通过,因为该部分只有两个条目。在处理递归组时,(2) 处的检查也通过了,因为该部分具有精确的kV8MaxWasmTypes条目。对于以下单一类型,没有进一步检查:在(3)处,该类型简单地分配在下一个空闲索引处。在这种情况下,索引将为 kV8MaxWasmTypes,超过通常的最大值kV8MaxWasmTypes-1。如果该节末尾有多个非递归类型type,它们将类似地被分配kV8MaxWasmTypes+1、kV8MaxWasmTypes+2等,作为类型索引。
根本原因的影响
超过声明的堆类型的最大数量乍一看似乎是一个非常无害的资源耗尽错误。然而,由于 V8 如何处理 WebAssembly 堆类型的一些内部细节,它直接允许构造一些非常强大的漏洞利用原语。
在 中v8/src/wasm/value-type.h,定义了堆类型的编码:
// Represents a WebAssembly heap type, as per the typed-funcref and gc
// proposals.
// The underlying Representation enumeration encodes heap types as follows:
// a number t < kV8MaxWasmTypes represents the type defined in the module at
// index t. Numbers directly beyond that represent the generic heap types. The
// next number represents the bottom heap type (internal use).
class HeapType {
public:
enum Representation : uint32_t { kFunc = kV8MaxWasmTypes,
kEq,
kI31,
kStruct,
kArray,
kAny,
kExtern,
...
kNone,
...
};
CVE-2024-2887-1.cpp
这里,V8 假设所有用户定义的堆类型都将被分配小于 的索引kV8MaxWasmTypes。较大的索引保留给固定的内部堆类型(以 开头kFunc)。这会导致我们自己的类型声明对这些内部类型之一进行别名,从而导致许多类型混淆的机会。
通用 WebAssembly 类型混淆
为了利用这种编码歧义来造成完整的类型混淆,我们首先考虑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;
}
CVE-2024-2887-2.cpp
按照验证逻辑进入has_struct()方法v8/src/wasm/wasm-module.h:
bool has_struct(uint32_t index) const {
return index < types.size() && types[index].kind == TypeDefinition::kStruct;
}
CVE-2024-2887-3.cpp
由于我们可以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);
}
}
...
}
CVE-2024-2887-4.cpp
这里,type执行检查消除。如果TypeCheckAlwaysSucceeds返回 true,则不会type发出实际检查,并且该值只是被重新解释为 target type。
该函数TypeCheckAlwaysSucceeds最终调用IsHeapSubtypeOfImpl定义在v8/src/wasm/wasm-subtyping.cc:
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());
}
...
}
...
}
CVE-2024-2887-5.cpp
这意味着,如果我们声明的类型索引为常量别名HeapType::kNone,那么当我们强制转换为任何非函数、非外部引用时,类型检查将始终被忽略。结合起来,我们可以通过以下步骤使用它将任何引用类型转换为任何其他引用类型:
-
在类型部分中,定义一个具有 type 单个字段的结构体类型,并使用上述 buganyref使该结构体具有等于类型索引。HeapType::kNone
-
将任何类型的非空引用值放在堆栈顶部,并struct.new使用类型索引设置为 进行调用HeapType::kNone。这将成功,因为根据has_struct()通过上一步建立的索引验证索引。
-
kV8MaxWasmTypes另外,声明一个普通类型索引低于目标引用类型的单个字段的 结构。ref.cast用这个结构体的类型索引来调用。引擎不会执行任何类型检查,因为此时输入值被理解为引用类型HeapType::kNone。
-
最后,通过执行 读回存储在结构中的引用struct.get。
这种引用类型的任意转换允许通过引用任何值类型,更改引用类型,然后取消引用它,将任何值类型转换为任何其他值类型 – 这是一种通用类型混淆。
特别是,它直接包含几乎所有常见的 JavaScript 引擎利用原语作为特殊情况:
• 转换int为int*然后解除引用会导致任意读取。
• 转换int到该int*引用然后写入该引用会导致任意写入。
• 转换externref为int原语addrOf(),获取JavaScript 对象的地址。
• 转换int为externref原语fakeObj(),强制引擎将任意值视为指向 JavaScript 对象的指针。
虽然不允许从 强制转换HeapType::kNone为 an ,但请记住,我们实际上是在多一层间接操作 – 转换为涉及强制转换为对包含一个成员的结构的引用。externrefexternrefexternref
但请注意,这些“任意”读取和写入仍然包含在 V8 内存沙箱中,因为所有涉及的指向堆分配结构的指针都已标记,是堆笼内的压缩指针,而不是完整的 64 位原始指针。
整数下溢导致 V8 沙箱逃逸
上述原语允许自由操作和伪造大多数 JavaScript 对象。然而,这一切都发生在 V8 沙箱有限的内存空间内。诸如 WebAssembly 实例数据之类的“受信任”对象尚无法被操作。现在我们将注意力转向一个可用于逃离内存沙箱的错误。
JavaScript 引擎漏洞利用的常用对象是ArrayBuffer及其相应的视图(即类型化数组),因为它允许直接、无标记地访问某些内存区域。
为了防止访问 V8 沙箱外部的指针,沙箱指针用于指定类型化数组的相应后备存储。类似地,ArrayBuffer 的长度字段始终作为“有界大小访问”加载,本质上将其值限制为最大值 235 − 1。
然而,在现代 JavaScript 中,由于引入了可调整大小的 ArrayBuffer (RAB) 及其可共享变体、可增长的 SharedArrayBuffer (GSAB),类型化数组的处理变得相当复杂。这两种变体都具有在创建对象后更改其长度的能力,并且共享变体被限制为永不收缩。特别是,对于具有此类缓冲区的类型化数组,数组长度永远无法被缓存,并且必须在每次访问时重新计算。
此外,ArrayBuffers 还具有一个偏移字段,描述实际底层后备存储中数据的开始。计算长度时必须考虑此偏移。
现在让我们看一下在优化 Turbofan 编译器中负责构建 TypedArray 长度访问的代码。它可以在 中找到v8/src/compiler/graph-assembler.cc。请注意,为了简单起见,省略了大多数非 RAB/GSAB 情况和负责调度的代码:
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));
};
...
}
CVE-2024-2887-6.cpp
对于由可调整大小的 ArrayBuffer 支持的数组,我们可以在 (1) 中看到长度计算为floor((byte_length – byte_offset) / element_size)。至关重要的是,有一个下溢检查。如果byte_offset超过byte_length,则返回 0。
但奇怪的是,对于 GSAB 支持的数组,缺少相应的下溢检查。因此,如果byte_offset大于byte_length,则会发生下溢,并且减法将返回到接近最大无符号 64 位整数 264 的值。由于这两个字段都在(目前)攻击者控制的数组对象中找到,因此我们可以轻松地使用前面讨论的沙箱任意读/写原语触发此操作。这会导致访问整个 64 位地址空间,因为此函数计算的长度用于绑定任何类型化数组访问(在 JIT 编译的代码中)。
利用任意 Shellcode 执行
使用上述两个错误,利用变得相当简单。通用 WebAssembly 类型混淆部分中描述的原语直接给出了 V8 内存沙箱内的任意读取和写入。然后,这可以用于操纵可增长对象SharedArrayBuffer,使其偏移量大于其长度。然后,可以使用先前 JIT 编译的读/写函数来访问和覆盖进程地址空间中任何位置的数据。覆盖的适当目标是模块的编译代码WebAssembly,因为它驻留在 RWX(读-写-执行)页面中,并且可以用 shellcode 覆盖。
原文翻译自:
https://www.zerodayinitiative.com/blog/2024/5/2/cve-2024-2887-a-pwn2own-winning-bug-in-google-chrome
感谢您抽出
.
.
来阅读本文
点它,分享点赞在看都在这里