FlawOpen

flawopen.com/GitHub Actions security/Workflow script injection

How does GitHub Actions script injection happen, and how do you prevent it?

High impact when privileged CWE-78 CI/CD workflow security
Plain English

A workflow can turn data into shell syntax by inserting an issue title or pull request field into a script before Bash starts. It is like a label printer that mistakes a name for instructions. Pass the value through an environment variable and quote it when using it as data; then Bash receives the value separately instead of parsing it as part of the script. The impact still depends on the job's token, secrets, runner, and trigger.

Untrusted contextGitHub event fields such as issue titles, pull request bodies, branch names, labels, and commit messages. Treat them as attacker-controlled strings.
Expression substitutionGitHub evaluates ${{ ... }} before the shell runs. In a run: block this can splice input into generated script source.
Environment handoffMapping a context value to env: passes it as process data. Quote the shell expansion and avoid eval or constructing another script.
Privilege boundaryThe event and workflow determine token permissions, available secrets, and runner access. Injection impact is bounded by what that job can reach.

Root cause: data is inserted before shell parsing

GitHub expands expressions before launching the shell for a run step. If an untrusted title is interpolated directly into the script, its characters can change the shell program rather than remain data. A value passed through env: is separate from the script text; quoting its expansion preserves it as one argument for ordinary commands. This does not make later use with eval, another interpreter, or an unsafe downstream tool safe. GitHub documents untrusted event fields as attacker-controlled input.

.github/workflows/triage.yml (Unsafe interpolation)Unsafe
# UNSAFE: event data becomes part of the generated shell script
name: Issue Triage
on:
  issues:
    types: [opened]

permissions:
  contents: read

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue title
        run: |
          printf 'Title: %s\n' "${{ github.event.issue.title }}"
.github/workflows/triage.yml (Data kept separate)Hardened
# Safer: pass the event value through the environment
name: Issue Triage
on:
  issues:
    types: [opened]

permissions:
  contents: read

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue title
        env:
          ISSUE_TITLE: ${{ github.event.issue.title }}
        run: |
          printf 'Title: %s\n' "$ISSUE_TITLE"

Exploit path at a glance

Step 1

A user controls an event field

For example, a public issue title, pull request title, or branch name becomes input to a workflow.

Step 2

The workflow splices it into shell source

A direct ${{ github.event... }} expression inside run: is expanded before Bash parses the script.

Step 3

The shell interprets the altered program

If the value changes shell syntax, commands can run with the job process identity. No injection occurs when the field stays data and is used as a quoted argument.

Step 4

The job’s permissions set the impact

A compromised job can access only resources exposed to that run, including its effective GITHUB_TOKEN, explicitly available secrets, and reachable runner resources. Issue-triggered workflows and pull_request_target require careful privilege review.

Hardening checklist

Primary sources

Explore related security topics and post-mortems:Complete Security Directory →
See also Cosign GHSA-fx35-mq7g-6g98: Bundle Verification Bypass Dependency Confusion: Risks and Defenses Docker Socket Exposure: Risks and Defenses