GHSA-239g-whfq-7xj9: Code Injection
Summary
Repo.init decides which directory is the git directory by testing candidate paths in an order that considers the real .git last. Two earlier tests can be satisfied by ordinary tracked files. Git reserves only the literal name .git, so HEAD, objects/, refs/, config, gitdir, commondir and hooks/ at a repository root are all legal tracked content.
Consequently, after a victim opens or clones an attacker's repository, GitPython resolves gitdir to the working-tree root while real git correctly resolves <root>/.git. Everything GitPython then treats as "inside the git directory" is attacker-authored content — including hooks/, which it executes.
CVE-2026-87817
Affected code (3.1.59)
The discovery loop in git/repo/base.py tests, in order:
1. git/repo/base.py:299 — isfile(curpath/gitdir) and isfile(curpath/commondir) and isfile(curpath/HEAD) 2. git/repo/base.py:320 — isgitdir(curpath) 3. git/repo/base.py:341 — dotgit = osp.join(curpath, ".git") ← the real git dir, considered last
isgitdir (git/repo/fun.py:60) requires only that objects/ and refs/ are directories and that HEAD is a file; HEAD's contents are never parsed. The hook path is resolved from index.repo.gitdir (git/index/fun.py:73), i.e. the mis-resolved directory.
Proof of concept
Requires only pip install GitPython==3.1.59. Full script attached as poc1rce.py; it runs entirely in a temp directory and the payload only writes a marker file.
Attacker repository — four ordinary tracked files at the root:
| path | mode | content | |---|---|---| | gitdir | 100644 | .git\n | | commondir | 100644 | .git\n | | HEAD | 100644 | ref: refs/heads/master\n | | hooks/pre-commit | 100755 | #!/bin/sh + payload |
Victim — two ordinary calls:
python repo = git.Repo.clonefrom(url, dst) # or git.Repo(dst) repo.index.commit("automated commit") # code execution happens here
Observed on the PyPI release 3.1.59 (Linux and Windows):
real git says the git dir is : /tmp/.../victim/.git GitPython says it is : /tmp/.../victim <- shadowed attacker's hook executed : True git fsck : (clean)
The pre-commit hook runs at git/index/base.py:1201, before writetree(), so it fires even though the commit later fails.
Impact
A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool — can be made to:
1. Execute arbitrary commands via the tracked <root>/hooks/pre-commit when the victim calls index.commit(). 2. Read files outside the repository: the tracked <root>/config becomes the repository config and is parsed with mergeincludes=True (git/repo/base.py:765), so [include] path = ~/.aws/credentials discloses the file. (Repo.configreader still defaults mergeincludes=True, so the hardening added in 3.1.59 for .gitmodules/GHSA-7833 does not cover this path.) 3. Write a config file to an attacker-chosen directory via an absolute tracked commondir.
Delivery is silent: git clone exits 0, git fsck (including --strict, and with transfer.fsckObjects/fetch.fsckObjects=true) reports nothing, and the clone passes every read-only probe (head.commit, branches, isdirty(), untrackedfiles, itercommits) because a tracked commondir of .git pins commondir to the real .git.
Threat model / preconditions
- Attacker controls the content of a repository the victim opens or clones with GitPython (public repo, fork PR, mirrored dependency). - For code execution, the victim performs a commit via GitPython's native index.commit(). For the file-read impact, opening the repo and reading config is enough. - GITWORKTREE is not a mitigation — gitdir is assigned and the discovery loop breaks before the environment is consulted. - Real git is unaffected; only GitPython mis-resolves the directory.
Remediation
1. Test curpath/.git before the gitdir/commondir/HEAD triple and before isgitdir(curpath). 2. Resolve hookpath / commithookpath / getvalidatedreflogpath through repo.commondir. 3. Containment-check commondir/gitdir contents before joining (reuse SymbolicReference.getvalidatedpath). 4. Validate HEAD in isgitdir (require ref: refs/... or a 40-hex sha). 5. Pass mergeincludes=False in Repo.configreader (git/repo/base.py:765).
Note for the maintainers
Commit 406b98e1 (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via git rev-parse --git-path, which is immune because git rediscovers the true .git. Commit 9bc287a2 (2026-07-20) reverted it to avoid an unconditional rev-parse dependency — reintroducing the exec step. Measured at each commit with the healthy-looking layout: 406b98e1 → hook did not fire; 9bc287a2 … 3.1.59 → hook fired.
Scope note
Only the repository-root case is reported: where a real .git exists, git prefers it, and GitPython does not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a general ecosystem hazard rather than a GitPython defect.
###POC Files : poc1rce.py poc2fileread.py
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/gitpythonto a version that resolves this vulnerability.Fixed in 3.1.60 - Configuration
Pass merge_includes=False in Repo._config_reader to prevent repository configuration includes from being processed.
GitPython Repo._config_reader merge_includes = False - Compensating control
Resolve hook_path, _commit_hook_path, and _get_validated_reflog_path through repo.common_dir, and containment-check commondir/gitdir contents before joining paths.
- Compensating control
In the GitPython repository discovery loop, test curpath/.git before the gitdir/commondir/HEAD triple and before is_git_dir(curpath), so the real repository .git directory is preferred.
- Compensating control
Strengthen is_git_dir validation to validate HEAD contents, requiring either ref: refs/... or a 40-hex SHA.
Event History
Frequently Asked Questions
What must an attacker control for exploitation to be possible?
The attacker must provide a repository that the victim opens or clones with GitPython. The repository needs tracked content at its root that satisfies GitPython's earlier git-directory detection checks before GitPython examines the real .git directory.
Which repository-root files and directories are relevant when assessing exposure?
Check for root-level tracked HEAD together with objects/ and refs/, which can satisfy is_git_dir. A root-level gitdir and commondir file together with HEAD can also satisfy an earlier check; config and hooks/ are also legal tracked names mentioned in the advisory.
How can I determine whether GitPython is resolving the wrong directory?
For a suspicious repository, compare the directory GitPython assigns as git_dir with the directory Git itself uses. The affected behavior resolves git_dir to the working-tree root rather than <root>/.git when the earlier checks are satisfied.
Which version is identified as containing the affected discovery logic?
The provided affected-code details identify GitPython 3.1.59. The data does not specify a fixed version.