CVE-2019-14271 docker cp漏洞分析

漏洞简介

该漏洞是由于docker cp从不受信任的位置加载了动态库所导致的。当用户运行docker cp命令时,docker会调用docker-tar,docker-untar命令对文件进行打包。而在打包动作之前会先使用chroot进入容器内部。但非静态编译版本的docker在docker-tar的打包过程中会加载外部动态库,由于已经chroot进入了容器内部,所以此时会加载容器内部的动态库。攻击者可以通过替换容器内部的动态库进行攻击。

漏洞代码分析

下载docker源码,将源码切换到该cve未修复的某个commit处查看源码。
当用户使用docker cp命令时,docker client会向docker daemon提供的api接口发送请求。docker daemon接受到请求后会根据请求的不同进行打包和解包的操作。此漏洞出现在docker daemon打包的过程中。此处对docker daemon打包过程的代码简要分析:
当用户使用docker cp从容器中向宿主机中复制文件时,docker client会向docker daemon的"get /containers/(containerID)/archive"接口发送请求,请求体包括打包的文件信息和容器的相关信息。docker daemon接受到请求后进入如下处理流程:(/api/server/router/container/copy.go)

func (s *containerRouter) getContainersArchive(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
    v, err := httputils.ArchiveFormValues(r, vars)
    if err != nil {
        return err
    }

    tarArchive, stat, err := s.backend.ContainerArchivePath(v.Name, v.Path)
    if err != nil {
        return err
    }
    defer tarArchive.Close()

    if err := setContainerPathStatHeader(stat, w.Header()); err != nil {
        return err
    }

    w.Header().Set("Content-Type", "application/x-tar")
    return writeCompressedResponse(w, r, tarArchive)
}

其中s.backend.ContainerArchivePath函数经过一系列函数调用后会调用到invokerPack函数(/pkg/chrootarchive/archive_unix.go)

func invokePack(srcPath string, options *archive.TarOptions, root string) (io.ReadCloser, error) {
    ...
    cmd := reexec.Command("docker-tar", relSrc, root)
    ...
}

该函数使用reexec库调用了docker-tar命令对应的执行逻辑。在/pkg/chrootarchive/init_unix.go的init函数中进行了docker-tar命令的注册:

func init() {
    reexec.Register("docker-applyLayer", applyLayer)
    reexec.Register("docker-untar", untar)
    reexec.Register("docker-tar", tar)
}

所以此时程序会调用到tar函数(/pkg/chrootarchive/archive_unix.go),该函数的具体处理逻辑为:

func tar() {
    ...
    if err := realChroot(root); err != nil {
        fatal(err)
    }

    ...

    rdr, err := archive.TarWithOptions(src, &options)
    ...
}

首先会通过realChroot函数进行chroot进入容器内部。(/pkg/chrootarchive/chroot_linux.go)

func realChroot(path string) error {
    if err := unix.Chroot(path); err != nil {
        return fmt.Errorf("Error after fallback to chroot: %v", err)
    }
    if err := unix.Chdir("/"); err != nil {
        return fmt.Errorf("Error changing to new root after chroot: %v", err)
    }
    return nil
}

之后调用TarWithOptions函数进行文件打包操作(/pkg/archive/archive.go)

func TarWithOptions(srcPath string, options *TarOptions) (io.ReadCloser, error) {
    ...
    go func() {
        ta := newTarAppender(
            idtools.NewIDMappingsFromMaps(options.UIDMaps, options.GIDMaps),
            compressWriter,
            options.ChownOpts,
        )
        ...
       if err = ta.addTarFile(filePath, relFilePath); err != nil {
                    logrus.Errorf("Can't add file %s to tar: %s", filePath, err)
                    // if pipe is broken, stop writing tar stream to it
                    if err == io.ErrClosedPipe {
                        return err
                    }
                }
                return nil
            })
        }
    }()

    return pipeReader, nil
}

TarWithOptions函数会创建一个tarAppender对象,并调用其addTarFile函数将文件进行打包(/pkg/archive/archive.go)

func (ta *tarAppender) addTarFile(path, name string) error {
    ...
    hdr, err := FileInfoHeader(name, fi, link)
    ...
}

addTarFile函数使用FileInfoHeader函数根据文件信息和链接信息获取一个填充了更多信息的tar.Header。FileInfoHeader函数会调用到go标准库archive/tar中的FileInfoHeader函数(go/libexec/src/archive/tar/common.go)

func FileInfoHeader(fi fs.FileInfo, link string) (*Header, error) {
    ...
    if sysStat != nil {
        return h, sysStat(fi, h)
    }
}

通过上述代码可以发现,当sysStat不为空时会调用到sysStat所代表的函数。而在go/libexec/src/archive/tar/stat_unix.go的init函数中对sysStat进行了赋值:

func init(){
    sysStat=statUnix
}

因此,我们会调用到statUnix函数

func statUnix(fi fs.FileInfo, h *Header) error {
    ...
    if u, ok := userMap.Load(h.Uid); ok {
        h.Uname = u.(string)
    } else if u, err := user.LookupId(strconv.Itoa(h.Uid)); err == nil {
        h.Uname = u.Username
        userMap.Store(h.Uid, h.Uname)
    }
    if g, ok := groupMap.Load(h.Gid); ok {
        h.Gname = g.(string)
    } else if g, err := user.LookupGroupId(strconv.Itoa(h.Gid)); err == nil {
        h.Gname = g.Name
        groupMap.Store(h.Gid, h.Gname)
    }
     ...
    return nil
}

在该函数中使用到了user.LookupIduser.LookupGroupId两个函数来获取用户和用户组信息。以LookupId为例:

func LookupId(uid string) (*User, error) {
    if u, err := Current(); err == nil && u.Uid == uid {
        return u, err
    }
    return lookupUserId(uid)
}

func lookupUserId(uid string) (*User, error) {
    ...
    return lookupUnixUid(i)
}


func lookupUserId(uid string) (*User, error) {
    ...
    return lookupUnixUid(i)
}

func lookupUnixUid(uid int) (*User, error) {
    ...
    err := retryWithBuffer(userBuffer, func(buf []byte) syscall.Errno {
        var errno syscall.Errno
        pwd, found, errno = _C_getpwuid_r(_C_uid_t(uid),
            (*_C_char)(unsafe.Pointer(&buf[0])), _C_size_t(len(buf)))
        return errno
    })
    ...
}

func _C_getpwuid_r(uid _C_uid_t, buf *_C_char, size _C_size_t) (pwd _C_struct_passwd, found bool, errno syscall.Errno) {
    var f, e _C_int
    pwd = C.mygetpwuid_r(_C_int(uid), buf, size, &f, &e)
    return pwd, f != 0, syscall.Errno(e)
}

可以发现,此时会调用到_C_getpwuid_r函数。而该函数又会调用到cgo中的mygetpwuid_r函数

static struct passwd mygetpwuid_r(int uid, char *buf, size_t buflen, int *found, int *perr) {
    struct passwd pwd;
    struct passwd *result;
    memset (&pwd, 0, sizeof(pwd));
    *perr = getpwuid_r(uid, &pwd, buf, buflen, &result);
    *found = result != NULL;
    return pwd;
}

其中,使用了getpwuid_r函数。使用该函数需要加载nsswitch动态库,但此时已经通过relChroot函数chroot到了容器文件系统中,因此会从容器中动态加载nsswitch库。此时漏洞产生。
该漏洞出现过程中的函数调用链:
getContainersArchive->s.backend.ContainerArchivePath->...->invokerPack->tar->TarWithOptions->addTarFile->FileInfoHeader->tar.FileInfoHeader->statUnix->user.LookuoId->_C_getpwuid_r->C.mygetpwuid_r->getpwuid_r

官方修复代码

官方的修复代码的逻辑很简单,就是在chroot之前加载nsswitch动态库,这样就不会在不受信任的容器内部加载动态库了。
截屏2023-07-25 11.26.27.png


BigCircle
0 声望0 粉丝