はじめに
この記事は、GitHub Actionsのスクリプトインジェクションの仕組みを理解し、自分のworkflowを守るための学習の記事です。検証は必ず自分が所有するリポジトリで行ってください。
GitHub Actionsで、PRが来たらタイトルを使って何かするworkflowをよく書きます。
たとえば、PRのタイトルをSlackに通知するようなやつです。
on: pull_request
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Slack に通知する
run: |
curl -s -X POST "$SLACK_WEBHOOK" \
-d "text=新しいPRです: ${{ github.event.pull_request.title }}"
一見ふつうですが、PRのタイトルはPRを送る人が自由に決められます。もしタイトルをx"; touch PWNED; echo "のようにインジェクションを含めたような形にすると、${{ }}の部分がそのまま埋め込まれて、ランナー上ではこういうコマンドになります。
curl -s -X POST "$SLACK_WEBHOOK" -d "text=新しいPRです: x"; touch PWNED; echo ""
touchを別のコマンドにすれば、PRを送るだけでランナー上で好きな処理を実行できるようになってしまいます。
これはスクリプトインジェクションと呼ばれる脆弱性で、「${{ }}をrun:に直接書かず、envから"$VAR"で受ける」ようにすることで対策できると認識していました。
しかし、${{ }}はrun:のほかに、github-scriptのscript:、他アクションへ渡すwith:、composite actionやreusable workflowのinputsにも書けます。どれが危なくて、envに逃がす対策は全部に効くのか、理解できていなかったので、実際に複数のパターンを試してみました。
${{ }}は実行前に文字列を埋め込んでいるだけ
公式ドキュメントにこう書いてあります。
Before the shell script is run, the expressions inside
${{ }}are evaluated and then substituted with the resulting values, which can make it vulnerable to shell command injection.
${{ }}は、シェルやJSがコードとして解釈するより前に、評価した結果の文字列をそのソースに埋め込みます。
実行時に値を読むシェルの$VARとは別物で、評価が「実行前」に起きるのがポイントです。だから埋め込まれた文字列の中の"や;が、その場所の言語のメタ文字として効いてしまう。
env経由が安全なのは逆で、値がソースに埋め込まれず、実行時にただの変数として読まれるからです。run:なら"$TITLE"、github-scriptならprocess.env.TITLE。値は最後までデータのままで、コードの一部になりません。
「その${{ }}の結果がどの言語のソースに埋め込まれるか」を追えば、危険かどうかが分かります。
試した構成
${{ github.event.pull_request.title }}を6か所に置いたworkflowを用意しました(トリガーはon: pull_request)。
攻撃側は同じリポジトリのブランチから、PRのタイトルにペイロードを入れてPRを出すだけです。
試した6パターンは下記です。
# C1 run: にそのまま展開 → シェルのコマンド文字列に埋め込まれる
# タイトル内の " や ; が bash のメタ文字として効き、後ろにコマンドを継ぎ足せる
run: echo "PR: ${{ github.event.pull_request.title }}"
# C2 github-script の script: にそのまま展開 → JS のソースに埋め込まれる
# script: の中身は JS として実行されるので、シェルではなく JS のブレイクアウトが効く
script: console.log("PR: ${{ github.event.pull_request.title }}")
# C3 with: で他アクションに値を渡すだけ → これ自体はコードに埋め込まれない
# 危険かどうかは渡した先の使い方しだい。受ける側(C5/C6)を見て判断する
with: { text: "${{ github.event.pull_request.title }}" }
# C4 env に入れたのに ${{ env.TITLE }} で参照し直す → またシェルに戻る
# ${{ env.X }} もテンプレート置換なので、結局 run: のソースに文字列が埋め込まれる
run: echo "PR: ${{ env.TITLE }}"
# C5 composite action の inputs を、内部の run: にそのまま展開(受ける側)
# 呼び出し側は with: で渡すだけ。埋め込まれて実行されるのは action の内部
run: echo "got ${{ inputs.text }}"
# C6 reusable workflow の inputs を、内部の run: にそのまま展開(受ける側)
# C5 と同じく、uses: の境界をまたいだ先の run: で埋め込まれる
run: echo "got ${{ inputs.text }}"
C3のwith:だけが値の受け渡しで、残りはどれも${{ }}の結果をrun:のシェルかscript:のJSに直接つないでいます。
攻撃が成立したかどうかは、PWNEDという空ファイルがワークスペースにできたかで判定します。各workflowの最後にこのステップを置きました。
- name: observe
if: always()
run: test -f PWNED && echo "C1_run_direct=executed" || echo "C1_run_direct=literal"
(補足)forkの場合は、注入が成立しても実害は限定的
forkからの通常のpull_requestではGITHUB_TOKENはread-onlyでSecretも渡らないので、この注入が成立しても実害は限定的です。怖いのはpull_request_targetやself-hosted runner、書き込み権限を持つトークン、Secretを参照するジョブのとき。PWNEDができた=即Secret流出、ではない点も先に書いておきます。
PRのタイトルは2種類用意しました。シェル実行を想定したものとJSでの実行を想定したもので形が違います。
# シェル用
x"; touch PWNED; echo "
# JS用
"); require("child_process").execSync("touch PWNED"); ("
6か所に同じ文字列を通した結果
同じ攻撃文字列を入れたPRを2本(シェル用・JS用)試して、仕込んだコマンドが実行されたらexecuted、実行されず、ただの文字列で終わればliteral、という印が各workflowから出ます。
結果は下記でした。
| # | 書ける場所(埋め込み先) | シェルPR | JS PR | なぜ |
|---|---|---|---|---|
| 1 |
run:直接(シェル) |
executed | literal | シェルに埋め込まれる |
| 2 |
github-scriptのscript:(JS) |
literal | executed | JSソースに埋め込まれる |
| 3 |
with:で他アクションへ渡す |
— | — | 単独では決まらない(#5/#6に解消) |
| 4 |
env:を${{ env.TITLE }}で再参照(シェル) |
executed | literal | envでも再展開で戻る |
| 5 | compositeのinputsを内部run:へ |
executed | literal | 受ける側が再展開 |
| 6 | reusableのinputsを内部run:へ |
executed | literal | 受ける側が再展開 |
実際に試したときのログ(各observeステップの出力をまとめたもの)
===== PR#1 attack-shell (shell payload) =====
C1_run_direct=executed C1_run_env=literal
C2_script=literal C2_script_env=literal
C4_env_quoted=literal C4_env_retemplate=executed
C5_composite_bad=executed C5_composite_safe=literal
C6_reusable_bad=executed C6_reusable_safe=literal
===== PR#2 attack-js (JS payload) =====
C1_run_direct=literal C1_run_env=literal
C2_script=executed C2_script_env=literal
C4_env_quoted=literal C4_env_retemplate=literal
C5_composite_bad=literal C5_composite_safe=literal
C6_reusable_bad=literal C6_reusable_safe=literal
github-script(#2)だけシェルPRとJS PRで結果が逆なのは、script:がJSとして実行されるからです。JS用ペイロードはJSとして成立してexecuted、シェル用はJSの文法として壊れてliteralになります(仕組みはasync-function.ts)。
ほかに気になったのは次の2つです。順に見ていきます。
- envに逃がしたのに戻ってくるところ(#4)
- compositeとreusableで境界を越えるところ(#5・#6)
envに入れても${{ env.X }}で参照すると戻ってくる
「envに逃がせば安全」だとこれまでは考えていましたが、効いているのはenvに入れたことではなく、参照の仕方の方でした。
env-quoted: # ✅ 安全
steps:
- env: { TITLE: "${{ github.event.pull_request.title }}" }
run: |
echo "PR: $TITLE" # シェルが実行時に変数を読む。データのまま
env-retemplate: # ❌ 危険
steps:
- env: { TITLE: "${{ github.event.pull_request.title }}" }
run: |
echo "PR: ${{ env.TITLE }}" # ${{ }} で再展開。またソースに戻る
"$TITLE"で参照すれば、シェルが実行時に値を読むだけなので安全ですが、
同じ値を${{ env.TITLE }}で参照すると、テンプレート置換の段階でまたrun:の中に文字列が埋め込まれ、シェルに戻ってきてコマンドが実行されます。
envに入れたという事実ではなく、値を${{ }}でソースに戻さないことが効いていた、と理解しています。
compositeとreusableは「受ける側」で決まる
値を別の場所へ渡した場合についても、受け取った側がその値をどう使うかで決まります。
同じ入力を、内部でrun:に展開するcompositeと、envで受けるcompositeに渡して比べました。
# ✅ 安全: env で受ける
runs:
using: composite
steps:
- shell: bash
env: { TEXT: "${{ inputs.text }}" }
run: echo "got $TEXT"
# ❌ 危険: inputs を run: に展開
runs:
using: composite
steps:
- shell: bash
run: echo "got ${{ inputs.text }}"
reusable workflow(workflow_call)でも構図は同じです。uses:で呼ぶ別ファイルのworkflowが、受け取ったinputsを内部でどう使うかで決まります。
# reusable-safe.yml (✅ 安全: env で受ける)
on:
workflow_call:
inputs: { text: { type: string } }
jobs:
use:
runs-on: ubuntu-latest
steps:
- env: { TEXT: "${{ inputs.text }}" }
run: echo "got $TEXT"
# reusable-bad.yml (❌ 危険: inputs を run: に展開)
on:
workflow_call:
inputs: { text: { type: string } }
jobs:
use:
runs-on: ubuntu-latest
steps:
- run: echo "got ${{ inputs.text }}"
こちらも内部がrun:に展開すればコマンドが実行され、envで受ければ文字列のままでした。
おわりに
${{ }}を6か所に同じ文字列で通して、危険度が注入点ではなく埋め込み先の言語・使い方で決まることを確かめました。
envも${{ env.X }}で参照し直すと戻ってくる、compositeとreusableは受ける側次第、というあたりも、手を動かして理解できました。