Pointer Compression in V8
Syed Faraz Abrar
In this blog post, I will provide some details on how the Chromium developers implemented pointer compression in V8. I will also talk about what this means from an exploit development perspective.
I’ve been an intern at InfoSect for the past couple of weeks now, and in this time, I’ve had to do a bunch of security related research into both V8 and Spidermonkey. One of the things that I spent a short amount of my time on was pointer compression in V8. I hadn’t heard of the term at all until Bruno Keith (@bkth_) mentioned it on twitter some time in December last year. The V8 developers also made a blog post to celebrate V8 v8.0 where they mentioned that implementing pointer compression had allowed them to save up to 40% in usage of heap memory! That’s a big improvement, so let’s take a look at what pointer compression is and what it means from an exploit developer’s perspective.
What is pointer compression?
The V8 heap
Before I get into what pointer compression is, I’ll briefly talk about what the V8 heap is.
The thing to notice here is that the upper 32 bits of this entire heap is always the same per run. The 0x0000177f value will change between multiple runs, but within a single run, only the lower 32 bits will differ between different objects on the heap.
When V8 didn’t have pointer compression, any pointers in the V8 heap that pointed to other objects in the V8 heap would be stored as 64-bit pointers. Now, if you think about it, this is essentially a waste, since the upper 32 bits of every single pointer would be the same, so storing the upper 32 bits with every single pointer doesn’t make sense. It would be better to store the lower 32 bits within the heap, and only store the upper 32 bits once through some other means.
The Chromium team thought about this and ended up deciding to implement pointer compression for the V8 heap. Their design decisions are documented in this document.
Essentially, they ended up deciding to take the upper 32 bits of the V8 heap’s memory space (known as the isolate root) and storing it in one specific register (R13) that they decided to call the root register. Now, any pointers in the V8 heap are 32-bit pointers that only store the lower 32 bits of their actual 64-bit address.
Note – in the case of the above example, the isolate root would be 0x0000177f00000000
This is what pointer compression is. The pointers on the heap are compressed when they point to somewhere else in the V8 heap. Any time they need to be accessed, the isolate root that is stored in the root register is simply added to the compressed 32 bit address stored in the V8 heap, and then subsequently dereferenced.
A downside to this is that the V8 heap can not be any greater than 4 GB as that is the maximum limit of a 32-bit address space. This is fine for browsers, as the heap doesn’t need to be greater than 4 GB anyway. It becomes a problem with things like node.js that require larger heaps. Because of this, pointer compression is disabled for node.js until a better solution can be figured out.
You can see some implementation details of pointer compression in V8 in the following files:
What does this mean for exploitation?
Well, to start off with, there isn’t really an easy way to leak the isolate root (upper 32 bits of the V8 heap memory space) through JS, but if you think about it, there really isn’t a need to do that in the first place.
If you can massage a vulnerability into addrof and fakeobj primitives, you can fake a JSArray and control the elements pointer to gain arbitrary r/w primitives. The catch here is that these primitives would only let you perform arbitrary reads and writes within the V8 heap. Why you ask? Because the elements pointer of a JSArray stores a 32-bit compressed pointer, and if you change it to an arbitrary 32-bit memory address, performing reads and writes using this elements pointer will cause V8 to add the isolate root to the 32-bit address each time, meaning you are stuck within the V8 heap no matter what you do.
The way around this is to then go the classic route of allocating an ArrayBuffer on the V8 heap and overwriting its backing store to an arbitrary 64-bit memory address. Then, performing reads and writes with it using either a TypedArray or a DataView object will grant you an arbitrary r/w primitive within the entire 64-bit address space.
The reason this works is because the backing stores of array buffers are allocated using PartitionAlloc (I’m not entirely sure if this is still the case, but this was the case approximately 3-4 years ago, and I haven’t seen anything to suggest that it has changed). All PartitionAlloc allocations go on a separate memory region that is not within the V8 heap. This means that the backing store pointer needs to be stored as an uncompressed 64-bit pointer, since its upper 32 bits are not the same as the isolate root and thus have to be stored with the pointer.
In conclusion, pointer compression as it has been implemented currently only ever so slightly affects exploitation in the sense that all tagged pointers stored on the V8 heap now only take up 32 bits, which means you’ll need two separate pairs of arbitrary r/w primitives: one pair for performing arbitrary r/w within the V8 heap using a fakeobj (or similar) primitive, and another pair for performing arbitrary r/w elsewhere using the backing store of an ArrayBuffer.