Level 8B - Super "Optimised" Swap (SpiderMonkey pwn)

This is a JavaScript engine exploit. More specifically, we are given a binary called js, which is the SpiderMonkey JavaScript engine. SpiderMonkey is Mozilla's JavaScript and WebAssembly Engine, used in Firefox, Servo and various other projects. It is written in C++, Rust and JavaScript.
Usually for JS pwn challenges, vulnerable patches are made to the JS engines, and you need to write an exploit in JS to gain an RCE over the remote server. As this was my first time writing a JS exploit, I had to do a ton of research in order to understand what was going on ๐ญ. On the bright side it meant that I learnt alot more about memory management in the SpiderMonkey engine :).
The challenge source is mostly in code/diff.txt, which shows us how the JS engine was modified to make it vulnerable. Let's zoom in on the important parts:
The idea is that arrays have a new .swap() function introduced, and its intended functionality is to swap elements in an array. Let's break down what it does:
Gets the
lenof the array usingGetLengthPropertyInlinedChecks that the array can be optimised for dense storage
Get the array capacity using
getDenseCapacity()Get the underlying
js::Value*elements by usingnobj->getDenseElements()Get the
fromandtoproperties of the array usingGetProperty(). These are both 32-bit integers.Check that the indices
fromandtoare both< lenand< capacityActually perform the swap on the
js::Valuemembers usingmemcpy
Using LLMs, I learnt that the GetProperty may invoke arbitrary attacker-controlled JavaScript. In other words, when GetProperty is called to retrieve the index to or from, we can perform operations on the array before returning the integer index.
Having done numerous heap-based userspace pwn challenges before, my first observation was that the elements of the array are stored in js::Value* elements2 before we call GetProperty().
This means that if we can find a way to free the array's backing buffer and reclaim it using other objects, we could potentially achieve an arbitrary swap between elements/metadata of two objects. My guess (which was correct) was that expanding the array beyond the capacity would cause it to be allocated somewhere else.
I also came across SpiderMonkey blogs which mentioned the objectAddress() function which was directly accessible from the JavaScript engine, which saved me the effort of having to implement one using the existing primitives.
Furthermore, on reading previous blogs/CTF challenges, I realised that a popular way of exploiting SpiderMonkey was to first obtain arbitrary read and write, then use those primitives to:
Write a function containing shellcode encoded in doubles, and execute it multiple times so the shellcode is mapped into RX memory
Read the
JsJitInfoAddrwhich is located atobjectAddress(shellcode) + 0x28Read the first 8 bytes in
JsJitInfoAddrto leak the memory address of the readable and executable region (and hence have a better idea of where existing user shellcode is located)Write the address of user shellcode into
jitInfoAddrso it gets executed by the JIT engine
As I was unfamiliar with V8/SpiderMonkey exploits, I also researched on various existing ways of exploiting UAF on arrays. After a few days, I encountered the following holy grail when reading this sentinelone blog:

This prompted me to look at how ArrayBuffers and Arrays were stored in memory. As an exercise, let me run you through how I get an arbitrary read. Suppose I have a function shellcode and its object address. How do I read the content of objectAddress(shellcode) + 0x28? I need this arbitrary read to determine the address of JSJitInfoAddr.
Here's what I'm doing:
Use
Object.defineProperty(arr1, 'to', ...)to dictate the JS that is run before thetoindex is returned to the JS engine. WhengetProperty()is called, we will:Expand the array
arr1from 64 -> 65 elements, expanding it beyond its capacity to trigger a re-allocation elsewhere.Perform garbage collection to free its previous backing buffer.
Create a certain number of typed BigUint64Arrays such that part of
arr1's previous backing buffer gets reclaimedCreate another array
arr2whose only element is the decimal representation ofobjectAddress(shellcode) + 0x28Finally return the index
toand return control to the JS engine
Allow the swap to occur, swapping the data pointer of the LAST BigUint64Array with the VALUE
objectAddress(shellcode) + 0x28Now when we read the first element of the last BigUint64Array, we will effectively be dereferencing
objectAddress(shellcode) + 0x28and reading the 8 bytes at its address.
In this run objectAddress(shellcode) = 0xf4ad676c158
If we run the arbread function we'll see the following debug message from the JS engine:
This tells us that the arr1's elements were at the address 0xd643eb00cb8 and we can look there to check what reclaimed it. Notice that I swapped index 11 (0x0b) and 34 (0x22) here.
This is the memory layout in the debugger AFTER the swap has occurred:
As you can see, I have swapped the elements at 0xd643eb00d10 and 0xd643eb00dc8 . This swaps the last BigUint64Array's data pointer with objectAddress(shellcode) + 0x28.
Now, when I try to read the 0th element of the BigUint64Array, it will dereference objectAddress(shellcode) + 0x28 and give me that value instead of the actual BigUint I put in it! (which was 0x42424242)
This first arbitrary read allows me to read the contents of objectAddress(shellcode) + 0x28 = address of JSJitInfoAddr. I use another similar arbitrary read to read the content of JSJitInfoAddr, which is the virtual address of the RX region.
Now we come to the arbitrary write. In order for the shellcode to be executed by the JIT engine, we have to find the address of the executable shellcode and overwrite the value at JSJitInfoAddr with that. Since the environment was fixed, I found out that the executable shellcode would be assigned at a constant offset with respect to the RX region that we already leaked. Therefore, we just need to overwrite the value of JSJitInfoAddr with that address to successfully pwn it.
Here's a code snippet for the arbitrary write:
The main idea is essentially the same: I allocate a bunch of BigUint64Arrays to partially reclaim the backing buffer of arr1, then allocate arr2 containing the target address in decimal. Then I swap the data pointer of the last BigUint64Array with the actual addr element.
After the swap is performed, when we try to write element 0 of the last BigUint64Array, we will end up dereferencing addr and changing the value in it, achieving our arbitrary write.
This allows us to successfully overwrite the first 8 bytes of JSJitInfoAddr to the address of the executable shellcode. Finally, to activate the RCE, we need to simply invoke the shellcode() function. Below is the full exploit:
Last updated