(You can read this article in English here.)

免責事項

GitHubはBug Bountyプログラムを実施しており、その一環として脆弱性の診断行為をセーフハーバーにより許可しています。
本記事は、そのセーフハーバーの基準を遵守した上で調査を行い、その結果発見した脆弱性に関して解説したものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
GitHub上で脆弱性を発見した場合は、GitHub Bug Bountyへ報告してください。

要約

GitHub Actionsのランナーのソースコードをホストするactions/runnerリポジトリにおいて、セルフホストランナーの使用方法に不備があり、結果としてGitHub Actionsに登録されているPersonal Access Tokenの窃取が可能だった。
このトークンはGitHubスタッフのアカウントに紐づいていたため、当該のスタッフとして各種操作を行うことができた。
これにより、潜在的にactions/checkoutactions/cacheといったようなリポジトリに対して悪意あるコードを挿入できる可能性が存在した。

セルフホストランナーとは

セルフホストランナーとは、名前の通りGitHub Actionsのランナーをユーザーが所有するサーバー等で実行することができる機能であり、主にハードウェア的な要件が存在するCI等で使用されているケースが多い。
この機能はGitHub Actionsのランナーをユーザーの環境にインストールすることで実現されているのだが、このランナーは実行毎に環境の隔離を行わないため、別途ユーザーが環境の隔離などを行わない限り、ジョブ間でシステムの状態が共有されるという仕様となっている。これは、信頼されているワークフローのみが実行される状況であればセキュリティ上の問題とはならない。

pull_requestトリガー

しかしながら、GitHub Actionsにはpull_requestと呼ばれるワークフローのトリガーが存在する。
これは、プルリクエストに関連したイベントが発生した際にGitHub Actions上でコードを実行するトリガーなのだが、このトリガーは実行するワークフローの定義ファイルをフォークしたリポジトリから読み取り1、ベースリポジトリのコンテキストで、コンテンツに対する読み取り権限のみが付与されたトークンを渡された状態で実行される。
つまり、公開リポジトリにおいては、フォークしたリポジトリから任意のワークフローを実行できるということになる。2

pull_requestトリガーの挙動を示す図

GitHubがホストするランナーを使用している際はワークフロー実行毎に環境が初期化されるため何ら問題ない挙動なのだが、セルフホストランナーを利用しているケースにおいては実行毎に環境が初期化されないため、プルリクエスト内のワークフロー定義ファイルで、セルフホストランナーを実行環境として指定することで任意のコードを実行し、環境を汚染することができる。
これにより、後にそのセルフホストランナーが機密情報(例: 書き込み権限を持ったGitHubのトークン等)を受け取ったタイミングでその情報を窃取することが可能となる。

この挙動に関してはGitHubのドキュメントにも明示的に記載されており、セルフホストランナーはパブリックリポジトリで使用するべきではないとされている。

脆弱性が存在したワークフロー

さて、ここまでの前提を踏まえた上で、実際に脆弱性が存在したワークフローの内容を見ていく。
actions/runnerには、e2etest.ymlと呼ばれるE2Eテスト用のワークフローが存在した。3

このワークフローは、要約すると以下のような処理を行う:

  1. 現在リポジトリに登録されているセルフホストランナーの一覧を取得し、それらを全て削除する。
  2. ランナーを異なるアーキテクチャ及びOS(Linux/Windows/macOS)向けにそれぞれビルドする。
  3. 以下の処理を行うスクリプトを非同期に実行する。
     1. 現在リポジトリに登録されているセルフホストランナーの一覧を取得する。
     2. 取得した一覧から、E2Eテストに使用されるべきランナーを探し、ランナー上でOSに対応するテストを実行する。
     3. テスト実行対象のランナーが全て見つかったら、処理を終了する。そうでなければ処理を続行。
     4. APIレートリミットを避けるため、10秒間スリープする。
     5. 手順3-1に戻る。
  4. セルフホストランナーを起動するため、それぞれのアーキテクチャ及びOSの組み合わせに対して、以下のような処理を実行する。
     1. 手順2でビルドした実行可能ファイルをダウンロードする。
     2. セルフホストランナーを設定し、actions/runnerリポジトリへ登録する。
     3. 手順3で実行されたスクリプトからジョブが実行されるのを待つ。
     4. 受信したジョブを実行する。
     5. actions/runnerリポジトリからセルフホストランナーを削除。
     6. テスト結果をアップロードする。
  5. テストが全て終了したら、アップロードされたテスト結果を解析する。

一見すると、このワークフローはセルフホストランナーを使用した後、再利用していないため前述の攻撃は不可能であるように思える。
しかしながら、これらの手順には不備があり、ここで使用されているセルフホストランナー上で任意のコードを実行することが可能だった。

このワークフローの問題点

前述した手順において、手順3でスクリプトが非同期に実行されるのを覚えているだろうか。
このスクリプトはAPIのレートリミットを避けるため、登録されたセルフホストランナーの一覧を取得する度に10秒間スリープするという仕様となっていた。
つまり、手順4-2でセルフホストランナーが登録されてから、手順3-2でランナーに対してジョブが送信されるまでに、最大で10秒強の遅延がある。

セルフホストランナーとはセクションで解説した通り、セルフホストランナーはプルリクエストから任意のジョブを実行することができるため、この10秒間の遅延の間に悪意あるプルリクエストを送信することで、セルフホストランナー上で任意のコードを実行し、後続の処理を汚染されたランナー上で実行することが可能だった。
さて、ここで後続の処理の内容を見ると、手順4-5でactions/runnerリポジトリからセルフホストランナーを削除している。
通常、GitHub Actionsでランナーに渡されるGitHub Tokenはセルフホストランナーを操作する権限を持たないため、以下のようにGitHubのPersonal Access Tokenを利用してセルフホストランナーの登録/削除を行っていた。

.github/workflows/e2etest.yml 165行目~178行目

      - name: Configure Runner
        env:
          unique_runner_name: linux-x64-${{needs.init.outputs.unique_runner_label}}
        run: |
          ./config.sh --url ${{github.event.repository.html_url}} --unattended --name $unique_runner_name --pat ${{secrets.PAT}} --labels $unique_runner_name --replace
      - name: Start Runner and Wait for Job
        timeout-minutes: 5
        run: |
          ./run.sh --once
      - name: Remove Runner
        if: always()
        continue-on-error: true
        run: |
          ./config.sh remove --pat ${{secrets.PAT}}

ジョブを実行するのはStart Runner and Wait for Jobという名前のステップであるため、後続のRemove Runnerにおいては既にランナーを汚染することができている。
つまり、./config.sh remove --pat ${{secrets.PAT}} で渡されているsecrets.PATの中身を窃取することが可能となっていた。

影響

さて、ここでsecrets.PATの中身は誰に紐づいているのか、という点が問題となってくる。
幸いにも、このトークンは別の箇所でワークフローの実行に使用されていたため、容易にトークンの所有者を特定することができた。

実行者の名前を黒塗りにしたワークフローの実行履歴画面

そこで、当該のユーザーのプロフィールを確認したところ、@actionsだけではなく@githubにも所属していることが確認できた。
前述の通り、このトークンは複数の目的に利用されているため、最低でもpublic_repoスコープを持っていることが推察できる。
そのため、この脆弱性を悪用し、トークンの窃取を行うことで、@actions及び@githubの公開リポジトリに対する書き込み権限を得ることができたものと思われる。
これは、actions/checkoutactions/cacheといったようなリポジトリを含んでいたため、これらのリポジトリに対して変更を加えることで、膨大な数のGitHubリポジトリに対して影響が及ぶ可能性があった。

まとめ

今回の記事では、GitHub Actionsの設定ミスがどのようにして深刻なサプライチェーンへのリスクへ繋がるのかについて解説を行いました。
脆弱性を報告してからこの記事を執筆し始めるまで1年以上かかってしまいましたが、以前から公開したいと考えていた記事だったため、楽しんでいただけましたら幸いです。

本記事に関する質問/感想はTwitter(@ryotkak)またはMisskey(@[email protected])までお願いいたします。

タイムライン

日付 (日本時間)出来事
2021/06/19脆弱性の発見/報告
2021/06/19一時対応完了
2021/06/22対応完了
2022/12/18記事の執筆を開始
2023/04/11記事の公開許可が出る
2023/04/22本記事の公開

  1. より詳しく言うと、プルリクエストのマージコミット時点のワークフローの定義ファイルを使用します。 ↩︎

  2. 正確には、GitHubが加えたこの変更により、最低一度対象のリポジトリに対してコントリビュートをしていないと、自動でワークフローが実行されません。 ↩︎

  3. 合計で300行以上あるためコードの記載は行いませんが、ワークフローの内容はこちらから確認できます: https://github.com/actions/runner/blob/a9be5f65578a8225c6a024799f572ad2066c4fd8/.github/workflows/e2etest.yml ↩︎