Rating:
Fullchain
Do you have what it takes to pwn all the layers?
Last weekend it was GoogleCTF weekend again :). I didn't have much time to
spend on it this year; but I took a quick look at Fullchain
. This challenge
comes with two patches for chrome and a linux kernel module (with source).
Together these introduce three vulnerabilities.
Reaching the flag required a full chain of three exploits.
The bug is introduced in v8_bug.patch
:
In TypedArrayPrototypeSetTypedArray
an important bounds check is removed:
CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
otherwise IfOffsetOutOfBounds;
// CheckIntegerIndexAdditionOverflow(srcLength, targetOffset, targetLength)
// otherwise IfOffsetOutOfBounds;
This removes the bounds check when calling set
on a TypedArray. The following
will then crash a debug build of d8
:
d8> var a = new Uint8Array([0,0])
d8> a.set(a, 1)
abort: CSA_ASSERT failed: Torque assert 'countBytes <= target.byte_length -
startOffset' failed [src/builtins/typed-array-set.tq:270]
[../../src/builtins/typed-array-set.tq:92]
Since we can write out of bounds of the data buffer we immediately become interested to discover what lies right after the data buffer of a TypedArray:
var a = new Uint8Array(new Array(4).fill(0xab));
%DebugPrint(a);
// Lots of output, but the important part is:
- map: 0x24fb082c24d9 <Map(UINT8ELEMENTS)> [FastProperties]
- data_ptr: 0x24fb08109c34
gef➤ x/xg 0x24fb08109c34
0x24fb08109c34: 0x082c24d9abababab
We see that right after the data buffer is the compressed
map
pointer. Among other things, this map
holds information about the
datatype of the array. Let's see what happens if we were to change this to
another type. (In a non-debug build of d8
otherwise we will trigger debug
assertions)
var b = new Uint32Array(new Array(4).fill(0xdeadbeef));
%DebugPrint(b)
- map: 0x24fb082c3181 <Map(UINT32ELEMENTS)> [FastProperties]
var c = new Uint8Array([0x81, 0x31]); // Lower two bytes of b's map
a.set(c, 4) // Overwrite a's map
d8> a
2880154539,136327553,134226477,134518561
Now a
is still the same length, but all the elements are 32 bit. Thus the
data buffer is now assumed to be 16 bytes. This allows an OOB read/write on
three 32 bit values after a
.
An interesting memory layout happens if we allocate an ArrayBuffer right after an TypedArray:
var a = new Array(64).fill(0xab);
var b = new Uint8Array(a)
var c = new Arraybuffer(4);
%DebugPrint(b)
- data_ptr: 0x31ef08109958
%DebugPrint(c);
DebugPrint: 0x31ef081099e1: [JSArrayBuffer]
- backing_store: 0x55555563c410
// The distance between the data buffer and the JSArrayBuffer
gef➤ p/x 0x31ef081099e1 - 0x31ef08109958
$2 = 0x89
gef➤ p 'v8::internal::TorqueGeneratedJSArrayBuffer<v8::internal::JSArrayBuffer, v8::internal::JSObject>::kBackingStoreOffset'
$3 = 0x1c
gef➤ p/d (0x88+0x1c)/4
$4 = 41
Thus we can modify the backing store of the ArrayBuffer by writing to fields 41 and 42 of the corrupted TypedArray.
However, so far we only encountered compressed pointers but the top bits of this pointer are also in the memory right after the corrupted TypedArray:
gef➤ x/xw 0x31ef08109958 + 27*4
0x31ef081099c4: 0x000031ef
And in a real chrome process (this is where we leave the d8
shell) we can then
find a chrome pointer at 0x000031ef00000040
to break ASLR
Following along with Project Zero it is now possible to enable the Mojo bindings and reload the page.
Follow the same offsets as the blog from the g_frame_map
, the magic offset to
RenderFrameImpl::enabled_bindings_
can be found by disassembling the function
which checks if Mojo bindings should be enabled:
gef➤ disassemble 'content::RenderFrameImpl::DidCreateScriptContext(v8::Local<v8::Context>, int)'
0x000056049f6e2369 <+89>: test BYTE PTR [rbx+0x444],0x2
All that's left for this stage is to write the BINDINGS_POLICY_WEB_UI
bit to
the address we found and reload the page. We then have the Mojo
object in our
javascript context, which we need for the next stage.
The bug is introduced in sbx_bug.patch
, which adds a CtfInterface
with
three functions:
void CtfInterfaceImpl::ResizeVector(uint32_t size,
ResizeVectorCallback callback) {
numbers_.resize(size);
std::move(callback).Run();
}
void CtfInterfaceImpl::Read(uint32_t offset, ReadCallback callback) {
std::move(callback).Run(numbers_[offset]);
}
void CtfInterfaceImpl::Write(double value,
uint32_t offset,
WriteCallback callback) {
numbers_[offset] = value;
std::move(callback).Run();
}
There are no bounds check on anything here. Again we can read/write out of
bounds, this time to everything after the numbers_
vector.
This CtfInterface
consists of three pointers:
If we were to allocate a CtfInterface
with a vector of size 4, we get in
memory:
0x...4e0
v
v + 0x20
A possible way gain code execution is thus to allocate a CtfInterface
and scan
some memory following the vector for this pattern. This immediately gives us
the address of our vector (breaking heap ASLR) and a vtable pointer (breaking
chrome ASLR and allowing RIP control).
Ofcourse the vector may not always be located in memory before the
CtfInterface
, but we can just keep allocating CtfInterfaces
in a loop until
we have the desired layout.
To pop the shell I then fake a CtfInterface
vtable (of which I modify the
resizeVector
function pointer). And then write a small ropchain to execve
/bin/sh
. Since rax
points at the vtable the xchg rsp, rax
gadget is
useful to pivot the stack.
I had some issues with my ropchain getting overwritten so in my exploit you'll see that I first scan for some unused (zeroed) memory to write the vtable+ropchain at.
Now that we have a shell as a normal user we only need to get root.
We get the source to a kernel module which is inserted into the running kernel.
The module adds a device: /dev/ctf
with which we can interact from
userspace to:
Allocates a zero initialized ctf_data
struct and saves it in the files struct
private_data
Read/Write an user specified amount of bytes from ctf_data->mem
to
user/kernel space
Allocate/free a buffer of user controlled size in kernel space. Saves the
allocated buffer in ctf_data->mem
Free the ctf_data->mem
buffer
However on freeing ctf_data->mem
the pointer is not zeroed out. Allowing a
UAF on this allocation.
If we allocate and free a buffer of size 0x10
, we can reallocate this memory
with another ctf_data
struct. We can use this to create the following memory
layout (which is kind of hard to describe in words so I drew a picture):
I've drawn the file
structs as red boxes and put a letter representing a
pointer in each private_data
.
Now two things are interesting:
C
) Because of the reallocation trick
this is the same pointer as is stored in fd3's private_data
ctf_data
. This gives us arbitrary
read/write in the kernel.Because we have a leaked heap pointer and arbitrary read/write we can scan
memory for fd3's private_data
, since this should be at offset 0xc8
from the
file
struct we can filter on this to eliminate most false positives.
Sometimes this still hits a false positive but it's a CTF so it's good enough
(and it wouldn't be hard to perform more validation or abort)
After locating fd3's file
struct we can break ASLR by reading a kernel
pointer from this struct. We gain RIP control by overwriting the f_ops
. I
overwrite the ctf_write
function with set_memory_x
, then:
write(fd3, (char *)1, 1);
Becomes:
ctf_write(struct file *f, const char __user *data, size_t size, loff_t *off)
Which becomes (because of the function pointer hijack)
set_memory_x(f, 1)
Which means we now have an RWX page in the kernel at fd3's file
to drop our
shellcode. We can then obtain root with the classic
commit_creds(prepare_kernel_creds(NULL))
shellcode.
Use the arbitrary read/write to cleanly restore fd3's file
and then execve
into /bin/sh
, finish with cat /dev/vdb
for the flag!
Note that the target did not have network connectivity, so I dropped my kernel exploit the low-tech way by pasting it into the shell as base64 :)
See also the exploits:
CTF{next_stop_p2o_fda81a139a70c6d4}