Ancient Monkey: Pwning a 17-Year-Old Version of SpiderMonkey
Last year, @swapgs and I found a fun bug in the popular enterprise VPN solution Zscaler. The VPN client was using the pacparser library to decide which HTTP requests should be proxied. The decision was made based on a pre-configured Proxy Auto-Configuration (PAC) file which contains JavaScript code.
The bug allowed us to escape from a string and execute arbitrary JavaScript in the context of the PAC file. We noticed that pacparser was using a 17 year old version of SpiderMonkey (Firefox’s JS engine), but we didn’t have the chance to develop a full exploit at the time. Instead, we just reported the vulnerability, suggesting that code execution is likely possible.
Fast forward to this year. When preparing Hack.lu CTF 2024, I noticed we were low on pwn challenges, so I decided to dust off my pwning skills (I’m usually a web player) and give this bug a try!
A Promising SpiderMonkey Bug
I started by searching the Mozilla bugtracker for a suitable bug. I found a few that were working in pacparser’s version of SpiderMonkey, including one that sounded quite interesting:
When reading through the comments, this one sums up the bug pretty well:
Switch statements in large functions is quite broken. In this testcase the switch can’t be reached but its mere presence causes the emitter to generate the wrong bytecode for the ‘if’ statement. It should be ‘ifeqx 32804’, but it is ‘ifeq 14’ instead.
With this trick an attacker could get the engine to execute arbitrary bytecodes so this might be security sensitive?
When a function’s total bytecode is too long, jumps get messed up. The following code will cause the first if
to make a short jump (11 bytes) instead of jumping to the end of the block:
1 | function trigger(a) { |
Such a misaligned jump can be used to execute arbitrary bytecode by jumping into the literal portion of a UINT24
instruction:
1 | 0000: 56 00 00 GETVAR 0 ; var foo |
The IFEQ
jump at 0000b
will jump 0xb
(11) bytes, landing at 0016
. Since the UINT24
instruction starts at 0015
, this is a misaligned jump. SpiderMonkey will then try to run the next byte (0x41
), which corresponds to the THIS
instruction.
With this, we can execute arbitrary byte code but we’re limited to 3 bytes. Most instructions are either 1 or 3 bytes long. We can run longer sequences of 1-byte instructions by using the first 2 bytes for instructions and setting the last byte to the IFEQ
or IFNE
opcodes. Since IFEQ
and IFNE
are 3-byte instructions, they will consume the 2 bytes after.
When writing JavaScript like 0x000007,0x000007,0x000007
, the resulting bytecode assembly looks like this:
1 | 0000: bc 00 00 07 UINT24 0x000007 |
However, when using the misaligned jump, the following VM instructions are executed:
1 | 0000: bc ; ignored due to misaligned jump |
By cleverly selecting IFEQ
or IFNE
so that the branch is never taken, we can succesfully skip the 51 bc
bytes from the original POP
and UINT24
instructions. Instead of NOP
s, we can use arbitrary 1-byte instructions, making a very long chain if necessary.
Memory Corruption
Arbitrary bytecode execution is cool and all, but to do anything meaningful we want to corrupt some memory. For this, I had to look around a bit and see which instructions do interesting stuff.
After a while, I realized that POP
/POP2
might be just what I was looking for. They decrement the VM’s stack pointer and do not check if they underflow the stack:
2349 | BEGIN_CASE(JSOP_POP) |
When inspecting the memory around sp
, I noticed that the stack frame object lives directly beneath. It contains some interesting things, including a lot of pointers:
60 | struct JSStackFrame { |
By chaining a bunch of POP2
s, we can make the stack pointer point to &fp->argv
. Next time we push something to the stack, we will overwrite fp->argv
. We can then read from and write to fp->argv
using the GETARG
and SETARG
instructions:
4599 | BEGIN_CASE(JSOP_GETARG) |
We can control slot
for these instructions, which is a uint16_t
read from the bytecode. This means that we can now read ptr[slot]
and write ptr[slot] = val
. The only problem is that the stack frame gets corrupted along the way, crashing the VM when returning from the current function. I didn’t investigate why exactly this happens or if I could prevent it. Instead, I just started calling a callback function instead of returning:
1 | function pwn(a, cb) { |
Building an addrof
Primitive
In this SpiderMonkey version, JavaScript values can be one of multiple types. The value is tagged with a type-specific masked to allow the VM to know the type before using the value. These are the possible types with their masks:
Type | Mask |
---|---|
JSObject* |
0b000 |
int |
0b001 |
double* |
0b010 |
JSString* |
0b100 |
bool |
0b110 |
As an example, when the value is an object, it will be the raw pointer to the object (obj_ptr & 0b000
). When the value is an integer, it will be stored as (int_val << 1) | 1
. When the value is a double or a string, it is a pointer masked with the corresponding mask value.
To convert a raw pointer to a value that can be handled in the JavaScript world, we need to either turn it into an int or a double, or somehow write it into the char array of a string. Ints don’t fit well because we can’t just set the least significant bit to 1. Strings are also inconvenient because we would have to remove the pointer mask to get the char array pointer.
Doubles can be more helpful for us, which becomes clear when we look at them in memory. A value like 0x55d9ab4b8c62
is identified as a double since it has a 2
as the least significant hex digit. To access the value, the VM would remove the mask, resulting in 0x55d9ab4b8c60
, and read the 8 bytes at that address. The bytes 3d0ad7a370bd2a40
correspond to the IEEE-754 floating point encoding of 13.37
:
0x55d9ab4b8c60: 3d 0a d7 a3 70 bd 2a 40 00 00 00 00 00 00 00 00
0x55d9ab4b8c70: 40 00 00 00 00 00 00 c0 80 8c 4b ab d9 55 00 00
However, with our pointer write primitive, we can only write to the double pointer without removing the tag (0x55d9ab4b8c62
). This would result in accessing the following memory:
0x55d9ab4b8c60: 3d 0a d7 a3 70 bd 2a 40 00 00 00 00 00 00 00 00
0x55d9ab4b8c70: 40 00 00 00 00 00 00 c0 80 8c 4b ab d9 55 00 00
Doing such a misaligned write would only overwrite the upper 6 bytes of the double value and “lose” the upper 2 bytes of the value being written since it’s written outside of the double value. Pointers are 8 bytes on 64-bit machines, but they actually only hold 48 bits of information, which is 6 bytes.
This nice coincidence allows us to write a pointer to the misaligned (tagged) double pointer and still have all the relevant bits inside the double value! Writing a pointer of 0x0000414141414141
to a double would look like this:
0x55d9ab4b8c60: 3d 0a 41 41 41 41 41 41 00 00 00 00 00 00 00 00
0x55d9ab4b8c70: 40 00 00 00 00 00 00 c0 80 8c 4b ab d9 55 00 00
Back in the JavaScript world, we can read the double and manually convert it to its byte representation, resulting in 3d0a414141414141
. By removing the first 2 bytes and converting from little-endian, we get the original pointer of 0x414141414141
. Therefore, our addrof
primitive can implemented like this:
1 | function addrof(a, double, ptr, cb) { |
The callback then has to convert the double to its byte representation, for example using this JS IEEE-754 implementation.
Leaking Interesting Stuff
From now on, we just have to do the regular pwn work™. First, we leak the base address of the binary in memory by reading a function pointer. This quite easy with our pointer deref primitive and addrof
. All JS objects contain a struct of function pointers in obj->map->ops
:
By reading the lookupProperty
function pointer of a regular JS object, we get the address of the js_LookupProperty
function inside the binary. From there, we can calculate the base address by subtracting the function’s offset.
To get the libc base address, we can read one of the global offset table (GOT) entries. All of them are resolved because full RelRO is enabled on the binary. From there we can calculate the address of the system()
function which we’ll use to get a shell later.
Getting a Shell
To hijack the control flow, we can overwrite and call a function pointer. The ops
pointers of JS objects are good candidates for this, but the problem is that we don’t fully control the call arguments. Looking at the actual functions, we can see that all of them expect a context pointer as their first argument:
1 | JS_HasProperty(JSContext *cx, JSObject *obj, const char *name, JSBool *foundp); |
Lucky for us, the context object is the same during the runtime of binary and it is stored in the binary’s .bss
section:
0013e1b0 uint64_t myip = 0x0
0013e1b8 uint64_t rt = 0x0
0013e1c0 uint64_t cx = 0x0
0013e1c8 uint64_t global = 0x0
0013e1d0 uint32_t didFirstChecks.0 = 0x0
The object itself lives on the heap, so we can leak its address and write to its location to control what the first argument passed to the ops
functions points to. To get a shell, we will overwrite the getProperty()
function pointer with system()
and write our shell command to *cx
.
To write arbitrary data, we could use a double value like before, but we can go for a simpler way here. Since ints are shifted and masked with 1
, we can use it to create a short byte sequence that has the LSB of the first byte set.
For a shell, we can write "sh\x00"
as the value, which is 736800
in raw bytes, or 0x6873
as a little-endian integer. To accomodate for the value tagging, we have to use the right-shifted value in the JS world (0x3439
). In memory, the value will again be our desired byte sequence due to the value tagging (73680000
).
With everything in place, we can get our shell by simply accessing obj.x
. This JS expression will cause the x
property of obj
to be retrieved via obj->map->ops->getProperty(cx, ...)
. Since the getProperty
operation was overwritten with libc’s system()
and cx
now points to "sh\x00"
, this is equivalent to calling system("sh")
and gives us a shell!
1 | [*] './pactester' |
Summary
All of this took me roughly one week to figure out, during which I learned a ton! I attributed most of this time to me not being a regular pwner, but maybe I overestimated how much time can be saved with experience. After the CTF, I realized that I probably should have given more hints to the players because the challenge was only solved once, and the player used different bugs.
If I would create the challenge again, I would give the bug ticket as a hint so people don’t have to find it themselves and can focus on pwning instead. I think the challenge would have been more approachable that way, since the bug allows executing arbitrary bytecode, giving players much more to play around with.
Anyways, I hope you enjoyed this writeup, maybe you also learned a thing or two. It’s interesting how some missing mitigations made exploitation easier (e.g., no constant blinding) while some missing features made it less convenient (e.g., no WASM rwx
pages). Maybe I have to create another challenge for next year to have an excuse for learning modern JS engine pwning?
Final Exploit
exploit.py
1 | import re |
exploit.js
1 | function toIEEE754(v, ebits, fbits) { |