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.LookupId
和user.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动态库,这样就不会在不受信任的容器内部加载动态库了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。