在容器和宿主机的文件系统直接复制文件。执行cp命令的入口有两个,分别是docker container cp和docker cp, 两者作用相同。命令格式为
docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH|-
或
docker cp [OPTIONS] SRC_PATH|- CONTAINER:DEST_PATH
,分别表示从容器内复制文件到宿主机,或从宿主机复制文件到容器内。不可以在容器间复制文件,也不可以在容器外之间复制文件。
根据
docker cp 源码分析
, docker cp命令在容器中打包和解包文件时,分别会调用
docker-tar
和
docker-untar
命令。为了防止这两个命令的写操作影响到宿主机,在执行具体操作前,进行了
chroot
。根据
docker-tar,docker-untar源码分析
,我们知道,这些函数是不受
capbility
,
seccomp
和
LSM
的限制的。
受CVE-2019-14271漏洞影响的docker在执行docker cp命令时,docker-tar进程可能在chroot到容器rootfs后,加载nsswitch动态库。攻击者可以将容器内的nsswitch动态库修改为恶意文件,从而获取docker-tar进程权限,实现容器逃逸。
本节(2.影响)的分析较长,且需要对CVE-2019-14271已有了解,因此可以先跳过此节,阅读完全文后再回头阅读。
攻击者可以将容器内的nsswitch动态库修改为恶意文件,从而获取docker-tar进程权限,实现容器逃逸。
docker=18.09.9, 19.03.1 <= docker < 19.03.8 有条件受影响
原因可能是在宿主机加载nsswitch共享库时失败,因此在chroot到容器rootfs后可以重新加载。
TL;DR: 18.09.9未彻底修复; 18.09.0<= docker <=18.09.8不受影响,因其使用的archive/tar库,是从go源码中复制到vendor的代码,这段代码不会去加载nsswitch动态库;go1.11没有所谓的bug
在19.03.1的修复中,docker的开源项目经理thaJeztah在解答“为什么18.09不受影响”时这样解释:
current versions of 18.09 are not affected because they are still using Go 1.10, and a custom archive implementation. The 18.09 release branch was recently updated to Go 1.11 (which also removed the custom archive implementation), but no release was done yet with that code, but we had to backport the fix to prevent the next patch release being vulnerable https://github.com/moby/moby/pull/39612#issuecomment-517999360
他认为18.09不受影响,是因为18.09版本使用的go编译器版本是go1.10, 因此不受影响。
互联网上搜到的大部分分析文章,几乎都是 Yuval Avrahami的文章 docker修复了最严重漏洞CVE-2019-14271 的改编或重写。文章中提到:
Docker is written in Golang. Specifically, the vulnerable Docker version was compiled with Go v1.11. In this version, some packages that contained embedded C code (cgo) would dynamically load shared libraries at runtime.
他认为,在go1.11版本中,有一些因为cgo引入的代码会动态得加载共享库。因此,我们找到的其他文章几乎都是类似的观点,认为go1.11有一个bug。
thaJeztah与Yuval Avrahami的观点类似,他更进一步得认为,go1.10没有这样的bug。
而在实际的分析中,我发现 go1.11没有所谓的bug 。
我花了相当长的时间,摸索清楚了其中真实的漏洞成因。
我发现,go1.10和go1.11的archive/tar库,都会调用
user.LookupId
函数, 这个函数会导致nsswitch动态库的加载。
跟进这个函数,我们最终定位到该函数会调用os/user库的
mygetpwuid_r
,
mygetpwuid_r
是nsswitch的
getpwuid_r
封装。
在以上的行为上,两者没有差异。与之相反的,go1.11在这个问题上,是做了改进的。
go1.11增加了osusergo的build tag,如果指定了osusergo, 则编译器不会包含这个文件,也不会将相关代码编译进二进制文件中。 https://github.com/golang/go/blob/go1.11/src/os/user/cgo_lookup_unix.go#L6
// +build cgo,!osusergo
这个改变,是在go1.11才引入的,这解决了历史版本的一个问题,即静态编译不会再因为nsswitch而被打破。
https://github.com/golang/go/commit/62f0127d81
这是一个提升,这个提升并不会导致CVE-2019-14271的产生,即, 使用go1.11的安全性不会比使用go1.10的安全性弱。
docker18.09使用go1.10,19.03使用go1.11,这个变化,在理论上不会对是是否加载nsswitch产生变化。但是为什么实际验证中,发现docker18.09不会加载nsswitch动态库呢?
在分析go1.10和go1.11没有发现区别后,我转而分析docker18.09和19.03的区别。
我在
19.03分支vendor/archive/tar文件夹相关的commit记录
中,发现一个奇怪的现象,即一开始是有vendor/archive/tar的(这表示,go在编译docker时,会优先选在vendor的库,而不是gosrc中的库)。vendor/archive/tar中的代码,不会调用上文提到的
user.LookupId
函数,也就不会加载nsswitch库。
在相当久之前,这个commit增加了vendor/archive/tar: https://github.com/moby/moby/commit/72df48d1ad417401a5ce0a7ee82a3c8ba33e091c#diff-63d9c33e601a452591d5b82832ad760af4d1b4994675659217058ff5638c77e8
代码如下:未调用
user.LookupId
函数
https://github.com/moby/moby/blob/72df48d1ad417401a5ce0a7ee82a3c8ba33e091c/vendor/archive/tar/stat_unix.go#L18
func statUnix(fi os.FileInfo, h *Header) error {
sys, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return nil
h.Uid = int(sys.Uid)
h.Gid = int(sys.Gid)
h.AccessTime = statAtime(sys)
h.ChangeTime = statCtime(sys)
return nil
在 Kolyshkin参与的向go1.11中加入osusergo的build tag 后不久,Kolyshkin在docker19.03的分支中增加了一个删除vendor/archive/tar的commit。
删除vendor/archive/tar,意味着会使用go官方archive/tar库。根据上文的分析,如果使用静态编译,并使用osusergo tag,则不会加载nsswitch。如果使用非静态编译,则会调用
user.LookupId
函数。
就是这个commit直接导致了漏洞的。虽然osusergo的引入,使得静态编译的二进制文件,不会再加载nsswitch动态库。但是对于非静态编译的二进制文件,因为archive/tar的变化,由不加载变成了加载。
回答我们之前的疑问,为什么18.09不会加载nsswitch动态库呢?
因为18.09.8之前一直都有vendor/archive/tar, 因此,go版本的变化不会对其造成影响。
https://github.com/moby/moby/blob/v18.09.8/vendor/archive/tar/stat_unix.go
但v18.09.9,v18.09.9-rc1删除了vendor/archive/tar。 https://github.com/moby/moby/commit/ebf396050d6e977df2669d5e7d3f38098719f7c2#diff-fc3997a1697456150eac42ad2a2f77c420757ce451fdaf4f95a509cfe6e70cdb
因此18.09.9中,对于该漏洞只有如下一处修复,可能未修复彻底。 https://github.com/moby/moby/blob/v18.09.9/pkg/chrootarchive/archive.go#L16
func init() {
// initialize nss libraries in Glibc so that the dynamic libraries are loaded in the host
// environment not in the chroot from untrusted files.
_, _ = user.Lookup("docker")
_, _ = net.LookupHost("localhost")
在上一个问题中我们有提到,尽管v19.03.0使用go官方的archive/tar库,因为静态编译使用了osusergo这个build tag,也不会将 可能导致加载nsswitch库的 代码编译进来。
https://github.com/moby/moby/blob/v19.03.0/hack/make.sh#L148
LDFLAGS_STATIC=''
EXTLDFLAGS_STATIC='-static'
ORIG_BUILDFLAGS=( -tags "autogen netgo osusergo static_build $DOCKER_BUILDTAGS" -installsuffix netgo )
BUILDFLAGS=( ${BUILDFLAGS} "${ORIG_BUILDFLAGS[@]}" )
https://github.com/golang/go/commit/62f0127d81
// +build cgo,!osusergo
docker在v18.09.0开始使用osusergo
即使未使用osusergo, 使用go1.10前(不含)编译的二进制文件,也不会加载nsswitch。 https://github.com/golang/go/commit/0564e304a6ea394a42929060c588469dbd6f32af#diff-e0ee3deb6e5f67035f8a9808f8ec1af5e8b8926ff4d2de8fd664379fd3a85ae9
那么是否存在未使用osusergo,但是使用了go1.10的版本呢?
docker在v18.05.0-ce-rc1开始使用go1.10 https://github.com/moby/moby/blob/v18.05.0-ce/Dockerfile#L38
但vendor/archive/tar早在v17.06.1-ce-rc2就已经引入了,因此静态编译版本不受影响。 https://github.com/moby/moby/commit/89bacc278b3b6707d57b1b9f95a9221091bbde93
不同版本glibc中的函数实现可能不同,所需的参数不同。静态编译的文件如果需要调用c代码,则可能只能在特定版本的glibc库上运行,这不符合docker设计的初衷。
// TODO
https://github.com/containers/buildah
https://github.com/containerd/containerd/pull/2476
st0n3@yoga:~$ docker run -ti ssst0n3/docker_archive:CVE-2019-14271
... // wait for container starting up
Ubuntu 20.04.1 LTS ubuntu ttyS0
ubuntu login: root
Password: root
Welcome to Ubuntu 20.04.1 LTS (GNU/Linux 5.4.0-56-generic x86_64)
root@ubuntu:~# docker version
Client: Docker Engine - Community
Version: 19.03.0
API version: 1.40
Go version: go1.12.5
Git commit: aeac949
Built: Wed Jul 17 18:15:07 2019
OS/Arch: linux/amd64
Experimental: false
Server: Docker Engine - Community
Engine:
Version: 19.03.0
API version: 1.40 (minimum version 1.12)
Go version: go1.12.5
Git commit: aeac949
Built: Wed Jul 17 18:13:43 2019
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.2.6
GitCommit: 894b81a4b802e4eb2a91d1ce216b8817763c29fb
runc:
Version: 1.0.0-rc8
GitCommit: 425e105d5a03fabd737a126ad93d62a9eeede87f
docker-init:
Version: 0.18.0
GitCommit: fec3683
root@ubuntu:~#
确认libnss_files.so版本相同,避免兼容性问题
root@ubuntu:~# ls -lah /lib/x86_64-linux-gnu/libnss_files.so.2
lrwxrwxrwx 1 root root 20 Aug 17 2020 /lib/x86_64-linux-gnu/libnss_files.so.2 -> libnss_files-2.31.so
root@ubuntu:~# docker run -d -ti --name cve-2019-14271 swr.cn-southwest-2.myhuaweicloud.com/container_pentest/cve-2019-14271:v0.1
e4df8bf1c8594d57285d4670f9f9c6b7e578c0b963071a1b6541e2f16d64ba6e
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lah /lib/x86_64-linux-gnu/libnss_files.so.2
lrwxrwxrwx 1 root root 20 Aug 17 2020 /lib/x86_64-linux-gnu/libnss_files.so.2 -> libnss_files-2.31.so
模拟攻击过程,执行docker cp命令后,可以发现容器内挂载了host_fs
root@ubuntu:~# docker cp cve-2019-14271:/etc/hosts .
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lahd /host_fs
drwxr-xr-x 19 root root 4.0K Apr 6 10:06 /host_fs
详细exp参考 https://bestwing.me/CVE-2019-14271-docker-escape.html
区别在于需要通过删除/etc/nsswitch.conf和libnss_files.so.2等方式,使在宿主机上nsswitch相关动态库加载失败。
root@host $ docker run -ti ssst0n3/docker_archive:ubuntu-20.04_docker-ce-18.09.9_docker-ce-cli-18.09.9_containerd.io-1.2.2-3_runc-1.0.0-rc6
ubuntu login: root
Password: root
root@ubuntu:~# docker version
Client:
Version: 18.09.9
API version: 1.39
Go version: go1.11.13
Git commit: 039a7df9ba
Built: Wed Sep 4 17:24:10 2019
OS/Arch: linux/amd64
Experimental: false
Server: Docker Engine - Community
Engine:
Version: 18.09.9
API version: 1.39 (minimum version 1.12)
Go version: go1.11.13
Git commit: 039a7df
Built: Wed Sep 4 16:19:38 2019
OS/Arch: linux/amd64
Experimental: false
root@ubuntu:~# rm /lib/x86_64-linux-gnu/libnss_files.so.2
root@ubuntu:~# rm /etc/nsswitch.conf
root@ubuntu:~# docker run -d -ti --name cve-2019-14271 swr.cn-southwest-2.myhuaweicloud.com/container_pentest/cve-2019-14271:v0.1
root@ubuntu:~# docker cp cve-2019-14271:/etc/hosts .
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lahd /host_fs
drwxr-xr-x 19 root root 4.0K May 27 03:12 /host_fs
root@ubuntu:~# docker exec -ti cve-2019-14271 cat /host_fs/etc/hostname
ubuntu
根据docker cp的源码分析,我们已经知道docker-tar, docker-untar的存在,也需要理解一下这两个命令的执行过程,参见:
docker-tar和dockerd共用一个二进制,基于docker的reexec库实现,有一个简单的调试技巧,可以跳过docker cp的前序步骤,直接进入docker-tar的逻辑:
因为reexec是以cmdline选择命令代码的,所以可以通过复制或链接,得到一个docker-tar。
root@ubuntu:~# which dockerd
/usr/bin/dockerd
root@ubuntu:~# ln -sf /usr/bin/dockerd /usr/bin/docker-tar
root@ubuntu:~# docker-tar --help
Usage of docker-tar:
使用strace可以调试docker-tar执行时的系统调用
echo "{}" > /tmp/json
mkdir /tmp/test
cp /etc/hosts /tmp/test/
strace -r -f docker-tar </tmp/json /test/ /tmp/ > /tmp/test.tar
strace -r -f -k -e trace=open,openat,chdir,chroot docker-tar </tmp/json /test/ /tmp/ > /tmp/test.tar
docker-untar的调试类似
echo "{}" > /tmp/json
docker-tar </tmp/json /etc/hosts /tmp/ > /tmp/hosts.tar
strace -f docker-untar </tmp/hosts.tar 3</tmp/json /etc/ /tmp/
如果要gdb调试,可以将dockerd中的"docker-tar"字符串等长度替换,并dockerd复制到对应路径。这样gdb调用的进程的cmdline就一致了。
sed -i s@docker-tar@/bin/r-tar@g dockerd
cp dockerd /bin/r-tar
在gdb内,使用set args指定参数
set args </tmp/json /test/ /tmp/ > /tmp/test.tar
docker-tar进程是一个特权进程,为了避免对宿主机造成影响,会chroot到容器的rootfs内执行打包动作 https://github.com/moby/moby/blob/v20.10.6/pkg/chrootarchive/archive_unix.go#L135-L137
func tar() {
if err := realChroot(root); err != nil {
fatal(err)
rdr, err := archive.TarWithOptions(src, &options)
上面的源码分析已经涉及到了,具体原因是:
docker-tar调用的archive/tar库在收集文件所属用户信息时,调用了glibc的mygetpwuid_r函数,这个函数是基于nsswitch框架开发的,在执行时会动态加载nsswitch相关库(不在文件头中列出,执运行时动态加载)。
https://github.com/golang/go/blob/go1.11/src/os/user/cgo_lookup_unix.go#L100
return syscall.Errno(C.mygetpwuid_r(C.int(uid),
(*C.char)(buf.ptr),
C.size_t(buf.size),
&result))
如果在容器的rootfs内加载nsswitch动态库,则有可能会加载恶意的文件,导致docker-tar进程权限被获取。
strings /usr/bin/dockerd |grep mygetpwuid_r
注:编译了但不一定会执行
因为未执行会加载nsswitch的代码,通常只有在解析host和user,group信息时才会加载nsswitch。
v19.03.1版本,在chroot前就主动执行了user.Lookup和net.Lookup,这样会提前加载nsswitch动态库,在chroot后就不需要再次加载。 https://github.com/moby/moby/pull/39612
func init() {
// initialize nss libraries in Glibc so that the dynamic libraries are loaded in the host
// environment not in the chroot from untrusted files.
_, _ = user.Lookup("docker")
_, _ = net.LookupHost("localhost")
但版本发布后,仍有用户反馈存在同样的问题,原因我们在上文有过分析。后续docker在v19.03.8进行了完整修复。
为了不打破静态编译,docker前期已经应用了名为osusergo的build tag, 这保证了静态编译的版本不会受此风险。 https://github.com/golang/go/commit/62f0127d81
https://github.com/moby/moby/blob/v20.10.6/hack/make.sh#L115
静态编译的二进制文件,不会加载nsswitch动态库。
但是动态编译的版本,例如ubuntu的
docker.io
,
docker-ce
,可能还是会加载。
所以,又将不会加载nsswitch动态库的旧版本archive/tar放入了vendor中。 https://github.com/moby/moby/commit/aa6a9891b09cce3d9004121294301a30d45d998d#diff-630ba09448af522154f38ef7685ef1f44b0f3e9430f80829a03ce24f400f3754
至此可以认为彻底解决了此漏洞。
尽管不再存在此漏洞,但是当前将archive/tar复制到vendor下,显然不够优雅。也是不可持续的——如果golang官方做了某关键更新,docker还需要重点监测并维护。
当然,这些都不是问题,但是作为go语言的明星项目,docker在未来很可能会考虑一种优雅的实现,届时可能会导致新的漏洞产生。正如,CVE-2019-14271的产生一样。
和我们预测得一样,docker已经把 将archive/tar从vendor中移除的工作,加入了 roadmap。
https://github.com/moby/moby/issues/42402
commit aa6a989 (19.03 branch) and #40672 (master / 20.10) re-introduced a local copy of go’s archive/tar package, with a patch applied patches/0001-archive-tar-do-not-populate-user-group-names.patch.
This patch was applied for the 19.03.8 release to improve mitigation for CVE-2019-14271 for some nscd configuration.
We should try to get rid of this fork again.
The discussion on #40672 (comment) mentioned we should open a ticket / pull request in upstream Go to make this functionality “optional”, but I think @tonistiigi also had alternatives in mind to address it.
根据我们一连串的分析,像查案一样,我们似乎可以推测出事件发生的大致时间线:
我们在docker cp相关的漏洞中,多次发现同一个关键因素——特权进程。试想,如果docker-tar的权限与容器的进程一致,即使存在相关问题,也不会有利用价值。要彻底解决这类问题,应从设计角度,限制此类进程权限,否则未来可能仍然会出现类似漏洞。
docker-tar进程是否会、何时会加载nsswitch,是glibc决定的,如果glibc的行为有变化,则可能会导致CVE-2019-14271重新生效。例如glibc2.33引入的reloadable nsswitch特性,即可支持在检测到nsswitch配置变化后,自动加载nsswitch,这样的功能是可能导致docker的漏洞的。
好在glibc社区已经提前发现了这个风险,做了限制,chroot后不主动加载nsswitch。
https://sourceware.org/bugzilla/show_bug.cgi?id=12459 https://github.com/bminor/glibc/commit/429029a73ec2dba7f808f69ec8b9e3d84e13e804
但是,这样的问题仍然让我们心有余悸。相信如果docker-tar等进程不改变这一关键性质,未来可能还会出现相关漏洞。