CVE-2025-48384: Breaking Git with a carriage return and cloning RCE

Jul 8, 2025 - 19:30
 0  0
CVE-2025-48384: Breaking Git with a carriage return and cloning RCE

tl;dr: On Unix-like platforms, if you use git clone --recursive on an untrusted repo, it could achieve remote code execution. Update to a fixed version of git and other software that embeds Git (including GitHub Desktop).

If you've ever used an old mechanical typewriter, you know that when you get to the end of the line there's a physical action to to get back to the start of the line. Sometimes this was done through an actual lever on the typewriter, later models had a button. Because this action — the carriage return — was distinct from the line feed, it has its own character. In ASCII this is the character known as "Carriage Return", represented as "␍", character number 13. The "↵" icon, as often seen on the "Enter" or "Return" key on a modern keyboard encodes this action, along with the action of moving to the next line, known as "Line Feed" (␊).
An Olympia SM9 with a carriage return lever. Via classictypewriter.com.

This legacy from the very early days of communications (carriage return was introduced by the Murray code in 1901!) is still something we have to deal with. Unix attempted to simplify this by using just LF (a "\n" in C-string terms) to separate lines, but Windows and various Internet protocols use both CR+LF (a "\r\n" in C-string terms).

Why am I talking about this?

git has a simple .ini style configuration format, that looks like this:

[section]
         key = value

If this was just used in the user’s configuration files there wouldn’t be a concern about the formats that are accepted. However this configuration format is also used in the .gitmodules file, which is a file that is checked into the git repository that tracks submodules.

The format supports DOS line endings. It handles this in the fairly obvious way of stripping them when it reads lines, below is the function get_next_char in config.c:

static int get_next_char(struct config_source *cs)
{
        int c = cs->do_fgetc(cs);

        if (c == '\r') {
                /* DOS like systems */
                c = cs->do_fgetc(cs);
                if (c != '\n') {
                        if (c != EOF)
                                cs->do_ungetc(c, cs);
                        c = '\r';
                }
        }

The cs->do_fgetc call is nearly equivalent to the standard C function fgetc(), so even without knowledge of git's particular functions this is quite understandable.

It boils down to: If the character is a carriage return (\r), then look at the next character, if it is a line feed (\n), just return a line feed (and eat the carriage return). If however the next character isn’t a line feed, return a carriage return (\r) and put the next character back (ungetc), so it is returned next time.

The key thing to understand here is that this is done per line. So if for some reason a line happens to end with a CR, it will be stripped, regardless of the overall format of the file.

In addition to the code to read the configuration, git can also write configuration files, for example when the user uses git config to set a configuration value, it will use the code below to write key = value pairs out to the file.

static ssize_t write_pair(int fd, const char *key, const char *value, [...]
{
       [...]

       /*
         * Check to see if the value needs to be surrounded with a dq pair.
         * Note that problematic characters are always backslash-quoted; this
         * check is about not losing leading or trailing SP and strings that
         * follow beginning-of-comment characters (i.e. ';' and '#') by the
         * configuration parser.
         */
        if (value[0] == ' ')
                quote = "\"";
        for (i = 0; value[i]; i++)
                if (value[i] == ';' || value[i] == '#')
                        quote = "\"";
        if (i && value[i - 1] == ' ')
                quote = "\"";

        strbuf_addf(&sb, "\t%s = %s", key + store->baselen + 1, quote);

The actual bug is in the interaction of the above get_next_char function with this code. Elsewhere in the configuration reading code git supports quoted strings, using double quotes. However, when it writes a value back out to the configuration file, it will quote it only if it contains spaces in certain positions, or a ; or # anywhere. This means when it later reads the value write_pair wrote to the configuration file back, it can be tricked to drop a final \r off the value.

So, if we have a file with key = "foo^M", when it's written out again it becomes key = foo^M (where ^M is the literal CR character).

As I alluded to already, .gitmodules is untrusted, so if we can confuse the config parser, maybe that’s enough to confuse the code that handles submodules within git?

On Unix based systems (not Windows), it is possible to have control characters in filenames, so a path entry like the following in .gitmodules will attempt to check out the module into a directory named with a control character in its name.

[submodule "foo"]
  path = "foo^M"

However when this is written out by git’s configuration code to .git/modules/foo/config, it will look something like this:

[core]
  workdir = ../../../foo^M

The validation has already happened against the untrusted path that was read from .gitmodules. This means the path then essentially changes after validation because when it is read, the config reading code strips off the final \r.

The result of all this, is when a submodule clone is performed, it might read one location from path = ..., but write out a different path that doesn’t end with the ^M.

This simple primitive is enough to confuse git, such that when it checks out a submodule the contents of it will be written to a different path. This is very similar to CVE-2024-32002 where case-insensitivity (or lack of) in submodules could be used to confuse git. Ironically, that bug required a case-insensitive file system, this one requires a file system that allows control characters in filenames, therefore Windows is not directly vulnerable to this particular bug (and macOS is vulnerable to both CVE-2024-32002 and CVE-2025-48384).

When cloning on the command line, git clone on its own is not enough to clone submodules, so a manual mitigation for this is to use git clone without --recursive first, examine .gitmodules to check it is safe and then init the submodules.

However GitHub Desktop automatically clones with the recursive option by default, so if you use GitHub Desktop to clone, this can happen:

GitHub Desktop Demo
▶️

The patch for this is surprisingly simple, ensure that in write_pair, when writing a string with a carriage return in it, it is quoted. (Technically only the last character needs quoting, but it is safer to always quote.)

 	for (i = 0; value[i]; i++)
-		if (value[i] == ';' || value[i] == '#')
+		if (value[i] == ';' || value[i] == '#' || value[i] == '\r')
 			quote = "\"";

I'm not sharing a PoC yet, but it is an almost trivial modification of an exploit for CVE-2024-32002. There is also a test in the commit fixing it that should give large hints.

This is not the first time the carriage return has caused issues for Git, in January RyotaK found issues with the credential helper protocol that could also be tricked with carriage returns. It is also not the first time issues have been found with the configuration parsing, in 2023 there was a logic error found by André Baptista and Vítor Pinho.

I find this particularly interesting because this isn't fundamentally a problem of the software being written in C. These are logic errors that are possible in nearly all languages, the common factor being this is a vulnerability in the interprocess communication of the components (either between git and external processes, or within the components of git itself). It is possible to draw a parallel with CRLF injection as seen in HTTP (or even SMTP smuggling). For a long time the Internet worked on Postel's robustness principle:

Be conservative in what you do, be liberal in what you accept from others

However that may not be the most sensible advice now. This is covered in more detail than I can put here in RFC 9413.

This was found as part of an audit of Git and there are several other bugs I found of varying severity fixed in the releases today. Thanks to G-Research Open Source for enabling me to work on this.

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0