← Back to Attack Research

Breaking Git with a carriage return: arbitrary file write to RCE on recursive clone

A malicious repository and a single carriage return byte turn the everyday git clone --recursive into remote code execution. CVE-2025-48384 abuses an asymmetry in how Git reads and writes config values to drop a submodule outside its intended path, then a symlink into the hooks directory makes Git run attacker code during the same clone. Here is the mechanism, the weaponization, and what closes it.

Git is the piece of software almost every engineer runs against code they did not write. CVE-2025-48384 turns that everyday act into a code-execution primitive. A repository under attacker control ships a submodule whose configured path ends in a single carriage return byte. Because Git reads and writes that byte inconsistently, the submodule gets checked out to a location the attacker chose rather than the one Git thinks it validated. Point that location, through a symlink, into the repository's own hooks directory, and the submodule's contents become an executable post-checkout hook that Git runs before git clone --recursive has even finished. No credentials, no second stage, no user interaction beyond a clone. Git rated it CVSS 8.1; CISA added it to the Known Exploited Vulnerabilities catalog with a federal remediation deadline, and public reporting confirms it is being used in the wild.

This is a bug in Git itself, not in any one repository host or CI product, and it lands on the Linux and macOS clients that most engineering and build infrastructure runs on. The fixes shipped across every supported release line on the same coordinated date, 8 July 2025: v2.43.7, v2.44.4, v2.45.4, v2.46.4, v2.47.3, v2.48.2, v2.49.1, and v2.50.1. If you run anything older than the fix for your line, and you ever clone repositories you do not fully control, you are exposed. Below is the mechanism from the byte up, why the ordinary --recursive flag is the whole attack surface, and what actually closes it. We do not publish a working exploit. We explain the shape precisely enough that you can find it on your own fleet. Verifiable security.

Root cause: a read that strips, a write that keeps

Git stores configuration, including submodule definitions, in a small text format used by .git/config, the global ~/.gitconfig, and the .gitmodules file that travels inside a repository. Every value in that format goes through two separate code paths over its life: a parser that reads a value out of the file, and a serializer that writes a value back into it. The vulnerability is an asymmetry between those two paths in how they treat one specific byte, the carriage return, \r, hexadecimal 0x0D.

On the read side, Git's config parser is tolerant of Windows-style line endings. When it reaches the end of a value and finds a trailing carriage return before the newline, it strips that carriage return so that a value written on a CRLF system reads back cleanly on a LF system. That behavior is reasonable in isolation. On the write side, Git only wraps a value in quotes when it contains a character that would otherwise be ambiguous, and a trailing carriage return is not on that list. So a value that ends in \r is serialized literally, unquoted, with the raw carriage return sitting just before the line break.

Put the two halves together and you get a value that does not survive a round trip. Write path\r and it lands in the file as path\r; read that same line back and the parser strips the carriage return, handing you path. The stored representation and the interpreted representation disagree by exactly one invisible byte. In a text field that would be a cosmetic annoyance. In a field that Git uses to decide where on disk to write files, it is a security boundary that silently moves.

THE ROUND-TRIP THAT DOES NOT ROUND-TRIP attacker writes in .gitmodules : path = modules/x (raw 0x0D kept, unquoted) │ ▼ Git config PARSER reads it : path = modules/x (trailing 0x0D stripped) stored bytes ≠ interpreted value (differ by one 0x0D) one code path trusts the string; another acts on it

The asymmetry at the heart of CVE-2025-48384: the serializer keeps a trailing carriage return, the parser strips it. Illustrative, not from any specific repository.

This is why the classification is a carriage-return interpretation conflict layered on top of CWE-59, improper link resolution before file access. Two components look at the same bytes and reach different conclusions about what they mean, and the gap between those conclusions is where the file gets written to the wrong place. The Windows client is not affected the same way, which is why this is scoped to the Linux and macOS builds that dominate developer laptops and CI runners.

Weaponization: a submodule that lands where it should not

Submodules are the natural place to turn this desync into a file write, because a submodule is, by definition, a set of files that Git checks out to a path taken from configuration. The .gitmodules file at the root of a repository declares each submodule with a name, a URL to fetch it from, and a path that says where in the working tree its contents belong. During a recursive clone, Git reads that path, records the submodule, fetches its objects, and writes its files to the named location. Every one of those steps trusts the path field.

An attacker who controls a repository controls its .gitmodules file, and therefore controls that path field down to the byte. Give a submodule a path with a trailing carriage return and the asymmetry does the rest. One part of Git validates and records the path with the carriage return present; another part, reading it back through the stripping parser, acts on the path without it. The submodule's files are written to a location that differs from the one Git believes it checked, and that difference is attacker-chosen. In effect, the malicious repository has an arbitrary file write into the directory tree that the clone touches, gated only by what the attacker can express as a path plus one trailing byte.

By itself, that is not yet code execution. It is a primitive: attacker-controlled contents, at an attacker-influenced path, produced automatically by a clone. The exploit is in aiming that primitive somewhere that Git will not just store but execute.

From arbitrary file write to code execution

Git has a built-in mechanism for running code at defined moments in its lifecycle: hooks. A hook is simply an executable file in the repository's .git/hooks/ directory with a well-known name. The post-checkout hook, for example, is run automatically by Git immediately after a checkout completes, and a recursive clone performs a checkout. If an executable file named post-checkout exists in .git/hooks/ at that moment, Git runs it, with the privileges of the user doing the clone. Hooks are deliberately not copied from a remote for exactly this reason: a repository is not supposed to be able to plant one.

The file-write primitive removes that guarantee. The malicious repository also ships a symlink, a perfectly ordinary object for Git to check out, pointing from an innocuous-looking name in the working tree into .git/hooks/. The submodule path, with its trailing carriage return, is crafted so that when the stripping parser resolves it, the submodule's contents are written through that symlink and land inside .git/hooks/ as an executable named for a hook such as post-checkout. The submodule's payload is now a hook. The very same git clone --recursive that fetched it then completes its checkout, sees the hook, and executes it. There is no second command, no separate build step, no waiting for a developer to run make. Cloning the repository is the trigger.

Cloning the repository is the trigger. The attacker does not wait for you to build, test, or run the code. Git executes their hook before the clone command returns.

That collapse of the whole chain into one command is what makes CVE-2025-48384 severe rather than merely clever. The asymmetry provides the write, the symlink provides the aim, and Git's own hook execution provides the run, all inside one invocation that engineers and automation issue thousands of times a day.

Blast radius: everywhere --recursive meets untrusted code

The trigger is git clone --recursive (equivalently --recurse-submodules, or an ordinary clone followed by git submodule update --init) against a repository the attacker can influence. That is not an exotic condition. It is one of the most common operations in modern software delivery, and it runs in several places that matter:

Separate this from the class of GitHub Actions issues that leak secrets through workflow logs or the GITHUB_TOKEN. This is not a log-exposure or token-scope problem, and it does not depend on any one hosting platform's CI features. It is code execution in the Git client at clone time, so it applies equally to a self-hosted runner, a corporate build farm, an air-gapped mirror, and a laptop cloning from any remote. The common ingredient is not a platform. It is an out-of-date Git binary meeting a repository someone else can write to.

Detection: version, contents, and clone-time writes

You can assess and hunt this without ever running the exploit, because the exposure is determined by facts you can read directly.

  1. Inventory Git client versions across every place a clone happens. This is the highest-signal fact. Enumerate the git --version of developer workstations, CI runner images, base container images, build servers, and any service that shells out to Git. Compare each against the fixed release for its line. A pre-fix version that ever clones untrusted repositories is exposed, full stop.
  2. Scan repositories for hostile .gitmodules entries. Inspect .gitmodules files for submodule paths that contain raw carriage-return bytes (0x0D), that resolve into .git/ or a hooks directory, or that pair with a symlink pointing into .git/. A path field with an embedded control byte has no legitimate use and is a strong indicator on its own.
  3. Alert on hook files created during a clone. Any file appearing in .git/hooks/ as part of a clone or submodule checkout is anomalous, because hooks are never fetched from a remote by design. Watching for the creation of executable hook files during clone operations catches the payload at the exact moment it lands, independent of the specific path trick used to place it.

Be precise about scope, because precision is the point. Not every clone is dangerous: cloning your own repositories, or repositories from a fully trusted internal remote, does not expose you to this even on an unpatched client. The risk is specifically the intersection of a pre-fix Git version and a clone of code that someone outside your trust boundary can write to. The work is to find that intersection on your fleet rather than to assume it is everywhere or nowhere.

Remediation: patch the client, contain --recursive

Close CVE-2025-48384 across your fleet

The durable fix is the client upgrade, because it removes the asymmetry itself: the patched serializer no longer emits a bare trailing carriage return, so the round trip is faithful and the submodule can no longer be aimed away from its declared path. Everything else on the list is defense in depth for the window before every last Git binary in your estate is current, which, on real fleets with base images and vendored tooling, is always longer than the desktop rollout suggests.

How Celvex catches this

Find. Prove. Fix. Verify.

Find

We inventory the Git client version everywhere a clone runs, developer endpoints, CI runners, and base images, and cross-reference each against the fixed release for its line, surfacing every pre-fix binary that touches untrusted code.

Prove

A confirmed exposed client becomes an Ed25519-signed Proof Capsule carrying the host, the exact Git version, and the matching advisory and catalog entry, reproducible offline by you or your auditor.

Fix

The capsule's remediation block names the steps: upgrade to the fixed build, add the CI version gate, and contain recursive clones of untrusted repositories behind inspection.

Verify

A fresh sweep confirms the client is patched and the version gate holds. The finding closes and the verified-fix event is recorded for the audit trail.

The reason a one-byte bug in Git reaches so far is that Git sits at a trust boundary nobody labels as one. We think of a clone as reading, not running, so the scrutiny we apply to executing untrusted code is quietly skipped for the act of fetching it. CVE-2025-48384 is the reminder that on an unpatched client, fetching is running. The discipline is to treat every clone of code you do not own as the execution event it can be: patched client, gated version, inspected submodules. Answer that, and a carriage return stops being a foothold.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Which Git clients on your fleet still write the bad byte?

Free Exposure Check, no signup required. We inventory the Git version everywhere a clone runs, from developer laptops to CI base images, cross-reference each against the fixed releases, and ship a signed Proof Capsule for the highest-confidence finding.

Run a Free Scan →