Post

变量未初始化漏洞利用研究综述

因为最近要研究变量未初始化问题,所以希望先找到一个合适的 target,对应到真实世界的安全漏洞。虽然说不用研究漏洞利用的具体手段,但搞清楚漏洞利用的可行性和来源,培养对目标代码进行人工审计的感觉,对讲好论文的故事是很有帮助的。

变量未初始化是一个很普遍的问题,但具体能够导致什么样的安全漏洞,其实很大程度依赖于漏洞的利用手段和目标代码库。之前 Tracer 的工作中针对的对象还是太通用了,我需要定位出更容易受这一类漏洞影响的对象。

漏洞利用类的工作,因为其需要更多地阐述利用细节而不是结果,很多时候并不适合发学术文章,而更容易在工业界的会议上露面。当然,学术界也有类似的 workshop 为这一类工作提供了舞台,例如 WOOT1,可以看看 GoSSIP 对它的介绍。毕竟我还是做学术研究,就先从这一类 workshop 看起吧。

Exploiting Uses of Uninitialized Stack Variables in Linux Kernels to Leak Kernel Pointers

这篇工作发表在 WOOT 20202,讲了一类通过栈变量泄露内核指针来绕过 KASLR 的自动化利用手段,会议视频

作者提到,在 Linux 内核开发者眼中,很多未初始化问题都被认为是低危的(即使有 CVE 编号,CVSS 评分也不高)。许多修补这一类漏洞的补丁,往往被合并得很慢,甚至存在没有被打上去的情况。这一类被泄露的内核态的栈变量(stale values)可以在用户态被获取,如果其为内核指针,攻击者则能够知道内核的地址空间基址,来绕过内核态地址空间随机化(KASLR)。同样地,这一类漏洞也可能用来泄露内核态的密钥。

但这个领域已经没有免费午餐了,Linux kernel 已经有了动态分析(KMSAN+fuzzing)的工具来检测这一类问题,想把剩下的未初始化变量利用到高危漏洞的利用链也是很难的。从 2010 - 2019 年出现的 87 个 Linux kernel 未初始化变量造成的 CVE 中(没有公开利用代码),76 个是栈变量泄露的,只有 1 个是声称绕过了 KASLR。

于是,作者提出了一种自动化的方法,可以将内核栈变量的泄露转化为泄露内核指针(指向内核函数 / 内核栈)的泄露。他们在 5 个漏洞(其中 3 个的 CVSS 评分只有 2.1)上进行了验证,并在 4 个漏洞上完成了利用,最后将利用代码公开3

placeholder

背景部分,作者揭示了内核栈变量泄露的来源。首先我们先复习一下线程栈空间的布局。每个 Linux 的线程都有对应的内核栈空间,为了最大化地利用局部性原理,每个内核的栈空间都很小(默认 ulimit -a 查看是 8k)。据统计,90% 的系统调用只用了 1260 字节的栈空间。上述策略在提高性能的同时,也会造成栈空间复用,如下图所示。甚至在某些情况下,还存在编译器为了对齐而隐式分配的栈空间复用(甚至不需要泄露完整的 8 字节指针,4 字节就行)。

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
/* file: kernel/time/time.c */
COMPAT_SYSCALL_DEFINE1(adjtimex, struct compat_timex __user *, utp) {
  struct timex txc; // stack object
  int err, ret;

  err = compat_get_timex(&txc, utp);
  if (err)
    return err;
  ret = do_adjtimex(&txc);

  // the above code does not write the ‘tai’ field of the ‘txc’ struct
  err = compat_put_timex(utp, &txc);
  ...
}

/* file: kernel/compat.c */
int compat_put_timex(struct compat_timex __user *utp, const struct timex *txc) {
  struct compat_timex tx32;
  memset(&tx32, 0, sizeof(struct compat_timex)) tx32.modes = txc->modes;
  ...
      // copy the uninitialized data (‘tai’)
      tx32.tai = txc->tai;

  // kernel data leak to the user-space
  if (copy_to_user(utp, &tx32, sizeof(struct compat_timex)))
    return -EFAULT;
  return 0;
}

但如下图所示,之前的研究工作更专注于研究对于未初始化栈变量的控制,但本文更加专注于挖掘潜在的信息泄露漏洞。

placeholder

所以,攻击者需要对 KALSR 的机制有一定了解:在系统启动的时候,内核代码会随机地以 16MB 对齐的方式,被加载到 1GB 的内核空间,也就是说存在 64 个可能的.text段基地址。

  1. 如果我们泄露的指针指向内核函数,即可计算出 KASLR 的偏移。
  2. 如果该指针指向内核栈,则可以从返回地址进一步推断出内核函数地址,造成任意代码执行。也可以读取线程相关的结构体,造成信息泄露。

攻击模型确定了,但如何把泄露的内核指针传给用户态才是漏洞利用的难点。作者为此提出了 3 个挑战,当我们已经有了一个在用户态可观测的泄漏点之后:

  1. 如何计算该未初始化变量到栈基址的偏移?
  2. 如何将内核指针填入该未初始化变量所在的内存位置?(本文假设在填入之后不会再被复写,否则认为不能造成信息泄露)
  3. 如何在泄露小于 8 字节的情况下利用?

针对这些挑战,他们提出了两种方法:

分步找到泄露链 - 可以获得 ASLR 偏移

首先使用 footprint 方法来确定未初始化变量在内核栈中的偏移。如下图所示,类似 cyclic,不过考虑到泄露的变量是 8 字节对齐的,所以按照计数变量来填充,观察用户态的泄露(只需要 1 个字节就行)就能确定偏移了

placeholder

确定偏移后,我们需要找到能够将内核指针填充进该未初始化地址的程序路径。可以借鉴 Fuzzing 的思路,先将 magic number 喷射到栈空间,如下图所示。随后通过 LTP 测试框架,将记录每个能够改变栈空间(表现为将某 8 字节改写成内核态的函数地址 / 栈地址)的 syscall 所对应的参数和影响到的变量的偏移记录下来,与之前得到的偏移对应。最终,组合出能够造成用户态泄露的输入。

placeholder

效果如下图所示,对于 440-2298 字节偏移之间的栈空间,都可以通过 LTP 来找到满足条件的 syscall

placeholder

直接从源头开始喷射 - 可以获得栈基址

编译 eBPF 程序,将 eBPF 虚拟机的帧指针(R10 寄存器)喷射到其虚拟机执行的栈空间(内核态的 512 字节,每次 syscall 喷射的空间都不同)上,观察帧指针是否能够泄露到用户态。值得注意的是,该方法可以通过 eBPF verifier 的检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define BPF_MOV64_REG                                                          \
  (DST, SRC)((struct bpf_insn){.code = BPF_ALU64 | BPF_MOV | BPF_X,            \
                               .dst_reg = DST,                                 \
                               .src_reg = SRC,                                 \
                               .off = 0,                                       \
                               .imm = 0})
#define BPF_STX_MEM                                                            \
  (SIZE, DST, SRC,                                                             \
   OFF)((struct bpf_insn){.code = BPF_STX | BPF_SIZE(SIZE) | BPF_MEM,          \
                          .dst_reg = DST,                                      \
                          .src_reg = SRC,                                      \
                          .off = OFF,                                          \
                          .imm = 0})

void stack_spraying_by_bpf() {
  struct bpf_insn stack_spraying_insns[] = {
      BPF_MOV64_REG(BPF_REG_3, BPF_REG_10),
      ... 
      BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_3, -392),
      BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_3, -400),
      BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_3, -408),

  };

有时候只能泄露不到 8 字节的变量,但要确定内核地址至少需要高位的 52 bit(除去最后页对齐的 12 bit)。作者巧妙地利用了 eBPF 的addsub指令,在泄露高位部分字节的基础上,通过范围逼近的方式来确定剩余的字节,如下图所示,实现了 Small Data Leak。

placeholder

值得注意的是,在 4.14.113 版本内核引入了对于帧指针的操作限制后,该方法会失败。

最后,作者也谈到了对于未初始化漏洞的修复,其实 Linux 内核从 4.20 起已经有 STACKLEAK 这样的机制。静态分析方面,有使用数据流分析进行检测的方法(狂喜);动态分析方面,现在主流的方法是使用 KMSAN + syzkaller 进行 Fuzzing。

教程

参考链接

  1. https://wootconference.org/ 

  2. https://www.usenix.org/system/files/woot20-paper-cho.pdf 

  3. https://github.com/sefcom/leak-kptr 

This post is licensed under CC BY 4.0 by the author.