本記事は、「Trivy サプライチェーン攻撃:FutureVuls 配布バイナリの安全性検証レポート」の解説編です。検証レポートで実行した cosign verify や Rekor 透明性ログ検索が内部で何をしているのか、その信頼の仕組みを支える Sigstore エコシステム(Cosign / Fulcio / Rekor)を体系的に解説します。
この記事で扱うこと
2026 年 2 月 27 日〜3 月 2 日にかけて、hackerbot-claw と名乗る AI 自律型ボットが GitHub Actions ワークフローの脆弱性を突いた攻撃キャンペーンを展開しました。trivy リポジトリに対しては、まず 2 月 27 日 00:18 UTC に関連アカウント(MegaGame10418)が PR #10252 を開いて pull_request_target ワークフローを発火させ、トークンの窃取を試みました。
さらに翌 2 月 28 日 03:28 UTC には hackerbot-claw が PR #10254 を通じて同じ脆弱性を突き、PAT を窃取。19 分後の 03:47 UTC に窃取した PAT で main ブランチへの不正コミットが行われました。
trivy の maintainer は両アカウントを同一攻撃者と推定しています。同日(2 月 28 日)中にリポジトリのリネーム・非公開化が実行されました。
翌 3 月 1 日には GitHub Releases の全削除(178 件)と悪意ある VSCode 拡張のパブリッシュが確認され、同日中に Aqua Security がインシデントを公開開示し、復旧作業を実施しました。
FutureVuls は攻撃キャンペーン直前の 2/24 に Trivy v0.69.1 を GitHub Releases から取得してリリース作業を行っていました。
trivy のリポジトリ乗っ取り自体は 2/28 ですが、3/2 の早朝にレポートを執筆した時点では hackerbot-claw の攻撃キャンペーンがいつから trivy を標的にしていたのか、また GitHub Releases のアセットが攻撃期間中のどの時点で改ざん・差し替えの可能性があったのかが明らかになっていませんでした。
FutureVuls がバイナリを取得した 2/24 が「安全だった」と言い切れる根拠がない状況だったため、配布バイナリの改ざん有無を独自に検証する必要がありました。
検証レポートでは、以下の 4 つの観点から FutureVuls 配布バイナリの改ざん有無を確認しました。
- バイナリハッシュ比較:FutureVuls が
installer.vuls.biz経由で配布しているバイナリと、GHCR(GitHub Container Registry)の公式イメージ内バイナリの SHA256 を比較 - ビルドタイムスタンプ:GHCR イメージの作成日時が攻撃開始前(2/5)であることを
docker inspectで確認 - Rekor 透明性ログ検索:Rekor の公開 API を直接叩き、同一ダイジェストのエントリが攻撃前から存在していたことを確認
- cosign 署名検証:
cosign verifyで GHCR イメージの署名が Aqua Security の正規ワークフローによるものかを暗号学的に検証
これら 4 つの検証結果から構成される「信頼の連鎖」は、検証レポートでは以下のように整理しています。
cosign verify ✅ 成功(署名証明書: aquasecurity/trivy の GitHub Actions)
└─ Index Manifest: sha256:1c78ed1ef824ab8bb05b04359d186e4c1229d0b3e67005faacb54a7d71974f73 (署名検証済み)
│
├─ amd64 Platform Manifest: sha256:5c8786cba2b31948e0b1f944d0be48a497f709c8c3e061dc504f6a0965c5fa44
│ ├─ Rekor: 2026-02-05〜 連続記録(攻撃前から存在)✅
│ ├─ Image Created: 2026-02-05(Trivy のリポジトリ乗っ取り 2/28 の 23 日前)✅
│ └─ Binary SHA256: 0e5a8eb7...dda7251 = installer.vuls.biz ✅
│
└─ arm64 Platform Manifest: sha256:98eb7d6fc594903f89b68f23e9e53fe0a9cacab7623f5ab310f74d518487a1b1
├─ Rekor: 2026-02-23 のみ(Index Manifest の署名でカバー)✅
├─ Image Created: 2026-02-05(Trivy のリポジトリ乗っ取り 2/28 の 23 日前)✅
└─ Binary SHA256: cf8e44a8...acc69f = installer.vuls.biz ✅
このツリーの各検証が内部で何をしているのか、それを支える Sigstore(Cosign / Fulcio / Rekor)によるキーレス署名・検証の仕組みを、検証レポートの具体的なコマンド出力に対応させながら解説します。
従来の署名が抱えていた問題
デジタル署名の基本
デジタル署名は「秘密鍵で署名し、公開鍵で検証する」仕組みです。署名者は秘密鍵を使ってデータに対する署名を生成し、検証者は公開鍵を使って「この署名は確かにこの秘密鍵の持ち主が、このデータに対して生成したものだ」と確認できます。
データが 1 ビットでも変わると署名検証は失敗するため、改ざん検知に使えます。
ただし、これだけでは「その公開鍵は本当に正規の署名者のものなのか?」という問題が残ります。攻撃者が自分の鍵ペアを作って署名しても、検証者がその公開鍵を信じてしまえば意味がありません。
この「公開鍵の持ち主を証明する」ために使われるのが証明書であり、信頼された第三者(認証局 = CA)が「この公開鍵はこの人のものです」と署名して保証します。HTTPS の TLS 証明書と同じ仕組みです。
従来の署名の運用負荷
従来のソフトウェア署名では、開発者が GPG 鍵や PKI 証明書を自分で生成・管理し、その秘密鍵でアーティファクトに署名していました。これにはいくつかの問題があります。
- 鍵の保管場所の問題(CI の環境変数・シークレットストアに格納するのか、KMS を使うのか)
- 鍵のローテーションの問題
- 鍵が漏洩した際の失効管理の問題
- 複数メンテナがいる OSS プロジェクトでの鍵共有の問題
これらの運用負荷が原因で、OSS プロジェクトによっては「署名しない」という選択が取られることもありました。Sigstore はこの問題を解決するために生まれたプロジェクトです。
Sigstore の 3 つのコンポーネント
Sigstore は 3 つのコンポーネントで構成されています。それぞれの役割を、検証レポートの文脈に対応させながら説明します。
Fulcio:短命証明書の認証局
Fulcio は Sigstore の認証局(CA)です。従来の PKI の認証局と同じ役割ですが、大きな違いは発行する証明書の有効期間が約 10 分しかないことです。
Fulcio は「誰がこの署名をしたか」を OIDC(OpenID Connect)の ID トークンで証明します。GitHub Actions の場合、GitHub が OIDC プロバイダーとして機能し、「このトークンはどのリポジトリの、どのワークフローから発行されたものか」という情報を含むトークンを Fulcio に渡します。Fulcio はそれを検証し、その情報を埋め込んだ有効期限約 10 分の証明書を発行します。
Cosign:署名・検証ツール
Cosign(CLI コマンドとしては cosign)は Sigstore の署名・検証ツールです(Apache License 2.0 の OSS、Go 言語製、リポジトリ: github.com/sigstore/cosign)。Fulcio から受け取った短命証明書に対応する一時的な秘密鍵でアーティファクトに署名し、署名と証明書をアーティファクトに紐付けます。
検証時には、証明書チェーンの検証(Sigstore のルート認証局まで辿れるか)、署名の暗号学的検証(データが改ざんされていないか)、証明書に埋め込まれた ID 情報のマッチング(期待するワークフローからの署名か)を行います。
Rekor:追記専用の透明性ログ
Rekor は署名イベント(いつ、誰が、何に署名したか)を改ざん不能な公開台帳に記録します。
Rekor の重要な性質は 3 つあります。
- 追記専用:一度書き込まれたエントリは削除・改ざんできない
- 書き込み時刻の付与:各エントリに
integratedTimeが付与される。この時刻は Rekor サーバが付与するため、署名者が偽装できない - ダイジェスト検索:SHA256 をキーにエントリを検索できる
キーレス署名のフロー
署名時:GitHub Actions 上で何が起きているか
Aqua Security が trivy:0.69.1 をビルド・リリースした 2026 年 2 月 5 日、GitHub Actions 上のリリースワークフローで以下が自動的に行われていました。全体の流れを図に示します。
通信 1(ランナー → GitHub OIDC エンドポイント): Cosign が OIDC トークンを要求し、GitHub が JWT を返します。ワークフローの YAML に permissions: id-token: write を記述することで、この機能が有効になります。
通信 2(ランナー → Fulcio): Cosign が OIDC トークンとローカルで生成した公開鍵を送り、Fulcio が OIDC トークンを検証し、トークンに含まれる ID 情報(リポジトリ名、ワークフローパス等)を SAN フィールドに埋め込んだ短命証明書を返します。
通信 3(ランナー → GHCR): Cosign がイメージのダイジェストに一時的な秘密鍵で署名し、署名と証明書を GHCR にアタッチします。
通信 4(ランナー → Rekor): Cosign が署名と証明書のペアを Rekor に送り、透明性ログに記録します。
通信 1〜4 はすべて Cosign が内部的に順次実行するため、ワークフロー作成者から見ると「cosign のコマンドを 1 回呼ぶ」だけです。実際の v0.69.1 では、GoReleaser の設定ファイル(goreleaser.yml L292-314)で docker_signs が定義されており、GoReleaser がリリースプロセスの一部として Cosign によるキーレス署名を自動実行しています。
この設定を含むリリースワークフロー(release.yaml)が、v0.69.1 のワークフローラン #142(aqua-bot による実行、47 分 8 秒で完了)として実行されました。秘密鍵は署名後すぐ破棄され、証明書も約 10 分で期限切れになります。長期間管理すべき秘密鍵が存在しません。これが「キーレス署名」の特徴です。
cosign verify は内部で何をしているか
検証レポートで実行した以下のコマンドが、内部で何を行っているかを見ていきます。
cosign verify \
--certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/aquasecurity/trivy:0.69.1
cosign verify の内部には、2 つの独立した検証が含まれています。これを cosign のソースコード(pkg/cosign/verify.go)を参照しながら解説します。
cosign verify のエントリポイントは VerifyImageSignatures 関数です。GHCR からイメージのダイジェストを解決し、署名オブジェクトを取得した後、内部の verifyInternal へ処理が進みます。
検証 A:データの完全性(改ざんされていないか)
この検証に Rekor は登場しません。純粋な暗号演算です。
ステップ A-1:ダイジェストの計算 Cosign は GHCR から今現在の Index Manifest の実データを取得し、その SHA256 ハッシュ(ダイジェスト)を計算します。
ステップ A-2:署名の中のダイジェストとの照合 GHCR にアタッチされている署名データの中には、署名時点での Index Manifest のダイジェスト(sha256:1c78ed1...)が含まれています。ステップ A-1 で計算したダイジェストと、署名内のダイジェストを照合します。イメージが差し替えられていればダイジェストが変わるため、ここで不一致が検出されます。
ステップ A-3:署名の暗号学的検証 証明書から公開鍵を抽出し、署名データが改ざんされていないかを検証します。攻撃者が署名データの中のダイジェストを書き換えようとしても、秘密鍵を持っていないため正しい署名を再計算できず、ここで検出されます。
検証 B:署名者の身元(誰が署名したか)
この検証には Rekor が必要になります。
ステップ B-1:証明書チェーンの検証 取得した証明書が、Sigstore のルート認証局から辿れる Fulcio 発行の証明書であるかを確認します。cosign に埋め込まれた Sigstore Root CA → Fulcio 中間 CA → 取得した証明書、というチェーンを辿ります。自作の偽証明書はここで弾かれます。
ステップ B-2:Rekor ログとの照合(短命証明書の有効性証明) ここがキーレス署名特有のステップです。Fulcio の証明書は約 10 分で期限切れになるため、検証時点(3/2)ではすでに有効期限が切れています。以下、cosign のソースコードからこの仕組みの実装を追います。
tlogValidateEntry:最古の Rekor エントリを選択するロジック
バンドルに Rekor エントリが含まれていない場合、cosign はオンラインで Rekor に問い合わせます。その際、複数のエントリが見つかった場合に最古のものを選択する設計が tlogValidateEntry に実装されています。
func tlogValidateEntry(...) (*models.LogEntryAnon, error) {
tlogEntries, err := FindTlogEntry(ctx, client, b64sig, payload, pem)
// ...
// Always return the earliest integrated entry. That
// always suffices for verification of signature time.
var earliestLogEntry models.LogEntryAnon
var earliestLogEntryTime *time.Time
for _, e := range tlogEntries {
entry := e
entryTime := time.Unix(*entry.IntegratedTime, 0)
if earliestLogEntryTime == nil || entryTime.Before(*earliestLogEntryTime) {
earliestLogEntryTime = &entryTime
earliestLogEntry = entry
}
}
return &earliestLogEntry, nil
}
コメントに Always return the earliest integrated entry とある通り、最も古いエントリを返します。検証レポートの Rekor 検索で amd64 に対して 28 件のエントリがヒットしましたが、cosign の検証ロジック上は最古の 1 件(2/5)が後述の CheckExpiry に渡されます。
CheckExpiry:証明書の有効期限を「いつの時刻で」判定するか
// CheckExpiry confirms the time provided is within the valid period of the certificate and optionally
// all issuing CA certificates.
func CheckExpiry(cert *x509.Certificate, issuingChain []*x509.Certificate, it time.Time) error {
ft := func(t time.Time) string {
return t.Format(time.RFC3339)
}
if cert.NotAfter.Before(it) {
return &VerificationFailure{
fmt.Errorf("certificate expired before observed time: %s is before %s",
ft(cert.NotAfter), ft(it)),
}
}
if cert.NotBefore.After(it) {
return &VerificationFailure{
fmt.Errorf("certificate was issued after observed time: %s is after %s",
ft(cert.NotBefore), ft(it)),
}
}
// ...
return nil
}
なお、実際のコードでは第 2 引数 issuingChain により発行 CA 証明書チェーンの有効期限も検証されますが、本記事では it(判定基準時刻)の説明に焦点を当てるため、チェーン検証部分のコードは省略しています。
引数 it time.Time が判定基準の時刻です。cert.NotAfter(有効期限)と cert.NotBefore(有効開始)の間に it が収まっているかを見ています。重要なのは、この it に何が渡されるかです。
verifyInternal:it に Rekor の integratedTime が渡される流れ
以下は verifyInternal から Rekor に関連する部分を抜粋したものです。実際のコードでは RFC 3161 タイムスタンプによる検証パスも並行して存在し、CheckExpiry は RFC3161 タイムスタンプ → Rekor integratedTime → 現在時刻(time.Now())の 3 段階でフォールバックする設計です。ここでは Rekor バンドルから integratedTime を取得し、CheckExpiry に渡す流れを読み取ります。
func verifyInternal(...) (bundleVerified bool, err error) {
var acceptableRekorBundleTime *time.Time
if !co.IgnoreTlog {
// バンドルまたはオンライン問い合わせから integratedTime を取得
t, err := getBundleIntegratedTime(sig)
acceptableRekorBundleTime = &t
}
// ... 署名の暗号学的検証(検証 A に相当)...
// 証明書が存在する場合、有効期限を integratedTime で判定
if cert != nil {
if acceptableRekorBundleTime != nil {
if err := CheckExpiry(cert, verifierChain, *acceptableRekorBundleTime); err != nil {
return false, fmt.Errorf("checking expiry on certificate with bundle: %w", err)
}
}
}
return bundleVerified, nil
}
getBundleIntegratedTime は Rekor エントリの IntegratedTime を time.Time に変換する関数です。
func getBundleIntegratedTime(sig oci.Signature) (time.Time, error) {
bundle, err := sig.Bundle()
if err != nil {
return time.Now(), err
} else if bundle == nil {
return time.Now(), nil
}
return time.Unix(bundle.Payload.IntegratedTime, 0), nil
}
このコードから、cosign が CheckExpiry に渡す時刻は現在時刻(time.Now())ではなく、Rekor の integratedTime(bundle.Payload.IntegratedTime)であることが読み取れます。
3 月 2 日に検証を実行しても、証明書の有効期限判定は 2 月 5 日の Rekor 記録時刻を基準に行われるため、「証明書が期限切れ」とはなりません。
Fulcio の証明書は有効期間を約 10 分に制限しているため、従来の PKI で必要だった CRL(失効リスト)や OCSP(リアルタイム失効問い合わせ)による能動的な失効管理は不要です。証明書の期限切れ後の検証は、上記の通り Rekor のタイムスタンプが担保します。
ただし、Rekor 自体は提出されたエントリの証明書有効期限をチェックして受け入れ・拒否を行うわけではありません。誰でもいつでもエントリを提出できます。
ポイントは、Rekor がゲートキーパーとして記録を拒否するのではなく、cosign の検証ロジックが事後的に「integratedTime が証明書の有効期間外だった」場合に不正を検出する設計になっていることです。
ステップ B-3:ID 情報のマッチング 証明書の X.509 拡張フィールドから、署名者の身元に関する 2 つの情報を取得し、CLI で指定された期待値と照合します。
1 つ目は Issuer(OIDC 発行元) で、「どのプラットフォームで署名が行われたか」を示します。OIDC トークンを発行したプロバイダーの URL であり、GitHub Actions の場合は https://token.actions.githubusercontent.com、Google アカウントの場合は https://accounts.google.com のように、プラットフォームごとに固定の値です。
2 つ目は Subject(SAN = Subject Alternative Name) で、「そのプラットフォーム上の誰が署名したか」を示します。GitHub Actions の場合、SAN には OIDC トークンの job_workflow_ref クレームに基づくワークフローの完全な URL が格納されます。リユーザブルワークフローを使用している場合、SAN には呼び出し元ではなく実際に署名処理を実行するリユーザブルワークフローのパスが設定されます。
trivy の場合、以下のような値になります。
https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.69.1
https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.69.1
この URL 1 つに、リポジトリ名(aquasecurity/trivy)、ワークフローファイルのパス(.github/workflows/reusable-release.yaml)、トリガーとなった Git ref(refs/tags/v0.69.1)が含まれています。
検証レポートでは以下の 2 つのオプションを指定しました。
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'→ Issuer に対するチェック。「GitHub Actions で署名されたものであること」を要求--certificate-identity-regexp 'https://github\.com/aquasecurity/trivy/'→ Subject(SAN)に対する正規表現チェック。「aquasecurity/trivyリポジトリのワークフローで署名されたものであること」を要求
Issuer だけでは「GitHub Actions で署名されたが、どのリポジトリかは不明」であり、Subject だけでは「aquasecurity/trivy のワークフローらしいが、本当に GitHub Actions から発行された証明書かは不明」です。両方が一致して初めて、署名者の身元が確認できます。
以下は CheckCertificatePolicy から正規表現マッチングのパスを抜粋したものです(pkg/cosign/verify.go)。実際のコードでは switch 文により、正規表現マッチ(IssuerRegExp / SubjectRegExp)、完全一致(Issuer / Subject)、制約なし(default)の 3 分岐が処理されます。
func CheckCertificatePolicy(cert *x509.Certificate, co *CheckOpts) error {
ce := CertExtensions{Cert: cert}
oidcIssuer := ce.GetIssuer()
sans := cryptoutils.GetSubjectAlternateNames(cert)
for _, identity := range co.Identities {
// Issuer(OIDC 発行元)のマッチング
issuerMatches := false
if identity.IssuerRegExp != "" {
regex, _ := regexp.Compile(identity.IssuerRegExp)
issuerMatches = regex.MatchString(oidcIssuer)
}
// Subject(SAN)のマッチング
subjectMatches := false
if identity.SubjectRegExp != "" {
regex, _ := regexp.Compile(identity.SubjectRegExp)
for _, san := range sans {
if regex.MatchString(san) { subjectMatches = true; break }
}
}
if subjectMatches && issuerMatches {
return nil // 両方マッチしたら検証成功
}
}
return &VerificationFailure{
fmt.Errorf("none of the expected identities matched what was in the certificate, got subjects [%s] with issuer %s",
strings.Join(sans, ", "), oidcIssuer),
}
}
ce.GetIssuer() は証明書の X.509 拡張フィールド(Fulcio 独自の OID 1.3.6.1.4.1.57264.1.1、cosign のソースコードに定数として定義)から Issuer を取得します。sans には前述の SAN(ワークフロー URL やメールアドレス)が格納されています。issuerMatches と subjectMatches の両方が true の場合にのみ検証が成功する設計です。
※ OID1.3.6.1.4.1.57264.1.1は 2023 年 3 月の Fulcio v1.2.0 で deprecated となり、後継の1.3.6.1.4.1.57264.1.8(Issuer V2)が導入された。ただし後方互換のため Fulcio は現在も両方の OID を証明書に埋め込んでおり、cosign もどちらの OID でも検証できる。
cosign verify の出力と Index Manifest
{
"critical": {
"identity": {
"docker-reference": "ghcr.io/aquasecurity/trivy:0.69.1"
},
"image": {
"docker-manifest-digest": "sha256:1c78ed1ef824ab8bb05b04359d186e4c1229d0b3e67005faacb54a7d71974f73"
},
"type": "cosign container image signature"
}
}
docker-manifest-digest が sha256:1c78ed1ef824ab8bb05b04359d186e4c1229d0b3e67005faacb54a7d71974f73 を指しています。これは Index Manifest のダイジェストです。Index Manifest とは、各アーキテクチャの Platform Manifest のダイジェストを一覧として含む JSON ドキュメントであり、docker manifest inspect コマンドで取得できます。
docker manifest inspect ghcr.io/aquasecurity/trivy:0.69.1
JSON 全体は以下のような構造です(実際の出力から主要フィールドを抜粋)。
{
"manifests": [
{
"digest": "sha256:5c8786cba2b31948e0b1f944d0be48a497f709c8c3e061dc504f6a0965c5fa44",
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"digest": "sha256:98eb7d6fc594903f89b68f23e9e53fe0a9cacab7623f5ab310f74d518487a1b1",
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}
sha256:1c78ed1ef824ab8bb05b04359d186e4c1229d0b3e67005faacb54a7d71974f73 はこの JSON 全体の SHA256 ハッシュです。つまり、amd64 の sha256:5c8786c... と arm64 の sha256:98eb7d6... の両方の値がハッシュ計算に含まれています。どちらか一方の Platform Manifest が差し替えられればそのダイジェストが変わり、Index Manifest 全体のダイジェストも変わるため、cosign verify の署名検証は失敗します。
これが、arm64 の Rekor エントリが 1 件しかなかった問題を解消する理由です。cosign verify は Platform Manifest 単位ではなく Index Manifest 全体に対する署名を検証しているため、Index Manifest の署名検証が成功すれば、そこに含まれる amd64 も arm64 も暗号学的にカバーされます。
検証レポートの 4 つの検証が果たす役割
検証レポートでは 4 つの検証を行いました。それぞれが異なる役割を果たしています。
各検証の独立性
検証 1(バイナリハッシュ比較) は、FutureVuls が installer.vuls.biz 経由で配布するために配置したバイナリと、GHCR イメージ内のバイナリの SHA256 を比較しました。Cosign とは無関係な、純粋なハッシュ計算です。「FutureVuls が配布しているバイナリと GHCR 内のバイナリが同一か」を確認するもので、何にも依存しないプリミティブな検証です。
検証 2(ビルドタイムスタンプ) は、docker inspect でイメージの Created 日時を確認しました。これも Cosign とは無関係です。「イメージが攻撃開始前にビルドされたか」を確認するものですが、このタイムスタンプはイメージ作成者が設定できるため、単独では改ざん証拠としては弱いです。
検証 3(Rekor 透明性ログ検索) は、curl で Rekor の API を直接叩いてダイジェストを検索しました。Cosign を経由しておらず、Rekor の公開 API に直接アクセスしています。
検証 4(cosign 署名検証) が、ここまで解説してきた cosign verify による暗号学的な検証です。
cosign verify だけでは不十分な理由
cosign verify が証明するのは「GHCR のイメージが Aqua Security の正規ワークフローで署名されたものである」ということ、つまり GHCR のイメージの正当性です。しかしレポートの結論は「FutureVuls が installer.vuls.biz 経由で配布しているバイナリが改ざんされていない」です。cosign verify は配布バイナリについて何も言っていません。
cosign verify で「GHCR のイメージは正当だ」と確認し、検証 1 で「配布バイナリと GHCR イメージ内のバイナリは同一だ」と確認する。この 2 つが揃って初めて「配布バイナリは正当だ」という結論が導けます。
検証 3(手動 Rekor 検索)が必要だった理由
cosign verify 単独では排除できない脅威シナリオがあります。攻撃者がリポジトリの GitHub Actions ワークフローを制御し、悪意あるイメージに対して正規の署名を新たに作るというシナリオです。
攻撃者がワークフローを実行できれば、GitHub が正規の OIDC トークンを発行し、Fulcio が正規の証明書を出し、cosign verify は成功します。cosign はイメージの中身が善か悪かを判断せず、署名プロセスの正当性のみを検証するためです。
このシナリオを排除するために、手動 Rekor 検索で「同じダイジェストのエントリが攻撃前から連続して存在する」ことを確認しました。
攻撃者がイメージを差し替えて再署名していたらダイジェストが変わるため、攻撃前のエントリとは不連続になるはずです。連続していることが「途中で差し替えられていない」ことの証拠となります。
レポート執筆時点(3/2)では攻撃の全容が不明だったため、攻撃者がワークフローを制御できたか否かが確定していませんでした。最悪のシナリオを排除するための防御的な検証として、手動 Rekor 検索は必要でした。
検証レポートの設計意図の整理
| 検証 | 何を確認するか | 何に依存しているか | 省略可能か |
|---|---|---|---|
| 検証 1(SHA256 比較) | 配布バイナリ = GHCR バイナリ | なし(純粋なハッシュ計算) | 省略不可(配布バイナリの同一性は cosign では確認できない) |
| 検証 2(ビルド日時) | 攻撃開始前のビルドか | Docker イメージのメタデータ | 論理的には冗長だが、攻撃タイムラインとの対比で直感的に理解しやすい |
| 検証 3(手動 Rekor 検索) | 攻撃前から同一ダイジェストが存在 | Rekor の公開 API | 攻撃全容が判明していれば省略可能だが、不明な段階では必須 |
| 検証 4(cosign verify) | GHCR イメージの署名が正規か | Sigstore エコシステム全体 | 省略不可(暗号学的な正当性証明の根幹) |
第 1 波の検証設計と、第 2 波で明らかになった限界
検証レポートの 4 つの検証は、すべて GHCR のイメージを正規のリファレンスとして使っています。SHA256 比較、タイムスタンプ確認、Rekor 検索、cosign 検証——いずれも GHCR が信頼できることを前提とした設計です。
この設計は第 1 波の状況では正しいものでした。第 1 波の攻撃は GitHub Releases のアセット削除やリポジトリのリネームにとどまり、Aqua Security の公式インシデント報告および検証レポート時点の調査において、GHCR のコンテナイメージの改ざんは確認されませんでした。
しかし第 2 波(3/19 発生)では、攻撃者が窃取した認証情報を使って悪意ある v0.69.4 のリリースパイプラインを実行し、GHCR を含む全配布チャネル(GHCR、Docker Hub、ECR Public、GitHub Releases 等)に悪意あるバイナリが配布されました。GHCR のイメージ自体が侵害対象となったことで、第 1 波の検証手法がカバーしていない領域が浮き彫りになりました。これは cosign verify の仕組み自体の限界ではなく、今回の検証設計が GHCR という単一の信頼基点に依存していたことに起因します。
※第 2 波の詳細な影響分析は「Trivy サプライチェーン攻撃(第2波・3/19発生):FutureVuls 影響調査レポート」を参照してください。
まとめ:「改ざんされていない」を証明する信頼の構造
検証レポートの結論「FutureVuls が配布する Trivy v0.69.1 は改ざんされていない」を支える信頼の構造を改めて整理します。
Sigstore によるキーレス署名・検証:Cosign、Fulcio、Rekor の 3 コンポーネントにより、鍵管理なしで署名・検証が可能です。短命証明書 + Rekor の透明性ログにより、署名者の身元と署名時点の有効性を事後的に証明できます。
検証レポートの多層設計(Defense in Depth):cosign verify による暗号学的証明を最上位に、SHA256 比較、ビルドタイムスタンプ、手動 Rekor 検索を組み合わせました。それぞれが異なる脅威シナリオに対応し、攻撃の全容が不明な段階でも堅牢な結論を導いています。
ただし、これらの検証はすべて GHCR を信頼の基点としていました。第 2 波で GHCR 自体が侵害対象となったケースは、信頼基点の分散とビルド来歴の独立検証についても考慮する意義を示しています。
本記事では Sigstore の仕組みに焦点を当てました。「何を署名・証明すべきか」を定義する SLSA(Supply-chain Levels for Software Artifacts)フレームワークについては、別記事で改めて取り上げます。
参考リンク
- Trivy サプライチェーン攻撃:FutureVuls 配布バイナリの安全性検証レポート
- Trivy サプライチェーン攻撃(第2波・3/19発生):FutureVuls 影響調査レポート
- FutureVuls リリースノート(2026/02/24)
- Trivy v0.69.1 リリースワークフローラン #142
- Trivy v0.69.1 GoReleaser 設定(docker_signs)
- Cosign:Signing Overview
- Fulcio:Certificate Authority Overview
- Fulcio OID Information
- Rekor:Transparency Log Overview
- sigstore/cosign: Code signing and transparency for containers and binaries
- cosign verify.go(署名検証のメインロジック)
執筆者プロフィール
フューチャー株式会社
Cyber Security Innovation Group エンジニア
棚井龍之介(たない りゅうのすけ)
役割
セキュリティ領域を専門とするエンジニア(クラウドインフラ領域、バックエンド開発 )として業務に従事。同社が展開する脆弱性管理ツール「FutureVuls」に深く携わっており、OSSの脆弱性スキャナ(TrivyやVuls)のエンジン検証、SBOMの取り込み、サプライチェーン攻撃の調査・安全性の検証レポートなどを担当。
略歴
フューチャー株式会社の公式技術ブログ「Future Tech Blog」や「FutureVuls Blog」で継続的に技術発信を行っており、クラウドインフラからサイバーセキュリティまで幅広い技術領域で活躍。
-
サイバーセキュリティ: 脆弱性管理(CPE名の検索最適化など)、サプライチェーンセキュリティの監視、セキュリティ製品の実装・運用
-
クラウド・インフラストラクチャ: AWS(DynamoDB等のサーバレス技術)、Terraformを用いたInfrastructure as Code(IaC)の推進、LocalStackを利用したローカルテスト環境の構築。
-
業務自動化とプログラミング: 主な使用言語はGo言語やRuby。GAS(Google Apps Script)やSlack Botを利用した業務効率化、Seleniumを利用したUI操作の自動化。