0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Actionsの式(${{ }})の挙動を理解したい

0
Posted at

はじめに

この記事は、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-scriptscript:、他アクションへ渡す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-scriptscript:(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は受ける側次第、というあたりも、手を動かして理解できました。

参考にさせていただいた記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?