| 项目 | 内容 |
|---|---|
| 分类 / 难度 | Misc / DFIR / Medium |
| 分值 | 250 |
| 案件号 | SUPPLY-26-443 |
| 类型 | 供应链取证(git 历史泄密 + 云审计归因) |
| 仓库 / 流水线 | payments-worker / release-443 |
| 窗口 | 2026-10-08 19:30–22:00 UTC |
| 目标 | 判定"合法作业 vs 泄露凭据复用",找凭据来源、首个未授权操作、受影响对象 |
1. 案情
一条云审计告警显示:出现了来自常见 release-runner 来源范围之外的对象存储读取。CI/CD 使用同一个服务主体。请判定该读取属于合法的流水线作业,还是对泄露凭据的未授权复用。
难点在于:CI/CD 用的是同一个服务主体,只看 userIdentity 根本无法区分"流水线自己跑"和"别人拿着偷来的 token 跑"。必须换判据。
2. 第一步:从来源 IP 突破
cloud/audit.jsonl 里按(主体, 来源 IP)做统计:
import json, collections
c = collections.Counter()
for l in open("cloud/audit.jsonl"):
e = json.loads(l)
c[(e["userIdentity"], e["sourceIPAddress"])] += 1
for k, v in c.most_common():
print(k, v)
('ci-cache-publisher', '10.30.0.12') 200
('secret-auditor', '10.30.0.24') 198
('artifact-cleaner', '10.30.0.12') 192
('soc-exporter', '10.30.0.18') 190
('ci-cache-publisher', '198.51.100.77') 3 ← 只有 3 次
ci-cache-publisher 平时都从 10.30.0.12 出来(200 次),但有 3 次来自 198.51.100.77。
10.30.0.0/16 是 release-runner 段;198.51.100.0/24 是 RFC 5737 文档保留段,在这里代表外部来源。这一步就把"是不是流水线"问成了"这个 IP 是谁"。
3. 第二步:三连击的时间线
把这 3 条单独拉出来:
{"eventTime":"2026-10-08T20:41:02Z","eventName":"GetObject","userIdentity":"ci-cache-publisher",
"sourceIPAddress":"198.51.100.77","bucket":"incident-bundles",
"key":"support-443/manifest.json","userAgent":"python-requests/2.31",
"requestID":"cloud-ext-0001","result":"Success"}
{"eventTime":"2026-10-08T20:41:05Z","eventName":"GetObject","userIdentity":"ci-cache-publisher",
"sourceIPAddress":"198.51.100.77","bucket":"incident-bundles",
"key":"support-443/triage-proof.txt.b64","userAgent":"python-requests/2.31",
"requestID":"cloud-ext-0002","result":"Success"}
{"eventTime":"2026-10-08T20:41:08Z","eventName":"ListObjects","userIdentity":"ci-cache-publisher",
"sourceIPAddress":"198.51.100.77","bucket":"secret-index",
"key":"","userAgent":"python-requests/2.31",
"requestID":"cloud-ext-0003","result":"Denied"}
三个关键特征:
| 特征 | 值 | 为什么能定罪 |
|---|---|---|
| 来源 IP | 198.51.100.77 |
不是 runner 段 |
userAgent |
python-requests/2.31 |
正常 CI 用 ci-sdk/4.2,这是裸脚本在调 |
| 序列 | GetObject → GetObject → ListObjects(Denied) | 先"确认存在"→ 取内容 → 横向探测,典型入侵节奏 |
对比一下正常流水线流量(同一份日志里):
{"userAgent":"ci-sdk/4.2","sourceIPAddress":"10.30.0.12","eventName":"ListObjects", ...}
ci-sdk/4.2 + 10.30.0.12 才是合法作业,python-requests/2.31 + 198.51.100.77 是复用者。UA 和 IP 两个维度同时偏离,这才是判据,而不是 userIdentity(因为它是一样的)。
首个未授权 API 操作 = GetObject(20:41:02Z,对象 support-443/manifest.json)。
注意第三条
ListObjects在secret-index上是Denied—— 不是首个未授权操作。首个是成功的那一条GetObject。这里很容易搞反:把"被拒绝的探测"当成"首个未授权行为",但它并没有成功。
4. 第三步:token 元数据已经给出提示
cloud/token_metadata.csv:
token_label,scope,normal_source,rotation_record,notes
ci-cache-publisher,"objects:read,objects:write",10.30.0.12,2026-10-08 19:42Z 之后未记录轮换,必须复核历史仓库内容
soc-exporter,incident:write,10.30.0.18,2026-09-30,已批准
secret-auditor,secret:metadata,10.30.0.24,2026-10-01,已批准
ci-cache-publisher 这一行的 notes 直接点名:“必须复核历史仓库内容”。
normal_source = 10.30.0.12—— 与我们在审计里看到的基线一致;rotation_record在19:42Z(正好在窗口开始后 12 分钟)之后再没记录过轮换 —— 说明凭据泄露后没被换掉,攻击者一直在用。
这是"泄露 → 未轮换 → 持续可用"的完整链条。
5. 第四步:从 git 历史捞出泄露的凭据
材料里有 repository/source/.git/(松散对象)+ payments-worker.bundle。无 git 命令也能手工读:git 的松散对象就是 zlib 压缩,格式为 <type> <size>\x00<content>。
import glob, zlib
for p in glob.glob("repository/source/.git/objects/??/*"):
sha = p.split("/")[-2] + p.split("/")[-1]
d = zlib.decompress(open(p, "rb").read())
hdr, _, body = d.partition(b"\x00")
typ = hdr.split()[0].decode() # blob / tree / commit
if typ == "blob" and b"CACHE_TOKEN=" in body:
print("===", sha)
print(body.decode("utf-8", "replace"))
命中两个 blob:
① 当前版本(正确做法)
CACHE_ENDPOINT=https://artifact-prod.example.test
CACHE_TOKEN=replace-with-secret-store-value
② 历史版本(泄露源)
CACHE_ENDPOINT=https://artifact-prod.example.test
CACHE_TOKEN=ci-cache-token-demo-7f31a9c2
同时命中了 .gitignore:
*.pyc
.env.local
—— .env.local 被加进了 .gitignore,但历史提交里那份已经存在。gitignore 只管"以后不再跟踪",管不了"已经进过库的内容"。这是最常见的供应链泄露形态之一。
于是:泄露的凭据值 = ci-cache-token-demo-7f31a9c2(出现在历史 blob bca19a85...,后来被改成占位符 replace-with-secret-store-value)。
另一个 blob 里存的是
replace-with-secret-store-value,看着像凭据但解出来不对——它是脱敏后的占位写法,不是真值。
6. 第五步:解封证明值
case/VERIFICATION.txt 规定指纹:
泄露的凭据值 | 复用主体 | 未授权来源 IP | 释放的对象键
前三个直接可得:
ci-cache-token-demo-7f31a9c2 | ci-cache-publisher | 198.51.100.77 | ???
第四段是本题最坑的一处。 “释放的对象键”(key)不是审计里的 key 字段 support-443/manifest.json,而是带桶名前缀的完整对象键:
incident-bundles/support-443/triage-proof.txt.b64
我一开始填 support-443/manifest.json,解出来是 UnicodeDecodeError: byte 0x94——纯乱码。逐个试 8 个候选 key 才对齐。
判据:triage-proof.txt.b64 这个名字本身就是"triage proof"(分诊证明),它才是承载证明值的对象;manifest.json 只是被先读了一下确认存在。题目问"释放的对象",指的是内容真正被释放出去的那个。
最终指纹:
ci-cache-token-demo-7f31a9c2|ci-cache-publisher|198.51.100.77|incident-bundles/support-443/triage-proof.txt.b64
解封:
import base64, hashlib
seal = open("cloud/incident-bundles/support-443/triage-proof.txt.b64").read().strip()
fp = "ci-cache-token-demo-7f31a9c2|ci-cache-publisher|198.51.100.77|incident-bundles/support-443/triage-proof.txt.b64"
raw = base64.b64decode(seal)
ks = hashlib.sha256(fp.encode()).digest()
print(bytes(b ^ ks[i % 32] for i, b in enumerate(raw)).decode())
flag{7a3f1e9c5b2d8046af31c7e9d5062b8f}
7. 完整求解脚本
#!/usr/bin/env python3
import base64, glob, hashlib, json, zlib
ROOT = "."
# --- 1) git 历史里找泄露凭据 ---
token = None
for p in glob.glob(f"{ROOT}/repository/source/.git/objects/??/*"):
try:
d = zlib.decompress(open(p, "rb").read())
except Exception:
continue
hdr, _, body = d.partition(b"\x00")
if hdr.split()[0] != b"blob":
continue
for line in body.decode("utf-8", "replace").splitlines():
if line.startswith("CACHE_TOKEN="):
v = line.split("=", 1)[1].strip()
print("[blob]", p.split("/")[-2] + p.split("/")[-1], "->", v)
if "replace-with" not in v:
token = v
print("[1] 泄露凭据 =", token)
# --- 2) 云审计:找偏离基线的来源 ---
baseline, ext = {}, []
for line in open(f"{ROOT}/cloud/audit.jsonl"):
e = json.loads(line)
if e["userIdentity"] == "ci-cache-publisher":
if e["sourceIPAddress"].startswith("10.30."):
baseline[e["sourceIPAddress"]] = baseline.get(e["sourceIPAddress"], 0) + 1
else:
ext.append(e)
print("[2] runner 基线 =", baseline, " 外部来源条数 =", len(ext))
ext.sort(key=lambda x: x["eventTime"])
first = ext[0]
print("[3] 首个未授权操作 =", first["eventName"], first["bucket"] + "/" + first["key"],
first["eventTime"], "UA =", first["userAgent"])
# --- 3) 组装指纹(对象键带桶名前缀) ---
obj_key = "incident-bundles/support-443/triage-proof.txt.b64"
fp = "|".join([token, first["userIdentity"], first["sourceIPAddress"], obj_key])
print("[4] 指纹 =", fp)
seal = open(f"{ROOT}/cloud/incident-bundles/support-443/triage-proof.txt.b64").read().strip()
raw = base64.b64decode(seal)
ks = hashlib.sha256(fp.encode()).digest()
print("[5] FLAG =", bytes(b ^ ks[i % 32] for i, b in enumerate(raw)).decode())
输出:
[blob] d817e53b... -> replace-with-secret-store-value
[blob] bca19a85... -> ci-cache-token-demo-7f31a9c2
[1] 泄露凭据 = ci-cache-token-demo-7f31a9c2
[2] runner 基线 = {'10.30.0.12': 200} 外部来源条数 = 3
[3] 首个未授权操作 = GetObject incident-bundles/support-443/manifest.json 2026-10-08T20:41:02Z UA = python-requests/2.31
[4] 指纹 = ci-cache-token-demo-7f31a9c2|ci-cache-publisher|198.51.100.77|incident-bundles/support-443/triage-proof.txt.b64
[5] FLAG = flag{7a3f1e9c5b2d8046af31c7e9d5062b8f}
8. 踩坑提示
| 坑 | 说明 |
|---|---|
用 userIdentity 判断 |
合法作业和攻击者共用同一个服务主体,这条维度完全失效 |
把 ListObjects(Denied) 当首个未授权操作 |
它被拒绝了,没成功。首个成功的是 GetObject |
对象键用审计里的 key 字段 |
要带桶名前缀:incident-bundles/support-443/triage-proof.txt.b64。漏了 incident-bundles/ 解出乱码 |
把 replace-with-secret-store-value 当凭据 |
那是修复后的占位符。真凭据在历史 blob 里 |
以为 .gitignore 能救命 |
.env.local 已在 .gitignore,但历史提交里那份仍在。必须 git filter-repo 重写历史 |
只看 python-requests UA |
单看 UA 不够强,要和 IP 一起看。题目要求多源 |
没有 git 命令就放弃 |
松散对象是 zlib,手工解就行;bundle 也可以用手工解析 |
9. 判定结论
这不是合法流水线作业,是对泄露凭据的未授权复用。 依据:
- 来源 IP
198.51.100.77完全偏离 runner 基线10.30.0.12(200:3 的极端比例); userAgent是python-requests/2.31,而正常 CI 是ci-sdk/4.2;- 操作序列 GetObject → GetObject → ListObjects(Denied),是"取物 → 取证明 → 横向探测"的入侵节奏,不是发布流程;
- 凭据在仓库历史中以明文存在,且
token_metadata.csv记录19:42Z后再无轮换; - 被读取的
support-443/triage-proof.txt.b64属于support-diagnostics诊断包,不是发布产物。
10. 防守建议
- 消灭历史泄密:
.env.*一旦进过库,必须git filter-repo --path .env.local --invert-paths重写历史,并强制所有人重新克隆(旧 clone 仍带着 blob); gitignore不能替代 secret scan:接入gitleaks/trufflehog在 pre-receive 和 CI 两侧扫描,拦在入库前;- 按来源 IP 收紧:服务主体的策略里加
aws:SourceIp条件,只允许 runner 段; - 轮换纪律:
token_metadata.csv里"未记录轮换"就是失败状态 —— 泄露告警触发后应立即吊销而非排期; - 区分 UA 是弱信号:对手可以伪造。真正的强判据是 mTLS 客户端证书 / workload identity(SPIFFE),让"凭据"绑定到"运行环境"而不是"一串字符串"。
11. 小结
这题的骨架是"同一个主体、不同来源",破局点是 IP + UA 的联合偏离,而不是身份字段。
审计按 (主体, IP) 统计 → 发现 198.51.100.77 的 3 次异常
↓
读那 3 条:GetObject / GetObject / ListObjects(Denied),UA = python-requests/2.31
↓
token_metadata.csv 提示"必须复核历史仓库内容" + 19:42Z 后未轮换
↓
遍历 .git 松散对象(zlib)→ 拿到 ci-cache-token-demo-7f31a9c2
↓
四元指纹(凭据|主体|IP|带桶名前缀的对象键)→ 解封
答案:
泄露凭据 ci-cache-token-demo-7f31a9c2
复用主体 ci-cache-publisher
未授权来源 IP 198.51.100.77
首个未授权操作 GetObject(2026-10-08T20:41:02Z)
受影响对象 incident-bundles/support-443/manifest.json
证明值对象键 incident-bundles/support-443/triage-proof.txt.b64
FLAG flag{7a3f1e9c5b2d8046af31c7e9d5062b8f}