TJ Actionsのchanged-files GitHub Actions侵害の再現
2025年3月17日
0 分で読めます2025年3月14日金曜日の午後、人気のGitHub Actionであるchanged files(tj-actions/changed-files)における重大なセキュリティエクスプロイトの詳細が明らかになり始めました。約23,000のGitHubリポジトリが、CIおよびDevOpsのワークフローの一部としてこのActionを利用しています。ブランチやコミット間で変更されたファイルを追跡できるActionです。
Actionのリポジトリへの書き込み権限を持つ攻撃者が、暗号化されたシークレットをGitHub Actionsのログに平文で表示させるコミットを行いました。Actionのログも公開されているパブリックリポジトリでは、深刻な侵害につながる可能性があります。
まず、お客様にご安心いただくためにお伝えします。Snykは自社インフラの評価を完了しており、脆弱なライブラリの影響を受けるバージョンを使用していないことを確認しています。したがって、Snykはこの脆弱性の影響を受けません。
そのうえで、このブログでは、Snykが行った脆弱性の詳細な分析、その仕組み、そして論理的な対処手順について解説します。
この記事の執筆時点では、攻撃者がこのGitHub Actionのコードを格納するリポジトリへの書き込み権限を持っていた理由は不明です。しかし、単に書き込み権限があったこと以外にも、攻撃を可能にした主な要因がありました。
既存のリリースタグを攻撃コミットを指すように変更
mainブランチを含む、どのブランチからも切り離された攻撃コミット
これらの要因によって攻撃が隠蔽されました。攻撃では、外部ネットワークへの接続を行って攻撃コードをダウンロードしていましたが、その結果、異常なネットワークアクティビティを監視するサービスによって発見されました。
この記事の後半では、攻撃を(無害な方法で)再現します。攻撃がどのように可能になったのか、また、どのように防げるのかを理解するのに役立ててください。
孤立したGitコミットについて
次の手順を試してみましょう。GitHubで新しいリポジトリを作成し、ローカルにクローンします。ブランチを作成してGitHubにプッシュし、コミットハッシュを控えておきます。その後、リモートブランチを削除します。既存のブランチに紐づかなくなっても、そのコミットには引き続きアクセスできます。
上記の手順は次のとおりです。
1. GitHubでリポジトリを作成する
2. ローカルリポジトリを作成し、先ほど作成したGitHubリモートを追加する
3. ブランチを作成する
4. コミットを追加してプッシュする
5. GitHubでコミットを確認する

6. GitHubでブランチを削除する

7. 先ほど控えたコミットのURLにアクセスする

これが孤立したブランチの例です。これは、changes-files GitHub Actionのワークフローを悪用するうえで、重要な要因の一つです。
もう一つの重要な要因は、リポジトリ内のリリースタグを移動させたことです。
GitHubのリリースタグについて
残念ながら、GitHubのリリースタグに対して、開発者がときに過度な期待を寄せることがあります。特定のリリースのv35を使うように言われると、そのタグを参照すれば、まさにそのバージョンを使えると思いがちです。通常であれば妥当な想定ですが、GitHubのタグは特定のコミットを指す便宜的な文字列にすぎません。厳密にセマンティックバージョニングを行っていても、特別な仕組みがあるわけではありません。以下は、changes-files Actionを参照するGitHub ActionのYAMLファイルの一部です。
重要なのは最後の行で、GitHub Actionリポジトリの特定のタグを参照しています。攻撃者は元のバージョンタグを削除し、孤立したコミットである悪意のあるコミットに付け替えました。既存の複数のリリースタグを悪意のあるコミットに付け替えたことで、攻撃の影響がさらに拡大しました。
先ほどの例を続けて、この動作を実際に示します。ローカルマシンで、作業中のブランチにタグを付けます。
GitHubでは、タグが孤立したコミットに紐づきました。次の場所にアクセスします。
すると、次のように表示されます。

つまり攻撃者は、changed-files GitHub Actionのコードがあるリポジトリを更新するだけで、悪意のあるコミットを参照させることができました。
要するに、23,000ものリポジトリが依存するリポジトリへの書き込み権限を、悪意ある攻撃者が持っていたのです。攻撃者は巧妙な方法で活動を隠しましたが、書き込み権限がなければ攻撃を実行することはできませんでした。
攻撃者が何を狙っていたのか、もう少し詳しく見ていきましょう。
シークレットの漏えいについて
スクリプトから他のサービスにアクセスする場合や、ビルドやテストの実行に必要なキーを渡す場合、シークレットが必要になることがよくあります。
GitHubは、シークレットを管理するための高度な暗号化の仕組みを備えています。Gitリポジトリレベルでシークレットを設定すれば、シークレットをビルドログに表示したり漏えいさせたりすることなく、ビルドスクリプトから利用できます。GitHubは内部でシークレットを復号し、ビルドスクリプト内の参照値として使用します。実際には、GitHub ActionのYAMLファイルに次のような行を記述できます。
これで、スクリプト本体やログにシークレットを記載することなく、スクリプトの他の部分からシークレットを利用できます。
攻撃者はTJのchanged-files GitHub Actionを改ざんし、外部の場所からスクリプトをダウンロードして、GitHub Actionの実行中に仮想マシン上のローカルファイルに保存し、そのスクリプトを実行させました。これは2段階で行われました。最初の段階は悪意のある孤立したコミットによるもので、このコミットはその後削除されています。以下が、そのGitコミットの重要な部分です。
上記の長い文字列をBase64でデコードすると(スクリプトでも同じ処理が行われます)、次のようになります。
ここで参照されていたgistはすでに削除されていますが、当時のPythonスクリプトを以下に示します。
上記のスクリプトは、sudoコマンドを使ってroot権限で実行され、プロセスメモリを調べて復号済みのシークレットを探し、Actionsのログに出力します。公開GitHubリポジトリのGitHub Actionsログも設計上公開されているため、これは非常に深刻な問題です。
3月14日に公開されたHacker Newsの投稿(こちら)によると、このエクスプロイトはGitHub Actionsにおける不正なネットワーク呼び出しなどを監視するサービス、StepSecurityによって検知されました。人気のGitHub Actionの作者兼メンテナーは、この投稿にコメントし、何が起きたのかを説明しています。最初に指摘されているのは、攻撃者がリポジトリへの書き込み権限を持っていたことです。
迅速な発見とメンテナーの素早い対応により、エクスプロイトはすぐに対処され、無効化されました。ただし、changed-files GitHub Actionの利用者は、3月14日以降のログを確認し、公開Actionsログにシークレットが漏えいしていないことを確かめてください。
エクスプロイトの実演
攻撃者の環境をできるだけ忠実に再現した構成で、このエクスプロイトを実演するためのリポジトリをいくつか作成しました。
最初のGitリポジトリでは、カスタムGitHub Actionを定義しています。tj-changed-files-action-goofです。
index.jsファイルはシンプルで、主にGitHubのAction作成チュートリアルを参考にしています。
who-to-greetという入力を受け取り、日付と時刻を出力します。
もう一つのリポジトリには、カスタムGitHub Actionを実行するYAMLファイルが1つだけあります。また、このリポジトリにはA_SECRETというシークレットが設定されています。リポジトリ名はtj-changed-files-action-exploit-goofです。.github/workflows/main.ymlを見ると、次のようになっています。
ここでは次の処理を行っています。
「Hello, world!」メッセージを出力する
tj-changed-files-action-goofGitHub Actionを実行するActionから時刻を出力する。また、GitHub Actionで想定どおりにリポジトリのシークレットを使用しています。
異なる2回の実行におけるA Goof Stepの出力を比較してみましょう。まずはこちらです。
次にこちらです。
どちらの場合も、snyk-labs/tj-changed-files-action-goof@v1.0 が実行されています。2回目の実行では、侵害されたGitHub Actionからの出力を確認できます。また、エンコードされた文字列をBase64で2回デコードすると、次のようになります。
どちらの実行でも、GitHub Actionsがシークレットの値を参照する際に保護していることがわかります。a_secret: ***。しかし、エクスプロイトコードはシステムメモリからシークレットの平文値を直接取り出します。
v1.0タグが付いたtj-changed-files-action-goofリポジトリに戻ると、元のエクスプロイトと同様に、孤立したブランチがあることがわかります。

v1.0は当初、mainブランチを指していました。しかし、エクスプロイトを再現するために、attackブランチを指すように付け替え、そのブランチを孤立させました。
見落としやすい巧妙な仕掛けの最後の部分は、ncc build index.jsです。これによりdist/index.jsが更新され、更新されたファイルだけがコミットされます。GitHub Actionが実際に実行するのは、Webpackでバンドルされたdist/index.jsファイルです。そのため、孤立したv1.0ブランチのindex.jsファイルを見ても、変更がないように見えます。
mainブランチと比較した、dist/index.jsのwebpackコード(v1.0)の差分です。
GitHub Actionsを保護するための対策
攻撃者がこの人気のGitHub Actionリポジトリへの書き込み権限をどのように、あるいはなぜ得たのかについては、今後さらに詳細が明らかになるでしょう。
依存するさまざまなライブラリのバージョンを参照するために、gitタグを使うのは一般的です。
今回のエクスプロイトを防ぐ方法はいくつかあります。
1. カスタムのリモートGitHub Actionsでは、コミットハッシュを直接指定する
たとえば、次のように指定できます。
これはタグではなくコミットハッシュを直接参照します。一般的な方法ではありませんが、実行されるカスタムGitHub Actionが、想定したバージョンであることを保証できます。
2. 予期しないネットワーク呼び出しを検知するカスタムGitHub Actionsを追加する。StepSecurityはこの方法でエクスプロイトを検知しました。
今回のような新たなエクスプロイトが発生した際に、オープンソースの貢献者や企業、個人が協力して発見と修正に取り組む姿を見ると心強く感じます。
SnykはこれまでにもGitHub Actionsに関する調査結果やベストプラクティスを公開しています。ぜひ参考にし、DevSecOpsやセキュリティ対策を見直してください。
オープンソースセキュリティの現状を探る
オープンソースソフトウェアとサプライチェーンセキュリティの最新動向やアプローチを理解しましょう。



