Blog post

Empowering weak primitives: file truncation to code execution with Git

Thomas Chauchefoin photo

Thomas Chauchefoin

Vulnerability Researcher

Date

  • Security

During recent security research, we came up with a fun "trick" that we later shared in a Capture the Flag challenge for the Hack.lu CTF and our Code Security Advent Calendar. We received good feedback and wanted to share the details with a broader audience. 


Let's say that you discovered a code vulnerability that allows you to truncate arbitrary files. It sounds like a pretty weak exploitation primitive, but if you are dealing with an application that involves operations on a Git repository under your control, you're in luck! 

The vulnerable snippet

For our example, let's use the code snippet of Day 16 of this year's Code Security Advent Calendar. It implements a service that allows cloning an arbitrary Git repository and later running git blame on specific files and lines.

challenge.py

def _git(cmd, args, cwd='/'):

   proc = run(['git', cmd, *args],

              stdout=PIPE,

              stderr=DEVNULL,

              cwd=cwd,

              timeout=5)

   return proc.stdout.decode().strip()

@app.route('/blame', methods=['POST'])

def blame():

   url = request.form.get('url',

                          'https://github.com/package-url/purl-spec.git')

   what = request.form.getlist('what[]')

   with TemporaryDirectory() as local:

       if not url.startswith(('https://', 'http://')):

           return make_response('Invalid url!', 403)

       _git('clone', ['--', url, local])

       res = []

       for i in what:

           file, lines = i.split(':')

           res.append(_git('blame', ['-L', lines, file], local))

       return make_response('\n'.join(res), 200)

This code suffers from an argument injection vulnerability when crafting the command line for git blame. Argument injections are widespread code vulnerabilities identified by our static analysis technology; you can find a scan report of the above snippet on SonarCloud:

Image of common arguments leading to unwanted behavior.

Exploiting argument injection vulnerabilities depends heavily on the features offered by the invoked binary. 


For instance, if a hypothetic program supports the option --output=foo that writes the program output to the file foo, attackers who can inject this argument could create new files or overwrite existing ones. The attacker's goal is usually to gain the ability to execute arbitrary code on the server, and such primitives are very powerful but also quite rare.

Finding an interesting argument

Let's get back to our code snippet, where we can add new arguments to the git blame invocation. 


After looking at the manual of git-blame, we couldn't find any "interesting" option to execute arbitrary code. Most arguments alter the behavior of the blame process or the way it renders its output. Most importantly, the manual does not document the presence of the option --output, which is usually present on other git sub-commands. 


It is then surprising to see this behavior when running git blame --output=foo; notice the presence of a new file named foo:

$ git blame --output=foo
usage: git blame [<options>] [<rev-opts>] [<rev>] [--] <file>


   <rev-opts> are documented in git-rev-list(1)


   --incremental         show blame entries as we find them, incrementally
[...]
$ ls -alh
total 0
drwx------    4 thomas  staff   128B Dec 29 14:43 ./
drwx------@ 191 thomas  staff   6.0K Dec 29 14:43 ../
drwxr-xr-x    9 thomas  staff   288B Dec 29 14:43 .git/
-rw-r--r--    1 thomas  staff     0B Dec 29 14:43 foo

Although the command failed, an empty file named foo was created. If a file with the same name already exists, the destination file is truncated!

$ date > foo
$ cat foo
Thu Dec 29 15:42:56 CET 2022
$ git blame --output=foo
usage: git blame [<options>] [<rev-opts>] [<rev>] [--] <file>


   <rev-opts> are documented in git-rev-list(1)


   --incremental         show blame entries as we find them, incrementally
[...]
$ ls -alh
total 0
drwx------    4 thomas  staff   128B Dec 29 14:43 ./
drwx------@ 191 thomas  staff   6.0K Dec 29 14:47 ../
drwxr-xr-x    9 thomas  staff   288B Dec 29 14:43 .git/
-rw-r--r--    1 thomas  staff     0B Dec 29 14:48 foo

This option provides attackers with an arbitrary file truncation primitive. The command git-blame supports --output because its implementation uses other sub-commands that do support --output: command-line arguments are parsed several times by these components.

Putting the pieces together

As we demonstrated in Securing Developer Tools: Git Integrations, control over the Git options of a local repository is dangerous: several configuration directives allow specifying external commands to change Git's behavior. For instance, core.fsmonitor can point to a third-party program to replace Git's built-in filesystem monitor. This process happens during most operations, including git blame


We could leverage this technique if we find a way to force Git operations to ignore the local repository and use one in our control instead. As you may have already guessed, the file truncation primitive was proven to be useful here. 


We can trick Git into loading a configuration from an unintended location by corrupting a critical file like .git/HEAD. In such cases, Git starts looking for repositories in the current folder, which the attacker fully controls as it is the work tree with all the files of the cloned remote repository.

Solving the challenge

To solve the challenge, we created a Git repository with the following structure:

  • objects/, refs/, worktree/: empty folders to comply with the expected structure of a Git repository
  • HEAD: non-empty file to fake a valid reference
  • config: malicious configuration based on what we described in Securing Developer Tools: Git Integrations and Justin Steven's advisory. Most importantly, it should contain:
    • bare = false: don't mark the current directory as bare 
    • worktree = worktree: the working tree directory under which checked-out are files
    • fsmonitor =  $(id>/pwned)#: the custom filesystem monitor daemon to start at the next Git invocation; this is the attacker's payload


When the repository is imported for the first time, nothing happens because the local Git repository stored in .git is constructed during the clone operation: this repository is valid and ignores the bare repository we planted. 


Then, the argument injection is triggered to truncate .git/HEAD, corrupting the once-valid local repository. By invoking git blame a second time, git now uses the malicious bare repository and calls the custom filesystem monitor, effectively executing the attacker's payload. 

Closing words

As we shared with our series of publications on vulnerabilities in the IT monitoring software Checkmk, seemingly minor vulnerabilities can hide a critical impact. Our Clean Code approach helps you identify these security liabilities before they are deployed to production. 


We hope you enjoyed this article and learned something about argument injection bugs; we sure had fun! 

Related Blog Posts