Home avatar

Wings

2023 XCTF Final Pwn haslang

一些废话: 比赛时先去看了 V8, 瞄了眼这题没什么想法后又跑去做 misc 了 (逃). 当时以为是什么打解释器的难题就先放了去做 shellgame, 结果啥都没做出来, 喜提零贡献 (逃).

V8 Pwn Basics 1: JSObject

JS 有这几种基本数据类型: Undefined, Null, Boolean, String, Symbol, Number, Object. 数组和函数实际上是 Object. 显然, 最复杂的一种类型就是 对象Object. 本文主要介绍 Object 在 V8 中的表示.

Object 的本质是一组有序的 属性property, 类似于有序字典, 即键值对有序集合. 键可以是非负整数, 也可以是字符串. 键为数字的属性称为 编号属性numbered property, 为字符串的称为 命名属性named property. 比如一个 object = {'x': 5, 1: 6};. 引用这个属性可以用 . 或者 [], 如 object.x, object[1]. 每个属性都有一系列 属性特性property attributes, 它描述了属性的状态, 比如 object.x 的值, 它是否可写, 可枚举等等.

每当创建一个对象时, V8 会在堆上分配一个 JSObject (C++ class), 来表示这个对象:

I I n n - - O O b b P j j r E e e o l L c c p e e t t M e m n a r e g P P p t n t r r i t h o o e s p p s e e r r t t y y # # 1 n
  • Map: 指向 HiddenClass 的指针
  • Properties: 指向包含 命名属性 的对象的指针
  • Elements: 指向包含 编号属性 的对象的指针
  • In-Object Properties: 指向对象初始化时定义的 命名属性 的指针

其中, Map 是用来确定一个 Object 的形状的, Proerties 和 Elements 都是 Object 中的属性. Properties 和 Elements 独立存储, 为两个 FixedArray (V8 定义的 C++ class), 编号属性一般也叫 元素Element, 他是可以用整数下标来访问的, 一般也就存储在连续的空间中. 而由于动态的原因, 命名属性难以使用固定的下标进行检索. V8 使用 Map Transition 的机制来动态表示命名属性.

Kernel Pwn Heap UAF and Struct cred

和用户态一样, 控持程序流的一个最有效的方法就是覆盖函数指针. 在用户态, 我们可能会去覆盖一些 hook 函数指针, 或者是 FILE vtable 的指针. 在内核态也是一样的. 下面会结合例题来介绍一些可以利用的结构体中的函数指针.

检查启动脚本和 init, 仅开启 smep, kallsyms 可读, 显示 dmesg. 加载了一个 babydriver.ko 到 /dev/babydev.

检查 ko 的保护, 只开启了 NX. 设备注册了 open, close, read, write, ioctl, open 的时候会 kmalloc 一个 chunk, 指针在全局变量上. ioctl 里可以重新 kmalloc 一个任意大小的 chunk, 覆盖全局变量上的指针. read 和 write 正常读写. close 的时候 kfree, 但是没有将指针置零. 由于指针是在全局变量上, 所以我们打开两次设备, 申请一块我们可以控制大小的空间, 关闭一次设备, 将其释放, 这样没有关闭的那个还可以进行读写操作, 也就是 UAF.

kernel 4.4.72 时, cred 结构体没有专门的一个 kmem_cache (slub allocator) 来分配. struct cred 大小是 0xa8, 会使用 kmalloc-192 这个 kmem_cache 来分配. 而我们知道, cred 中保存了 uid, gid, 等, 如果可以改变这些, 那么就可以成功提权.

Kernel Pwn Heap Basics

专门用来分配以页为单位的大内存空间, 且大小必须是 2 的整数次幂, $2^{order} \times PGSIZE$. 分配时会找 order 对应的块, 如果没有, 则向上找更大的去分割一半, 直到分割出一块 order 对应大小的 (线段树动态开点!). 释放时, 如果可以, 会将相邻的两个合并成一个大的, 一直往上合并.

TODO, 学到再说, 估计学不到 kernel heap 风水了

Slub allocator 最初是 slab allocator, 但是效率不高, 现在普遍是用的一个优化版本 slub allocator. slub allocator 是面向数据对象 (结构体) 的堆分配器, 某个或某些对象 (结构体) 的分配使用对应的某个 slub allocator. 最初 slub allocator 会向 buddy system 申请一个或多个连续页面, 这一整个空间称为一个 slub. 一个 slub 会被划分成多个大小相等的 object, 分配给特定的对象 (结构体) 使用.

slub allocator 的实现在 kernel 里是 kmem_cache 这个结构体, 可以简单认为他是 kmem_cache_cpu + kmem_cache_node. kmem_cache 管理多个 slub, 这些 slub 的 object 大小都相同. cat /proc/slabinfo 可以查看这些 slub allocator. slulb allocator 可以通过 kmem_cache_create() 来创建

Kernel Pwn ROP bypass KPTI

在不开 KPTI 的情况下, 每个进程的页表用时有 kernel space 和 user space 的映射, 但是处于用户态时没有权限访问 kernel space. 陷入内核后不需要切换页表和刷新 TLB, 可以避免很大的开销. 不过在 Intel 的 CPU 上, 存在可以侧信道攻击的硬件漏洞 (在 “用户态” 访问内核态?) 于是产生了这种缓解措施: 内核页表隔离KPTI.

KPTI 的想法也很简单, bug 无法避免, 那就让 bug 不能被利用. 每个进程维护两个页表, 一个是用户态使用的, 另一个是内核态使用的. 用户态使用的页表不再包含 kernel space 的所有映射, 仅仅包含如用于处理系统调用等必要的部分. 而内核态使用的页表则包含所有 kernel space 的映射, 以及所有 user space 的映射 (要 copy from, copy to). 既然内核态用了另一张页表, 那么显然给 user space 读写权限就够了, 所以即使是代码段 (对应的页), 在内核态的这张表中, 也没有可执行权限.

copy from/to user 时的 SMAP

某天突然想到开了 SMAP 那怎么进行 copy 呢?

Kernel Pwn ret2usr

在不开启 SMEP, SMAP, KPTI 的情况下, 内核态是可以执行用户空间的代码的. 于是我们只需要在内核态中控制程序流, 使其跳转到用户空间, 执行我们写好的代码, 如 commit_creds(prepare_kernel_cred(NULL)) 便可提取.

不过, 最终的目的是起一个 shell, 但是在内核态无法完成这件事, 所以还需要返回用户态.

iretq 是 x64 的中断返回指令. 当发生中断 (系统调用除外) 陷入内核时, 会发生这些事:

  1. 切换到内核栈. kernel 在 任务状态段Task State Segment (TSS) 这个数据结构上保存了内核栈段起始地址 (ss0) 和 内核栈顶指针 (rsp0).
  2. 接着在内核栈上压入用户空间的 ss, rsp, rflags, cs, rip.
  3. 之后将用户态的通用寄存器值保存起来, 通用寄存器赋值为 0, 保存用户态 gs 寄存器的值并设置内核的 gs .gs 寄存器在内核态时指向一个进程在内核中相关的数据结构, 其中就包含有 kernel stack canary.

返回用户态时, 发生的事情就反了过来:

  1. 恢复通用寄存器的值
  2. 使用 swapgs 指令恢复用户态 gs 的值
  3. 使用 iretq 指令, 内核将栈上用户态 rip, cs, rflags, rsp, ss 恢复

所以, 我们只需要 swapgsiretq, 就能够回到用户态. 寄存器的值是否恢复对我们的利用来说并不重要, 不恢复也是可以的.