{"id":"GHSA-f9j4-qggq-h239","kind":"vulnerability","aliases":["GHSA-f9j4-qggq-h239"],"related":[],"title":"GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution","description":"## Summary\n\n`Remote.pull()` in GitPython forwards its positional `refspec` argument directly to the `git pull` child process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as the `refspec` (for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as `--upload-pack=<command>`. The value bypasses the `unsafe_git_pull_options` check, is placed after the `--` separator on the `git pull` command line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (`allow_unsafe_options=False`).\n\n## Root Cause\n\n`Remote.pull()` is documented to reject dangerous options unless `allow_unsafe_options=True`, and `--upload-pack` is explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positional `refspec`. The check builds its candidate list with `Git._option_candidates([], kwargs)`, passing an empty list in place of the positionals, so the `refspec` value is never compared against `unsafe_git_pull_options = [\"--upload-pack\"]`. The normalized `refspec` is then forwarded verbatim to the `git pull` invocation. The leading `--` separator that neutralizes an option-shaped refspec for `git fetch` and `git push` does not protect `git pull`, because `git pull` re-forwards its trailing positionals to an internal `git fetch` without re-inserting a `--` separator, so the positional becomes an option to that internal fetch.\n\n```text\nAffected function: git.remote.Remote.pull\nSource: git/remote.py (guard at the check_unsafe_options call using _option_candidates([], kwargs); sink at the self.repo.git.pull(\"--\", self, refspec, ...) invocation)\n```\n\n## Impact\n\nAn attacker who controls the `refspec` value passed to `Remote.pull()` achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the `--upload-pack` value is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.\n\n## Proof of Concept\n\n```text\nEnvironment: GitPython from the affected source tree; system git 2.43.0.\nThe pulled origin uses a local filesystem transport (protocol.file.allow=always for git >= 2.38).\n```\n\nReproduction steps:\n\n1. Create an origin repository reachable over a local-path transport.\n2. In a consumer repository, register that origin and call `Remote.pull()` with an attacker-controlled refspec.\n3. Observe that the injected command executes.\n\n```python\nimport git\nrepo = git.Repo(\"consumer\")                 # has an origin using a local-path or ssh transport\norigin = repo.remote(\"origin\")\n# Attacker-controlled refspec value; default arguments (allow_unsafe_options=False):\norigin.pull(refspec=\"--upload-pack=touch /tmp/pwned; git-upload-pack\")\n```\n\n```text\ncmdline: git pull -v -- origin --upload-pack=touch /tmp/pwned; git-upload-pack\n[attack]  marker created (RCE)      : True\n[isolate] upload_pack passed as KWARG blocked : True   (only the positional refspec escapes)\n[sibling] same value via git fetch  : safe            (pull-specific behavior)\n[control] benign refspec            : safe\n```\n\n## Attack Chain\n\n1. **Exposure:** An application calls `Remote.pull(refspec=<value>)` on the default path, where `<value>` is derived from attacker-influenced input such as a branch or refspec request parameter.\n2. **Control:** The attacker supplies the string `--upload-pack=<command>; git-upload-pack`. The value passes through `Git._unpack_args`, which only flattens and stringifies it, preserving the option shape.\n3. **Path:** The protocol loop calls `Git.check_unsafe_protocols` on the value, which only matches remote-helper transport URLs (a `<transport>::` form) and does not flag an `--upload-pack=` token.\n4. **Guard:** The unsafe-option guard calls `check_unsafe_options(options=Git._option_candidates([], kwargs), ...)`. Because positionals are passed as an empty list, the positional `refspec` is never inspected against `unsafe_git_pull_options`.\n5. **Primitive:** The value is forwarded to `git pull -v -- <remote> --upload-pack=<command>; git-upload-pack`; `git pull` re-forwards the positional to an internal `git fetch` without a `--` separator, so it is honored as the upload-pack program.\n6. **Result:** For a local-path or SSH transport, git executes `<command>` as the upload-pack program, giving arbitrary command execution as the calling process user.\n\n## Bypass Evidence\n\nThe relevant guard is `Remote.pull()`'s own `check_unsafe_options` call, present in the current released artifact. It is incomplete: it inspects `Git._option_candidates([], kwargs)`, which only considers keyword arguments, so the positional `refspec` is never checked. This was confirmed empirically: passing the identical value as the `upload_pack=` keyword argument raises `UnsafeOptionError`, while passing it as the positional `refspec` does not, proving that only the positional escapes the guard.\n\nThe `--` separator on the `git pull` command line was tested as a possible mitigation and refuted: with system git 2.43.0, `git pull -- <remote> '--upload-pack=<cmd>'` executed the injected command, while the identical value supplied to `git fetch -- ...` and `git push -- ...` did not execute, because those subcommands honor the `--` terminator whereas `git pull` re-forwards positionals to an internal fetch without one. The `check_unsafe_protocols` transport check was also examined and does not flag the `--upload-pack=` token because it has no `<transport>::` form. No prior published advisory for this package addresses `Remote.pull`, the fetch or pull refspec positional, or `--upload-pack` injection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.\n\n## Affected Versions\n\n- `Ecosystem: pip (PyPI)`\n- `Package: GitPython`\n- `Confirmed affected range: >= 3.1.30`\n- `Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tag`\n- `Fix status: not fixed in any released version or on the development branch`\n\nThe keyword-only pull guard together with the positional `refspec` sink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shipped `git/remote.py` in each tagged artifact. Version 3.1.30 introduced the `unsafe_git_pull_options` guard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward the `refspec` positionally with no unsafe-option guard and no `--` separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.\n\n## Suggested Fix\n\nEnforce the unsafe-option invariant on the positional `refspec`, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list into `Git._option_candidates` alongside `kwargs`) so that an option-shaped `refspec` such as `--upload-pack=...` is rejected with `UnsafeOptionError` unless `allow_unsafe_options=True`. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with `-`, avoid passing untrusted data as the `refspec`, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.\n\nReported by zx (GitHub: @manus-pi).","summary":"GitPython's Remote.pull() checks only keyword arguments for dangerous options, so an option-shaped positional refspec such as --upload-pack=<command> reaches git pull unchecked. An application that passes attacker-influenced data as refspec can get arbitrary command execution as the calling process over local-path or SSH remotes.","severity":"high","cvss":8.1,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"GitPython","fixed":["3.2.0"],"affected":">= 3.1.30, < 3.2.0","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["ci","cwe-78","cwe-88","gitpython","package","python","web-services"],"not_affected_if":["fixed-refspec","https-remotes-only","no-untrusted-input"],"how_to_check":"- Find the GitPython version with `pip show GitPython` and check whether it is at least `3.1.30` and below `3.2.0`\n- Search the codebase for `.pull(` calls on remotes where the `refspec` argument comes from request parameters, user input or CI variables\n- Check whether those remotes use local-path or SSH origins (`git remote -v`)","fix":"- Upgrade GitPython to a release that enforces the guard on positional refspec; the sources say no fixed release exists yet, so this is unknown for now\n- Until fixed, reject or validate refspec values that begin with `-` before calling Remote.pull()\n- Avoid passing untrusted data as refspec; prefer HTTPS remotes where the client-supplied upload-pack program is inert","status":"open","published_at":"2026-10-27T15:26:50+00:00","first_seen_at":"2026-10-27T15:28:57+00:00","triaged_at":"2026-10-27T15:29:25+00:00","updated_at":"2026-10-27T15:29:25+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-27T15:29:25Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade GitPython to a release that enforces the guard on positional refspec; the sources say no fixed release exists yet, so this is unknown for now\n- Until fixed, reject or validate refspec values that begin with `-` before calling Remote.pull()\n- Avoid passing untrusted data as refspec; prefer HTTPS remotes where the client-supplied upload-pack program is inert","reach":"internet","title":"GitPython: pull refspec argument skips unsafe-option guard, allowing command execution","impact":"high","summary":"GitPython's Remote.pull() checks only keyword arguments for dangerous options, so an option-shaped positional refspec such as --upload-pack=<command> reaches git pull unchecked. An application that passes attacker-influenced data as refspec can get arbitrary command execution as the calling process over local-path or SSH remotes.","category":"package","nicknames":[],"who_cares":["python","gitpython","web-services","ci"],"confidence":0.7,"needs_auth":"unknown","worst_case":"Attacker runs arbitrary OS commands as the calling process user.","analyst_note":"Confirm whether any fixed release exists and what the patched GitPython version is; the source says none, and the advisory's fix status should be rechecked at https://github.com/advisories/GHSA-f9j4-qggq-h239.","how_to_check":"- Find the GitPython version with `pip show GitPython` and check whether it is at least `3.1.30` and below `3.2.0`\n- Search the codebase for `.pull(` calls on remotes where the `refspec` argument comes from request parameters, user input or CI variables\n- Check whether those remotes use local-path or SSH origins (`git remote -v`)","needs_analyst":true,"not_affected_if":["https-remotes-only","fixed-refspec","no-untrusted-input"],"who_is_affected":"Applications that call Remote.pull() with a refspec derived from untrusted input, such as a web or CI service that accepts a branch or refspec parameter. Exposure also depends on the pulled remote using a local filesystem path or SSH transport, since over HTTPS the injected upload-pack value is ignored. Programs that only pull from fixed, internally controlled refspecs are not exposed by this flaw.","user_interaction":"no","needs_special_setup":"no","severity_if_unknown":"unknown"},"cost_usd":0.001017,"input_tokens":6134,"output_tokens":808,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-78':30B '-88':32B '/tmp/pwned':593C,608C '1':520C,648C '2':532C,675C '2.38':517C '2.43.0':504C '3':549C 'achiev':381C 'add':464C 'addit':395C 'affect':347C,499C 'allow':209C,223C,583C 'alway':514C 'applic':66B,118C,651C 'arbitrari':14A,77B,194C,382C 'argument':45B,99C,116C,208C,244C,582C 'attack':70B,122C,372C,468C,546C,577C,613C,646C,664C,678C 'attacker-control':545C,576C 'attacker-influenc':69B,121C,663C 'becom':339C 'behavior':641C 'benign':643C 'block':624C 'branch':134C,670C 'build':252C 'bypass':4A,158C 'call':82B,202C,358C,541C,652C 'candid':254C,361C 'caus':214C 'chain':647C 'check':42B,164C,251C,355C 'checkout':476C 'child':105C 'ci':28B,142C 'client':446C 'client-suppli':445C 'cmdline':599C 'code':404C 'command':15A,78B,174C,195C,386C,554C 'common':477C 'compar':274C 'concept':493C 'consum':535C,561C 'control':374C,547C,578C,642C,676C 'creat':521C,615C 'cwe':29B,31B 'danger':47B,220C 'data':72B,124C 'default':207C,403C,581C,657C 'deploy':460C 'deriv':661C 'direct':100C 'document':206C,217C 'empti':261C 'enabl':13A 'environ':495C 'escap':630C 'exampl':129C 'execut':16A,79B,178C,196C,387C,555C 'explicit':232C 'exploit':406C 'expos':487C 'exposur':649C 'f9j4':19A,24A 'fals':212C,586C 'fetch':307C,329C,345C,636C 'filesystem':427C,511C 'forward':95C,288C,321C 'function':348C 'get':76B 'ghsa':18A,23A 'ghsa-f9j4-qggq-h239':17A,22A 'git':62B,103C,161C,172C,277C,292C,306C,309C,314C,317C,328C,503C,516C,558C,595C,600C,610C,635C 'git-upload-pack':594C,609C 'git._option_candidates':257C 'git.remote.remote.pull':349C 'git.repo':560C 'git/remote.py':351C 'gitpython':1A,27A,33B,39B,94C,392C,496C 'guard':12A,112C,239C,352C 'h239':21A,26A 'howev':240C 'https':432C 'ignor':443C 'impact':370C 'import':557C 'inert':439C 'influenc':71B,123C,665C 'inject':553C 'input':666C 'insert':333C 'inspect':113C 'interact':400C 'intern':327C,344C 'invoc':294C,369C 'invok':391C 'isol':618C 'keyword':44B,115C,243C 'kwarg':258C,362C,623C 'lead':296C 'line':175C 'list':233C,255C,262C 'local':86B,187C,422C,426C,480C,510C,529C,568C 'local-path':85B,186C,479C,528C,567C 'marker':614C 'name':424C 'narrow':455C 'neutral':299C 'never':246C,273C 'normal':284C 'observ':550C 'oper':384C 'operating-system':383C 'option':11A,48B,52B,111C,148C,163C,211C,221C,225C,237C,279C,302C,341C,357C,360C,585C 'option-shap':51B,147C,301C 'origin':484C,507C,523C,539C,564C,573C,575C,603C 'origin.pull':587C 'pack':8A,60B,155C,183C,230C,282C,419C,436C,450C,591C,597C,606C,612C,620C 'packag':34B 'paramet':137C,674C 'pass':68B,120C,259C,378C,621C 'path':87B,188C,405C,428C,481C,530C,569C,658C 'place':166C,264C 'popul':457C,488C 'posit':54B,97C,248C,267C,324C,338C,628C 'privileg':199C,396C 'process':83B,106C,203C,390C 'program':184C,451C 'proof':491C 'protect':313C 'protocol.file.allow':513C 'pull':63B,104C,162C,173C,278C,293C,315C,318C,410C,506C,601C,639C 'pull-specif':638C 'push':310C 'python':35B,556C 'qggq':20A,25A 'rce':616C 're':320C,332C 're-forward':319C 're-insert':331C 'reach':61B,474C 'reachabl':525C 'refspec':3A,55B,74B,98C,127C,136C,249C,270C,285C,304C,368C,376C,548C,579C,588C,629C,644C,654C,672C 'regist':537C 'reject':219C 'remot':90B,411C 'remote.pull':2A,41B,92C,215C,380C,542C,653C 'repo':559C 'repo.remote':574C 'repositori':524C,536C 'reproduct':518C 'request':673C 'requir':407C,454C 'root':213C 'run':416C 'safe':637C,645C 'screen':242C 'self':367C 'self.repo.git.pull':366C 'separ':169C,297C,335C 'server':442C 'servic':38B,143C 'shape':53B,149C,303C 'sibl':631C 'side':423C 'sink':363C 'sourc':350C,500C 'specif':640C 'ssh':89B,190C,430C,483C,571C 'step':519C 'string':681C 'substanti':490C 'summari':91C 'suppli':133C,145C,447C,679C 'system':385C,502C 'target':472C 'text':346C,494C,598C 'touch':592C,607C 'trail':323C 'transport':191C,414C,453C,512C,531C,572C 'tree':501C 'true':226C,617C,625C 'uncheck':64B 'unless':222C 'unsaf':10A,110C,160C,210C,224C,276C,356C,584C 'unsafe-opt':9A,109C 'upload':7A,59B,154C,182C,229C,281C,418C,435C,449C,590C,596C,605C,611C,619C 'upload-pack':6A,58B,153C,181C,228C,280C,417C,434C,448C,589C,604C 'use':204C,359C,412C,478C,508C,565C 'user':132C,399C 'user-suppli':131C 'v':602C 'valu':150C,157C,271C,377C,437C,580C,633C 'verbatim':289C 'via':634C 'vulner':459C,471C 'web':37B,140C 'web-servic':36B 'without':330C 'work':465C,475C 'yield':193C","nicknames":[],"mitigations":[],"who_affected":"Applications that call Remote.pull() with a refspec derived from untrusted input, such as a web or CI service that accepts a branch or refspec parameter. Exposure also depends on the pulled remote using a local filesystem path or SSH transport, since over HTTPS the injected upload-pack value is ignored. Programs that only pull from fixed, internally controlled refspecs are not exposed by this flaw.","tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-27T15:26:50+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-f9j4-qggq-h239","url":"https://github.com/advisories/GHSA-f9j4-qggq-h239","severity":"high","published_at":"2026-10-27T15:26:50+00:00","modified_at":"2026-10-27T15:28:29+00:00","first_seen_at":"2026-10-27T15:28:57+00:00","fetched_at":"2026-10-27T15:28:57+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"}