什么是 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 命令中从容器中拷贝文件到宿主机的流程可以梳理为:

  1. 通过 remote exec 接口对远程 Pod 中的对应容器执行 tar 命令,将对应路径的目录或者文件打包成一个 tar 包
  2. 从网络中读取到步骤 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

也就是说,从远处拷贝的文件或某个目录都必须位于宿主机目标位置下。也就是说我们必须

  1. 取出 tar 中路径中的非原始目录前缀部分,比如原始目录是 /home/ubuntu/bar,而我们只需要 /bar 这部分,而不需要前缀 /home/ubuntu
  2. 将步骤 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。如前文所述,我们在确定目标地址时会有两个步骤:

  1. 取出 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)
    }
  2. 将步骤 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,则此时 prefixsome/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 大概实现了如下逻辑:

  1. 当用户使用 kubectl cp 命令的时候,远程 Pod 的恶意的 tar 命令被触发,将产生如下的 tar 包:

    • ./baddir/twist 是一个符号链接,指向 /proc/self/cwd
    • ./baddir/twist/.bashrc 是一个普通文件;
  2. 当执行 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

  3. 创建 ./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 子命令,可见讨论

目测这个命令很有可能在未来被废弃,取而代之更安全的方式