GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration
brew-btcli-cve-2026-78678
Vulnerability from osv_homebrew
Published
2026-09-04 08:47
Modified
2026-09-09 23:44
Summary
GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()
Details

Summary

Repo.blame() / Repo.blame_incremental() guard forwarded revision options against unsafe_git_revision_options, but that denylist only contains the file-WRITE options --output/-o. git blame also honors --contents <file> and -S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of --contents=<path> passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame --output WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).

Root Cause

unsafe_git_revision_options = ["--output","-o"] (git/repo/base.py:188). The rev string is passed to _option_candidates([rev], kwargs) and placed BEFORE the -- separator (base.py:841). The canonical name of --contents=... is contents, which is not on the denylist, so no UnsafeOptionError is raised. The trailing -- protects only the pathspec, not the option before the revision.

Impact

Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default allow_unsafe_options=False.

Proof of Concept

result = repo.blame("--contents=/etc/passwd", "a.txt")
# result rows carry the victim file's line text

Attack Chain

  1. Entry: app calls repo.blame(rev, file) with attacker rev="--contents=/etc/passwd" (or kwarg contents="/etc/passwd", or -S).
  2. Check: Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options) @ base.py:841. Guard: denylist = ["--output","-o"] only. Bypass proof: canonical name contents ∉ denylist → no error.
  3. Sink: self.git.blame(rev, "--", file, p=True, ...). argv (observed): ['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].
  4. Impact: blame result rows carry the victim file's line text.

Bypass Evidence

Independently reproduced (independent test harness, default allow_unsafe_options=False): blame('--contents=<secret>','a.txt') → guard PASSED; result rows = ['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']. Control: blame('--output=…') still BLOCKED (guard active on this path). -S kwarg argv also reaches git unguarded.

Affected Versions

GitPython <= 3.1.58 (denylist present verbatim on the latest release tag).

Suggested Fix

Prefer an allowlist of blame options; at minimum add --contents/-S (and any other path-taking blame options) to unsafe_git_revision_options, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".


Reported by zx (Jace) — GitHub: @manus-use


{
  "affected": [
    {
      "ecosystem_specific": {
        "fix": null,
        "range_state": "affected",
        "resource": "gitpython",
        "resource_purl": "pkg:pypi/gitpython@3.1.51",
        "upstream_fixed_in": "3.1.59"
      },
      "package": {
        "ecosystem": "Homebrew",
        "name": "btcli",
        "purl": "pkg:brew/btcli"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "database_specific": {
    "confidence": "high",
    "source": "matched",
    "strategy": "registry",
    "upstream_evidence": [
      {
        "ecosystem": "PyPI",
        "key": "pkg:pypi/gitpython@3.1.51",
        "name": "gitpython",
        "resource": "gitpython",
        "strategy": "registry",
        "subject_version": "3.1.51"
      }
    ]
  },
  "details": "## Summary\n`Repo.blame()` / `Repo.blame_incremental()` guard forwarded revision options against `unsafe_git_revision_options`, but that denylist only contains the file-WRITE options `--output`/`-o`. `git blame` also honors `--contents \u003cfile\u003e` and `-S \u003cfile\u003e`, which cause the file\u0027s lines to be echoed into the blame result \u2014 an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of `--contents=\u003cpath\u003e` passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame `--output` WRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).\n\n## Root Cause\n`unsafe_git_revision_options = [\"--output\",\"-o\"]` (`git/repo/base.py:188`). The `rev` string is passed to `_option_candidates([rev], kwargs)` and placed BEFORE the `--` separator (base.py:841). The canonical name of `--contents=...` is `contents`, which is not on the denylist, so no `UnsafeOptionError` is raised. The trailing `--` protects only the pathspec, not the option before the revision.\n\n## Impact\nArbitrary local file read at the privileges of the host process; the file\u0027s line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default `allow_unsafe_options=False`.\n\n## Proof of Concept\n```python\nresult = repo.blame(\"--contents=/etc/passwd\", \"a.txt\")\n# result rows carry the victim file\u0027s line text\n```\n\n## Attack Chain\n1. Entry: app calls `repo.blame(rev, file)` with attacker `rev=\"--contents=/etc/passwd\"` (or kwarg `contents=\"/etc/passwd\"`, or `-S`).\n2. Check: `Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)` @ base.py:841. Guard: denylist = `[\"--output\",\"-o\"]` only. Bypass proof: canonical name `contents` \u2209 denylist \u2192 no error.\n3. Sink: `self.git.blame(rev, \"--\", file, p=True, ...)`. argv (observed): `[\u0027git\u0027,\u0027blame\u0027,\u0027-p\u0027,\u0027--contents=\u003csecret\u003e\u0027,\u0027HEAD\u0027,\u0027--\u0027,\u0027a.txt\u0027]`.\n4. Impact: blame result rows carry the victim file\u0027s line text.\n\n## Bypass Evidence\nIndependently reproduced (independent test harness, default `allow_unsafe_options=False`): `blame(\u0027--contents=\u003csecret\u003e\u0027,\u0027a.txt\u0027)` \u2192 guard PASSED; result rows = `[\u0027GATE_SECRET_LINE_A\u0027,\u0027GATE_SECRET_LINE_B\u0027]`. Control: `blame(\u0027--output=\u2026\u0027)` still BLOCKED (guard active on this path). `-S` kwarg argv also reaches git unguarded.\n\n## Affected Versions\n`GitPython \u003c= 3.1.58` (denylist present verbatim on the latest release tag).\n\n## Suggested Fix\nPrefer an allowlist of blame options; at minimum add `--contents`/`-S` (and any other path-taking blame options) to `unsafe_git_revision_options`, and make the membership rule \"the option takes a filesystem path\" rather than \"the option writes output\".\n\n---\nReported by **zx (Jace)** \u2014 GitHub: @manus-use",
  "id": "BREW-btcli-CVE-2026-78678",
  "modified": "2026-09-09T23:44:39Z",
  "published": "2026-09-04T08:47:49Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-5xxx-qhh7-9287"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78678"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/gitpython-developers/GitPython"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3788.yaml"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/gitpython-before-arbitrary-file-read-via-repo-blame"
    }
  ],
  "schema_version": "1.7.3",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()",
  "upstream": [
    "PYSEC-2026-3788",
    "CVE-2026-78678",
    "GHSA-5xxx-qhh7-9287"
  ]
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…