GHSA-239g-whfq-7xj9: Code Injection

Published Sep 30, 2026
·
Updated

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

1 affected componentFixes available
pip/gitpython<=3.1.59
3.1.60

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/gitpython to a version that resolves this vulnerability.

    Fixed in 3.1.60
  2. Configuration

    Pass merge_includes=False in Repo._config_reader to prevent repository configuration includes from being processed.

    GitPython Repo._config_reader merge_includes = False
  3. 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.

  4. 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.

  5. Compensating control

    Strengthen is_git_dir validation to validate HEAD contents, requiring either ref: refs/... or a 40-hex SHA.

Event History

Sep 30, 2026
Advisory Published
via GitHub·11:27 PM
Data Sourced
via GitHub·11:27 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

4

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203