Couple of months ago, there was a very interesting browser exploitation challenge on chinese CTF(WMCTF-2022). The goal was to exploit a memory corruption in LibJS(SerenityOS’s JS engine) in order to pop a shell. I was busy with trying other challs/couldn’t find the time to start with this one. So I saved the challenge files locally for later when I gain some more pwn knowledge & (somewhat) good grasp on C++ pwnables.
Currently, there’s still no public writeup about the full solution. The organizers posted this, which is not a full exploit but rather a small PoC that gives you some leaks and a r/w primitive(+I found it kinda unstable and dependant on a very specific heap layout/offsets).
Last week I finally found some time to give it a shot, and managed to come up with my own trick to shape the heap properly and build primitives in a way that makes our r/w primitive reusable and more importantly: reliable! :D
readme.txt file, sharing information about how the js engine was compiled(commit 7537a045 in SerenityOS repo)
serenity.patch: a patch file, which supposed to give us hints/clue about in which part of the JS engine the vulnerabillity exists.
I compiled my own build(according to the instructions in the readme.txt file) for a better integration with gdb.
The bug
The flaw was very straight-forward. We have a heap Out-of-Bound r/w.
The bug is in the Userland/Libraries/LibJS/Runtime/ArrayBufferPrototype.cpp file:
The byte_length_setter function(above) is triggered whenever we try to set a new length to a JavaScript ArrayBuffer object. For example:
In that case, it means the boundary of the array is very big, but in fact the original ArrayBuffer object was only 0x10 bytes long. This can be leveraged to a relative/OOB read-write primitive on the heap.
Initial PoC
At first, I tried to mess around with it without understanding too much the js engine internals. I tried to loop through buf as if it was an array and access indexes above 0x10(which was the original size), but that didn’t work out. After some googling around, I found that in order to read/write to a Arraybuffer object, we’ll need to wrap it around with a DataView:
It worked! we got some random heap data(+a pointer leak!). However, this is just a relative r/w on the heap. In order to continue with the exploit-dev process we’ll need to build stronger primitives(like arbitrary r/w), and for that we need to:
First, learn some basic internals about the JS engine(at least what’s required for the challenge)
Second, fire up gdb and pick strategic places to insert breakpoints
For now, we’ll focus on the 1st one as it’s more important.
The AK::ByteBuffer class
The JavaScript’s JS::ArrayBuffer class implementation has a m_buffer member, which contains the buffer itself. This member has a type of AK::ByteBuffer - This class has two interesting fields that caught my attention:
Note: This is a template class, the value of inline_capacity is set at AK/Forward.h#L19 to 0x20.
We’ll use those two fields in order to elevate our primitive from relative heap r/w to arbitrary r/w(below).
From relative heap r/w to arbitrary r/w
When allocating a new JS::ArrayBuffer, a new AK::ByteBuffer object is created:
If we request a size below 0x20, the content will be ‘embedded’ into the object itself(into m_inline_buffer[0x20]) and m_inline will have a true value.
If the requested size is above 0x20, m_inline_buffer[] will not be used as a buffer. Instead, the other part of the union will take place:
The first 8 bytes will be used to store a pointer(u8* m_outline_buffer)
The second 8 bytes will be used to store the length(size_t m_outline_capacity)
So, in order to achieve arbitrary r/w we can:
Create an ArrayBuffer object with a small length(new ArrayBuffer(0x10))
Set the 0x10 bytes to be a pointer value, followed by a length
Using the OOB r/w primitive, overwrite the m_inline member to be false(instead of true, which is the current value in buffers that have size less than 0x20)
After doing this, the next time we’ll access this buffer the JS engine will treat the first 8 bytes of the inline buffer as if it was a pointer! The PoC for this is below:
Nice! we managed to read the first 8 bytes of an arbitrarry address(lagom_base).
However:
Due to the heap layout, some offsets might change as we continue to develop our exploit.
If we want to change the object to point to a different address, we won’t be able to do it again. Because we changed the m_inline property(at offset 0x28) from 1 to 0, if we try to access offset 0x28 again it will attempt to perform memory access on lagom_base+0x28.
This requires us to create a new ArrayBuffer+DataView object on each r/w we want to perform, which can make the exploit look pretty ugly.
In order to continue with the exploit-dev process, we need to make this arbitrary r/w primitive re-usable.
Making the primitive re-usable
For this, I came up with the following heap spray strategy:
Spray ArrayBuffer objects with size 0x10 and put all of them into a JS array(we’ll call it spraybuf[])
Every ArrayBuffer will have the first 8 bytes containing some sentinel value(0xdeadbeef)
Followed by the sentinel value, we’ll write the index of the current object inside the spraybuf[] js array.
Then, we scan the heap with our initial primitive in order to find 0xdeadbeef
Once we find it, we can know the index of it in the spraybuf[] array by reading the next 8 bytes and save it in a new JS variable idx.
From here-on, we can reference spraybuf[idx] in order to read/write in memory without having the need to create a new ArrayBuffer every time we want to use the arbitrary r/w primitive.
We’ll wrap everything up into 2 functions read() and write():
awesome. Up next: Building our way up to RIP takeover!
RIP Control
Like in many browser exploits, in order to takeover the instruction pointer we’ll scan the heap & look for a stack ptr leak.
After we got a stack pointer leak, we can use the read() function (again) to scan through the stack in order to find the return address of JS::Program::execute()(this func should return after the JS interpreter finished processed everything & ready to exit).
ROP Chain
This part is pretty straight-forward: After taking over the PC/RIP register, we need to jump to a shellcode. However, we don’t have any rwx page. To overcome this, we will:
Get a heap leak
Place our shellcode in that address(or, somewhere nearby)
Insert a ROP chain that will:
Call mprotect in order to make that page executable