【GitHub Actions】スクリプトインジェクションの実践例
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- run: echo "PR title is ${{ github.event.pull_request.title }}"
- run: echo "PR Head Ref is ${{ github.head_ref }}"
要約
runの中で直接${{}}を使わないでください- 必要な値は環境変数を経由して渡してください
Pull Request のタイトルでスクリプトインジェクションしてみる
おや、こんなところに Pull Request のタイトルを出力するシンプルで完璧な GitHub Actions ワークフロー定義 YAML が落ちています。
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- run: echo "PR title is ${{ github.event.pull_request.title }}"
脆弱ですねェ〜〜〜〜〜〜〜〜!!
それではさっそく、「"; echo INJECTED"」というよくある名前で Pull Request を作成してみましょう。

GitHub Actions の実行ログを確認してみましょう。

GitHub Actions の実行ログ
INJECTED という文字列が出力されているのがわかりますね!!
任意コードとして echo INJECTED を実行することに成功しました!!🎉🎉
なぜなのか
今回の run のスクリプト↓は、
- run: echo "PR title is ${{ github.event.pull_request.title }}"
GitHub Actions ワークフローで実行される時に、以下のように展開されます。
- run: echo "PR title is "; echo INJECTED""
このスクリプトは以下のように解釈されます。
-
echo "PR title is "を実行 -
echo INJECTED""を実行 (""は空文字列なので、結果的にINJECTEDが出力される)
典型的な古き良き単純なインジェクション、という感じですね。とても美しいです。
Pull Request のブランチ名でスクリプトインジェクションしてみる
おや、こちらには Pull Request のブランチ名を出力するシンプルで完璧な GitHub Actions ワークフロー定義 YAML が落ちています。
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- run: echo "PR Head Ref is ${{ github.head_ref }}"
タイトルと違って、ブランチ名であればインジェクションされないとでも思ったのでしょうか?
脆弱ですねェ〜〜〜〜〜〜〜〜!!
それではさっそく、「main";echo${IFS}INJECTED"」というよくある名前のブランチで Pull Request を作成してみましょう。

GitHub Actions の実行ログを確認してみましょう。

GitHub Actions の実行ログ
INJECTED という文字列が出力されているのがわかりますね!!
こちらでも任意コード実行に成功しました!!🎉🎉
なぜなのか
仕組みとしてはタイトルのときと全く同じです。
今回の run のスクリプトは、
- run: echo "PR Head Ref is ${{ github.head_ref }}"
GitHub Actions ワークフローで実行される時に、以下のように展開されます。
- run: echo "PR Head Ref is main";echo${IFS}INJECTED""
このスクリプトは以下のように解釈されます。
-
echo "PR Head Ref is main"を実行 -
echo${IFS}INJECTED""(=echo INJECTED"") を実行
ブランチ名にはスペースを入れることができないので、ここでは代わりに ${IFS} を使っています。
こちらも単純ですね。芸術点が高いです。
どうすればいいの?
run の中で ${{}} を直接使わないでください。 いやほんとに。
${{}} の値を run の中で扱いたい場合、必ず環境変数を経由して参照し、クォーテーションで囲むようにしましょう。
値をシェルの環境変数として扱うことで、スクリプトインジェクションを防止することができます。
たとえば、先ほど紹介した PR タイトルを出力するワークフローの場合、以下のように書き換えることで安全になります。
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- - run: echo "PR title is ${{ github.event.pull_request.title }}"
+ - run: echo "PR title is ${PR_TITLE}"
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}

GitHub Actions の実行ログ
ちゃんと Pull Request のタイトルである "; echo INJECTED" がそのまま出力されており、任意コード (echo INJECTED) は実行されていないことがわかります。
ちなみに GitHub Actions ではデフォルトで用意されている環境変数も多いです。
たとえば今回ブランチの例で紹介した github.head_ref も GITHUB_HEAD_REF という環境変数が用意されてるので、こちらをそのまま使うことができます。自分で環境変数を定義する必要もありません。
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- - run: echo "PR Head Ref is ${{ github.head_ref }}"
+ - run: echo "PR Head Ref is ${GITHUB_HEAD_REF}"
PR タイトルやブランチ名以外の、ユーザーが直接コントロールすることが難しい値 (たとえば github.repository とか) であれば run の中で ${{}} を直接使っても実際にインジェクションされる可能性は低いですが、
とはいえ「これなら ${{}} 直接使っても大丈夫 / 大丈夫じゃない」というのをいちいち毎回正確に判断するのは非常に重労働なので、「とにかく run の中では ${{}} を直接使わない」「必要な値は環境変数を経由して渡す」というのを徹底した方がよっぽど楽ですし、安全です。
プライベートリポジトリなら大丈夫?
「パブリックリポジトリなら外部から Pull Request などを作成できるから危険だけど、プライベートリポジトリなら大丈夫だね!!」
全く大丈夫じゃないです。
例えば以下のケースが想定されます。
- プライベートリポジトリにインストールした GitHub App があるとする
- この GitHub App には「Contents: Write + Pull Requests: Write」権限がある
- 攻撃者によってこの GitHub App が侵害され、乗っ取られる
- 攻撃者が GitHub App を使って、プライベートリポジトリに対して Pull Request を作成する
- スクリプトインジェクション成功!
通常、GitHub App は「Contents: Write + Pull Requests: Write」権限だけでは GitHub Actions ワークフロー定義を書き換えることはできない = 任意コード実行はできません。
( GitHub Actions ワークフローの定義の書き換えには「Workflows: Write」権限が必要 )
しかし、スクリプトインジェクションを利用することで、一部の権限だけで GitHub Actions ワークフロー上で任意コード実行が可能になります。
他にも、プライベートリポジトリでも悪意のある外部ユーザーからスクリプトインジェクションを実行されるケースはいくらでも想定できます。「GitHub App を入れてなければ大丈夫」みたいな話ではないです。上記はあくまで一例に過ぎません。
「プライベートリポジトリだからスクリプトインジェクション対策はしなくてもいい♪」というのは「プライベートリポジトリだから AWS アクセスキーをコミットしても大丈夫♪」と同じような理論です。
黙ってスクリプトインジェクション対策してください。やらない理由がないです。
たまにある勘違い
「環境変数ってことは ${{ env.* }} を使えばいい...ってコト!?」
on:
pull_request:
jobs:
example:
runs-on: ubuntu-latest
steps:
- run: echo "PR title is ${{ env.PR_TITLE }}"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
ダメです。全く同じ問題が発生します。
結局 ${{ env.* }} も実行時に展開されるので、インジェクションされます。

GitHub Actions の実行ログ
先ほど紹介した例の $HOGE のように、シェルの環境変数を使ってください。
自動検出するために
ghasec や actionlint や zizmor などのツールを使ってください。今回紹介した問題のあるワークフローは、全てこれらのツールで検出できます。
--> ./example.yml:8:32
...
4 | jobs:
5 | example:
...
7 | steps:
8 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ "run" must not contain expressions; use environment variables instead (script-injection)
Ref: https://github.com/koki-develop/ghasec/blob/main/rules/script-injection/README.md
.github/workflows/title.yml:8:36: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
|
8 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
error[template-injection]: code injection via template expansion
--> ./.github/workflows/example.yml:8:36
|
8 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| --- this run block ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
= note: this finding has an auto-fix
まとめ
スクリプトインジェクションが容易に実行可能になっているリポジトリ、本当に多いです。結構有名なリポジトリとかでも普通に見かけます。
今回は単純に on: pull_request におけるタイトルやブランチ名を使った例だけを紹介しましたが、他にもスクリプトインジェクションに使える値やトリガーはいくらでもあります。例えばこの記事内の「Pull Request」の部分は全て「Issue」「ラベル」などに置き換えても同じような問題が発生します。
「あ、これ Issue を作成するだけでワークフロー内で使用しているトークンや API キー全部ぶっこ抜けますね!僕は良識のある大人なのでやりませんが!たまたま僕に良識があってよかった〜!」みたいなワークフロー、普通にその辺のパブリックリポジトリに転がってます。
本当に気をつけてください。
スクリプトインジェクションについて知らなかった人は「GitHub CI/CD実践ガイド」の「第15章 GitHub Actions のセキュリティ」を穴が開くまで読んでください。他の章も読んでください。めちゃくちゃ良書です。
参考
Discussion