什么是 kubectl cp 命令
kubectl 是 Kubernetes 中耳熟能详的控制命令,而 kubectl cp 则是其中一个子命令,让我们来看看这个子命令能干什么:
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
|
$ kubectl cp --help
Copy files and directories to and from containers.
Examples:
# !!!Important Note!!!
# Requires that the 'tar' binary is present in your container
# image. If 'tar' is not present, 'kubectl cp' will fail.
# Copy /tmp/foo_dir local directory to /tmp/bar_dir in a remote pod in the default namespace
kubectl cp /tmp/foo_dir <some-pod>:/tmp/bar_dir
# Copy /tmp/foo local file to /tmp/bar in a remote pod in a specific container
kubectl cp /tmp/foo <some-pod>:/tmp/bar -c <specific-container>
# Copy /tmp/foo local file to /tmp/bar in a remote pod in namespace <some-namespace>
kubectl cp /tmp/foo <some-namespace>/<some-pod>:/tmp/bar
# Copy /tmp/foo from a remote pod to /tmp/bar locally
kubectl cp <some-namespace>/<some-pod>:/tmp/foo /tmp/bar
Options:
-c, --container='': Container name. If omitted, the first container in the pod will be chosen
--no-preserve=false: The copied file/directory's ownership and permissions will not be preserved in the container
Usage:
kubectl cp <file-spec-src> <file-spec-dest> [options]
Use "kubectl options" for a list of global command-line options (applies to all commands).
|
通过上述的描述,我们可以知道 kubectl cp 命令可以干如下几件事情:
- 宿主机到Pod:将本地宿主机目录或者文件拷贝到远程 Pod 的某个容器中;
- Pod 到宿主机:将远程 Pod 某个容器的目录或文件拷贝到宿主机;
kubectl cp 命令可以使用的前提是容器必须有 tar
二进制命令。
kubectl cp 命令的原理
kubectl cp 命令的使用依赖于容器中的 tar
命令,这是因为 kubectl 会使用 tar
命令将容器中需要拷贝的文件打包成一个二进制文件,通过 remote exec 接口进行远程传送。
kubectl cp 命令的实现相对比较简单,源码位于 kubernetes/pkg/kubectl/cmd/cp/cp.go
。我们选取 v1.13.0 版本(未修复漏洞版本)来做一个简要说明。
kubectl cp 命令的核心逻辑如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
func (o *CopyOptions) Run(args []string) error {
// ...
// Pod -> Pod: 如果源目标是一个 Pod 且目标源地址也是一个 Pod
// 但是 kubectl cp 命令并不支持这一种情况,所以会先判断源目标是否是宿主机地址
if len(srcSpec.PodName) != 0 && len(destSpec.PodName) != 0 {
// 判断源目标是否是宿主机地址
if _, err := os.Stat(args[0]); err == nil {
// 执行 Host -> Pod 的逻辑
return o.copyToPod(fileSpec{File: args[0]}, destSpec, &exec.ExecOptions{})
}
return fmt.Errorf("src doesn't exist in local filesystem")
}
// Pod -> Host: 如果源目标是 Pod 且目标源地址是宿主机地址
if len(srcSpec.PodName) != 0 {
return o.copyFromPod(srcSpec, destSpec)
}
// Host -> Pod: 如果源目标是宿主机地址且目标地址是 Pod
if len(destSpec.PodName) != 0 {
return o.copyToPod(srcSpec, destSpec, &exec.ExecOptions{})
}
// ...
}
|
因为 copyFromPod()
会改变宿主机状态(将 Pod 中的文件拷贝到宿主机中),所以我们重点看这个函数的实现:
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
|
func (o *CopyOptions) copyFromPod(src, dest fileSpec) error {
// ...
// 建立一个读写的 pipe
// 从 reader 端进行读操作,从 outStream 端进行写操作
reader, outStream := io.Pipe()
// 构造 remote exec 的参数
options := &exec.ExecOptions{
StreamOptions: exec.StreamOptions{
IOStreams: genericclioptions.IOStreams{
In: nil,
// 输出
Out: outStream,
ErrOut: o.Out,
},
// 对应 Pod 的 Namespace
Namespace: src.PodNamespace,
// 对应 Pod 的名称
PodName: src.PodName,
},
// 使用 tar 命令将 Pod 中的目标文件打包成一个二进制文件
// 然后通过 outStream 进行传送
// src.File 可以是一个目录,此时就是将一个目录的文件打包成一个 tar 文件
Command: []string{"tar", "cf", "-", src.File},
Executor: &exec.DefaultRemoteExecutor{},
}
// 启动一个 goroutine 来执行远程 Pod 的 tar 命令
go func() {
defer outStream.Close()
o.execute(options)
}()
// ...
// 构造 prefix,后面将提及这一逻辑
// ...
return untarAll(reader, dest.File, prefix)
}
|
untarAll()
就是从 remote exec 接口中读取 tar 文件,并将其写入到宿主机的目标地址:
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
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
tarReader := tar.NewReader(reader)
// 遍历 tar 中的每一个文件
// 如果文件是一个目录,在宿主机目标位置创建目录
// 如果文件是一个普通文件,从 tar 中拷贝内容并在宿主机目标位置创建对应文件
for {
header, err := tarReader.Next()
if err != nil {
if err != io.EOF {
return err
}
break
}
entrySeq++
mode := header.FileInfo().Mode()
outFileName := path.Join(destFile, clean(header.Name[len(prefix):]))
baseName := path.Dir(outFileName)
if err := os.MkdirAll(baseName, 0755); err != nil {
return err
}
// 如果是目录,创建完目录后直接 continue 遍历下一个文件
if header.FileInfo().IsDir() {
if err := os.MkdirAll(outFileName, 0755); err != nil {
return err
}
continue
}
// 目录文件的处理流程
if entrySeq == 0 && !header.FileInfo().IsDir() {
exists, err := dirExists(outFileName)
if err != nil {
return err
}
if exists {
outFileName = filepath.Join(outFileName, path.Base(clean(header.Name)))
}
}
// 普通文件的处理流程
if mode&os.ModeSymlink != 0 {
// 创建符号链接
err := os.Symlink(header.Linkname, outFileName)
if err != nil {
return err
}
} else {
// 拷贝创建对应文件
outFile, err := os.Create(outFileName)
if err != nil {
return err
}
defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
return err
}
if err := outFile.Close(); err != nil {
return err
}
}
}
// ...
}
|
综上,kubectl cp 命令中从容器中拷贝文件到宿主机的流程可以梳理为:
- 通过 remote exec 接口对远程 Pod 中的对应容器执行
tar
命令,将对应路径的目录或者文件打包成一个 tar 包;
- 从网络中读取到步骤 1 的 tar 包后,遍历 tar 包中每一个文件,并在宿主机目标地址上创建对应的目录或文件;
从上面的流程可以看到,kubectl cp 命令依赖于正常的 tar
命令,但是很难验证容器上 tar
的合法性,攻击者可以设计自己的 tar
命令,当执行 kubectl cp 命令时攻击者可利用这个恶意的 tar
命令构造一些精心设计的 tar 包来触发漏洞。
CVE-2018-1002100 漏洞
这个漏洞已经比较老(2018 年),上文的 v1.13.0 代码已经修复了这个问题。我们可以通过更早版本的源码(v1.9.2)来追溯这个问题。
想要理解这个漏洞,让我们先了解下面这个场景:
-
将远程 Pod 中的容器的文件 /home/ubuntu/foo.txt
拷贝到宿主机的 /tmp/bar.txt
此时就是将 foo.txt
的内容拷贝到宿主机的 /tmp/bar.txt
-
将远程 Pod 中的容器的目录 /home/ubuntu
拷贝到宿主机的 /tmp/foo
下
如果此时 /home/ubuntu
的目录结构为:
1
2
3
4
|
/home/ubuntu
|-- bar
| `-- bar
`-- foo.txt
|
则拷贝之后,/tmp/foo
会变成:
1
2
3
4
|
/tmp/foo/
|-- bar
| `-- bar
`-- foo.txt
|
也就是说,从远处拷贝的文件或某个目录都必须位于宿主机目标位置下。也就是说我们必须:
- 取出 tar 中路径中的非原始目录前缀部分,比如原始目录是
/home/ubuntu/bar
,而我们只需要 /bar
这部分,而不需要前缀 /home/ubuntu
;
- 将步骤 1 中的到的非前缀部分与目标地址进行 Join,从而得到实际需要写入的目标地址,比如目标地址是
/tmp/foo
,则与 /bar
Join 后的目标地址为 /tmp/foo/bar
;
CVE-2018-1002100 发现,kubectl cp 命令并没有检查 tar 包中的路径,如果我们精心构造一个路径名,则有可能逃脱宿主机目标位置,将内容写到其他位置。比如:
执行如下命令:
1
|
$ kubectl cp /some/remote/dir /some/local/dir
|
假如此时得到的 tar 包中地址为 some/remote/dir/../../../../tmp/foo
, 按照未修复漏洞前的逻辑,kubectl cp 不会在 /some/local/dir
中写入,而是会在 /tmp/foo
中写入,从而逃脱了 /some/local/dir
,成功修改到其他路径文件的内容。
我们来看看较早版本的代码是如何触发这个 bug。如前文所述,我们在确定目标地址时会有两个步骤:
-
取出 tar 中路径中的非原始目录前缀部分
首先我们必须获取到原始目录前缀部分,这部分逻辑是在 copyFromPod()
中实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
func (o *CopyOptions) copyFromPod(src, dest fileSpec) error {
// ...
// getPrefix() 去除前导 '/'
// 比如 '/home/ubuntu' 将得到 'home/ubuntu'
// 这点是与 tar 的逻辑保持一致,tar 也是会去除前导 '/'
prefix := getPrefix(src.File)
// Clean() 返回最短的等价于原始输入路径的路径
// Clean() 最主要是去除一些不必要的 '..'
// 比如 '/path/to/test/../../hello' 将输出 '/path/hello'
prefix = path.Clean(prefix)
return untarAll(reader, dest.File, prefix)
}
|
-
将步骤 1 中的到的非前缀部分与目标地址进行 Join,从而得到实际需要写入的目标地址
这部分逻辑由 untarAll()
完成。untarAll()
会有传入步骤 1 获得的 prefix
:
1
2
3
4
5
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
outFileName := path.Join(destFile, header.Name[len(prefix):])
// ...
}
|
理清楚逻辑后,让我们来看看 CVE-2018-1002100 的 PoC:
如果从 kubectl cp 命令行中接收到的 Pod 源地址为 /some/local/dir
,目标宿主机地址为 /some/local/dir
,则此时 prefix
为 some/local/dir
。此时 tar 中有一个地址为 some/remote/dir/../../../../tmp/foo
,去除前缀,可得 /../../../../tmp/foo
。该路径再与 /some/local/dir
进行 Join 操作,可得到目标地址为 /tmp/foo
,从而达到写入其他目录的目的。
如何修复这个问题呢 ?其实很简单,在步骤 2 中取出 tar 中路径中的非原始目录前缀部分的时候调用 path.Clean()
即可,如 #61298 所示:
1
2
3
4
5
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
outFileName := path.Join(destFile, clean(header.Name[len(prefix):]))
// ...
}
|
从而阻止其目录逃脱。
CVE-2019-1002101 漏洞
这个 directory traversal 漏洞最早由 Twistlock 的工程师发现。这个问题实际也是通过精心构造一个 tar 包来触发的,也是基于 CVE-2018-1002100 的原理发动攻击。
untarAll()
函数在处理符号链接时,并未对路径做严格的检查,这导致攻击者可以精心构造一个指向其他目录的符号链接,然后使用这个符号链接写入一些恶意数据,从而完成攻击。
让我们来看看这个漏洞的 PoC:
这个 PoC 大概实现了如下逻辑:
-
当用户使用 kubectl cp 命令的时候,远程 Pod 的恶意的 tar
命令被触发,将产生如下的 tar 包:
./baddir/twist
是一个符号链接,指向 /proc/self/cwd
;
./baddir/twist/.bashrc
是一个普通文件;
-
当执行 untarAll()
时,遍历 tar 包中的文件时,会先遍历到 ./baddir/twist
,发现这是一个符号链接,将执行如下逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
// 如果是符号链接
if mode&os.ModeSymlink != 0 {
// 直接创建对应的符号链接
err := os.Symlink(header.Linkname, outFileName)
if err != nil {
return err
}
}
// ...
}
|
此时的结果是:将在宿主机上将 ./baddir/twist
这个文件指向 /proc/self/cwd
;
-
创建 ./baddir/twist/.bashrc
时,由于 ./baddir/twist
已经指向了 /proc/self/cwd
,则此时等于是将 /proc/self/cwd/.bashrc
覆盖成了 tar
中 .bashrc
,从而将当前目录的 Bash 配置文件改写;
如何修复这个问题呢?其实很简单,在创建符号链接的时候增加更多的检查,将其限制在指定宿主机目标目录下,可见 #75037:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
if mode&os.ModeSymlink != 0 {
linkname := header.Linkname
// 判断这个符号链接是否在目标地址之外,如果是则忽略,不予创建
relative, err := filepath.Rel(destFile, linkname)
if path.IsAbs(linkname) &&
(err != nil || relative != stripPathShortcuts(relative)) {
// ...
continue
}
if err := os.Symlink(linkname, outFileName); err != nil {
return err
}
// ...
}
}
|
CVE-2019-11246 漏洞
CVE-2019-11246 是由 Kubernetes Security WG 审计项目中发现的。虽然 CVE-2018-1002100 和 CVE-2019-1002101 对目录逃脱的漏洞做了修复,但是这种修复并非完备的。
还是回到上文的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
if mode&os.ModeSymlink != 0 {
// ...
// 这个分支其实只判断了 linkname 是绝对路径的情况而忽略了相对路径的情况
if path.IsAbs(linkname) &&
(err != nil || relative != stripPathShortcuts(relative)) {
...
continue
}
// ...
}
}
|
如果 linkname
是一个精心构造的相对路径,上面的判断其实就失效了。
如何修复这个问题呢?其实很简单,再重新完备检查逻辑,如下所示:
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
|
// linkJoin() 会考虑 link 是相对路径的场景
func linkJoin(base, link string) string {
if filepath.IsAbs(link) {
return link
}
return filepath.Join(base, link)
}
// 判断 dest 是否处于 base 之中
func isDestRelative(base, dest string) bool {
fullPath := dest
if !filepath.IsAbs(dest) {
fullPath = filepath.Join(base, dest)
}
relative, err := filepath.Rel(base, fullPath)
if err != nil {
return false
}
return relative == "." || relative == stripPathShortcuts(relative)
}
func untarAll(reader io.Reader, destFile, prefix string) error {
// ...
if mode&os.ModeSymlink != 0 {
linkname := header.Linkname
if !isDestRelative(destDir, linkJoin(destFileName, linkname)) {
// ...
continue
}
// ...
}
// ...
}
|
展望未来
一个 500 多行的代码接连出现 3 个 CVE,让人不得不重新思考这是不是一个好的机制(比如 #58512 所讨论的)。kubectl cp 命令有以下几个问题:
- 强依赖于容器内置
tar
命令,而有些极小镜像有可能没有这个命令;
- 无法控制
tar
命令的合法性,攻击者可以提供一个恶意的 tar
命令;
- tar 包是一个很简单的打包格式,缺少类似 HMAC 这类的消息验证机制;
等等。
目前社区有种种声音,但目前仍未统一,比如:
- 是否可以通过增加新的 CRI 接口从而让 kubelet 直接支持 kubectl cp 的特性 ?
- 废弃 kubectl cp 子命令,可见讨论;
目测这个命令很有可能在未来被废弃,取而代之更安全的方式。