| 失效的假设 | “代码审查过了、源码仓库是干净的,那发出去的东西就是干净的” |
| 所属组 | W6 · 供应链 |
| 代表事件 | SolarWinds(2020,构建被改)、Codecov(2021,CI 密钥外泄) |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、源码和产物之间,隔着一段没人盯的路
你的源码仓库很干净:每个 PR 都有人 review,Git 历史清白,扫描器全绿。
然后用户装上你发布的包,里面有个后门。源码里没有它——它是在编译那一刻被加进去的。
上一篇讲"别人的代码有毒"。这一篇讲一件更让人不安的事:就算你自己的源码完全干净,用户拿到的产物也可能带毒——因为从"源码"到"用户手里的二进制/镜像/包",中间还有一整段:拉依赖、编译、打包、签名、发布。这段路跑在你的 CI/构建系统上,而它本身就是一个高价值攻击目标。
你盯得很紧的: 源码仓库、代码审查、PR 流程
你可能没盯的: 构建机、CI 流水线、构建时拉的工具、发布用的密钥
↑ SolarWinds 和 Codecov 打的都是这一段
打个比方
你开了家餐厅,对菜谱管得极严:每一道菜的配方都要三个人签字,改一个字都留痕。
有人在你的菜里下了毒。
查下来,菜谱一个字没改过——是后厨那瓶"盐"被人换掉了。而你从来没有检查过后厨,因为在你的流程图里,后厨只是"照着菜谱做"的那一步。
⭐ 视角再抬一层:你信任链的终点不是"菜谱对不对",是"端上桌的那盘菜对不对"。 换成代码:不是"源码对不对",是**“用户运行的字节对不对”**。这两者之间的每一步,都要么被保护、要么被验证。
二、SolarWinds 式:源码干净,构建被改
攻击者不动你盯得最紧的地方(源码仓库),而是去动没人盯的那台构建机。
而"动构建机"甚至不需要改你的构建脚本。下面这段里,源码没变、构建脚本一个字符都没变,变的只是构建机 PATH 上多了一个同名工具:
import subprocess, tempfile, os, hashlib
d = tempfile.mkdtemp()
src = os.path.join(d, "app.txt"); open(src, "w").write("clean source code\n")
def build():
"""构建脚本:调用 packer 把源码打包。这个函数从头到尾没有改过。"""
return subprocess.run(["packer", src], capture_output=True, text=True).stdout
real = os.path.join(d, "realbin"); os.makedirs(real)
fake = os.path.join(d, "bin"); os.makedirs(fake)
open(os.path.join(real, "packer"), "w").write('#!/bin/sh\ncat "$1"\n')
open(os.path.join(fake, "packer"), "w").write('#!/bin/sh\ncat "$1"; echo BACKDOOR\n')
for p in (os.path.join(real, "packer"), os.path.join(fake, "packer")):
os.chmod(p, 0o755)
base = os.environ["PATH"]
sh = lambda s: hashlib.sha256(s.encode()).hexdigest()[:12]
os.environ["PATH"] = real + ":" + base # 干净的构建机
a = build()
os.environ["PATH"] = fake + ":" + real + ":" + base # 被攻陷的构建机:PATH 上多了个同名工具
b = build()
print("源码哈希(两次都一样):", sh(open(src).read()))
print("构建脚本改了吗 :", False)
print("干净构建机的产物 :", repr(a), sh(a))
print("被攻陷构建机的产物 :", repr(b), sh(b))
print("产物变了吗 :", a != b)
源码哈希(两次都一样): 45298e89cf70
构建脚本改了吗 : False
干净构建机的产物 : 'clean source code\n' 45298e89cf70
被攻陷构建机的产物 : 'clean source code\nBACKDOOR\n' d387f701b50d
产物变了吗 : True
做出这个选择的不是我的代码,是操作系统。 subprocess 按 PATH 从左往右找 packer,找到的第一个就是攻击者放的那个。构建脚本里那行 ["packer", src] 完全没变,它调用的却是另一个程序。
于是所有盯着"源码"的防御全部失效:
代码审查 过了 —— 源码确实干净
Git 历史 清白 —— 没有可疑提交
源码签名 有效 —— 签的确实是这份源码
↑ 这三样都没看【产物】
带后门的产物会被正常签名、正常分发给下游,因为签名签的是"我们构建出了这个",而不是"这个和源码一致"。
⭐ 修复方向是可复现构建(reproducible build)+ 对产物签名:让"同样的源码 + 同样的构建环境"必然产出逐字节相同的产物,这样任何人都能独立重建、比对哈希,验证"产物真的只来自这份源码"。这把信任从"相信构建机没被黑"变成了"任何人都能自己算一遍"——和整份清单反复出现的思路一致:别信任过程,验证结果。
但"逐字节相同"比你想的难
先别急着去比对哈希。用同一份源码、同一台机器、连续构建两次,看看会怎样:
import zipfile, hashlib, tempfile, os, time
src = b"print('hello')\n" # 源码一个字节都不变
def build(d, name):
p = os.path.join(d, name)
with zipfile.ZipFile(p, "w") as z:
z.writestr("app.py", src) # 打进包里的内容完全相同
return open(p, "rb").read()
d = tempfile.mkdtemp()
a = build(d, "a.zip")
time.sleep(2.2) # 只是过了两秒
b = build(d, "b.zip")
print("两次的源码相同吗 :", True)
print("两个产物的字节数相同:", len(a) == len(b))
print("两个产物的哈希相同吗:", hashlib.sha256(a).digest() == hashlib.sha256(b).digest())
print("第一处不同在第几字节:", next(i for i in range(len(a)) if a[i] != b[i]))
两次的源码相同吗 : True
两个产物的字节数相同: True
两个产物的哈希相同吗: False
第一处不同在第几字节: 10
没有攻击者,没有人动任何东西,两次产物的哈希就不一样了。
注意第二行和第四行:两个产物字节数完全相同,第一处差异在第 10 个字节——那正好是 zip 局部文件头里存修改时间的位置。原因平淡得让人生气:zip 格式会把每个文件的修改时间写进去,两次构建隔了两秒,时间戳不同,字节就不同。
⚠️ 这就是可复现构建难做的真实原因。破坏它的东西全是这类:“构建时间"“临时目录的绝对路径"“文件遍历的顺序"“并行编译的调度"“嵌进产物的构建机主机名”。每一个单独看都无害,合起来让"比对哈希"这个动作用不了。 所以可复现构建是一项需要专门投入的工程(把时间戳钉死、排序文件、路径规范化),不是一个开关——见 §六①。
三、Codecov 式:一个脚本卷走所有密钥
另一类攻击不改产物,而是把 CI 当成密钥的金矿。
CI 流水线为了干活,环境里往往塞满了密钥:部署用的云凭据、数据库密码、签名私钥、各种 API token。攻击者只要能在 CI 里跑一行自己的代码——改一个被广泛使用的构建脚本、投毒一个构建期依赖(第 18 篇那个 pip demo,跑的正是这条路)——就能把整个环境的密钥一次性外传。
注意这里没有"漏洞利用"这一步。CI 本来就是一台会自动执行别人代码的机器,这是它的功能。所以问题不是"怎么防止代码在 CI 上跑”,而是:
它跑起来的那一刻,手上有多少东西?
两种 CI 的爆炸半径差一个量级:
| 令牌能动的资源 | 环境里能偷到的密钥 | 一次被攻陷的后果 | |
|---|---|---|---|
| 过度授权 | 读写所有仓库 · 部署生产 · 云管理员 | 生产 DB 密码 · 云密钥 · 签名私钥 | 全盘沦陷 |
| 最小权限 | 只读当前仓库 | 无(OIDC 短期换取,用完即弃) | 仅限这个仓库 |
⚠️ 这是"最小权限”(贯穿全清单)在 CI 上最该被认真对待的一次——因为 CI 几乎是唯一一个"既能碰源码、又能碰生产、还握着所有密钥"的地方。它的权限有多大,一次失误的代价就有多大。
四、它出现过的地方
展开在事件分析里:
2020.12 SolarWinds 构建系统被攻陷,恶意代码注入到正常签名的产物中
2021.04 Codecov bash uploader 被改,CI 环境里的凭据被持续外传
五、防御
⭐ 回到那家餐厅:前面几条在盯后厨(隔离构建环境、固定工具链),后面几条在验那盘菜(可复现构建、对产物签名)。
而"可复现构建"这一条的分量值得再说一句:它的意思是任何人都能照着同一份菜谱、在自己的厨房做一遍,然后比对是不是同一盘菜。这就把"相信这家餐厅的后厨没问题"换成了"谁都能自己验一遍”——信任被换成了可验证性,这正是整份清单反复在做的那件事。
验证产物(对 SolarWinds 式)
① 可复现构建 同源码+同环境 => 逐字节相同的产物,可独立验证
② 对最终产物签名并验证 验的是"用户运行的字节",不只是源码
③ 隔离、加固构建环境 构建机当作生产资产保护;构建输入(工具链)也要固定
保护 CI 密钥(对 Codecov 式)
④ CI 令牌最小权限 只读当前仓库;按需授权,别给"所有仓库+部署+管理员"
⑤ 短期凭据代替长期密钥 OIDC 动态换取、用完即弃,别把长期密钥塞进 CI 环境
⑥ 限制 CI 里能跑什么 第三方 action/脚本钉版本、审来源(构建期依赖也是依赖,见第18篇)
判断标准两句:用户运行的产物,你能证明它只来自那份审过的源码吗?(可复现构建 + 产物签名)你的 CI 被攻陷一次,攻击者能拿到多少、能动多少?(最小权限 + 短期凭据)
六、这些防御的边界
① 可复现构建很难做到彻底。 时间戳、文件顺序、绝对路径、依赖版本漂移都会破坏可复现性。它是个持续工程,不是开关。但即使只做到"部分可复现 + 产物签名”,也比"只签源码"强得多。
② 最小权限降低损失,不阻止入侵。 CI 令牌收窄了,攻击者进来仍能干"当前仓库权限内"的坏事(比如投毒这个仓库的产物)。最小权限是限制爆炸半径,要和 ①②③ 的"验证产物"一起用。
③ 构建期依赖也是供应链。 你在 CI 里 pip install 的构建工具、拉取的第三方 action,和运行时依赖一样能投毒(第 18 篇)。“保护构建"必须把"构建时拉进来的东西"也算上,否则等于给前门上锁、后门大开。
关于时效:构建/CI 攻击的机制稳定。会变的是工具生态的默认安全能力——OIDC 短期凭据、构建溯源(如 provenance/SLSA 类框架)、默认权限收窄,都在快速演进。落地时用你的 CI 平台当前提供的最新能力,本篇不钉具体工具。
七、本篇小结
源码干净 ≠ 产物干净。源码到用户之间隔着构建和 CI,这一段本身就是攻击面。
信任链的终点是"用户运行的字节",不是"源码对不对"。
⭐ SolarWinds 式:攻陷构建机,源码不动、编译时注入后门
=> 只审源码/只签源码全部失效(它们没看产物)
修复:可复现构建 + 对产物签名 —— 别信任过程,验证结果
⭐ Codecov 式:CI 里跑一行恶意代码,卷走环境里的所有密钥
修复:CI 令牌最小权限 + 短期凭据(OIDC),把爆炸半径夹小
判断:产物能证明只来自审过的源码吗?CI 被攻陷一次能拿到/能动多少?
边界:可复现构建难彻底但值得;最小权限限损不防入侵;构建期依赖也算供应链。
思考题
- §二 的两个哈希"源码相同、产物不同”。为什么"对源码签名"完全防不住这种攻击?可复现构建把"信任"从什么变成了什么?
- 为什么说 CI 是"唯一一个既能碰源码、又能碰生产、还握着所有密钥的地方"?这个特殊地位如何决定了它的权限必须最小化?
- §三 对比了两种 CI 的"爆炸半径"。同样是被攻陷,为什么"最小权限 + 短期凭据"能把损失夹小一个量级?它防住的是"入侵"还是"入侵后的扩散"?
- §五③ 说"构建期依赖也是供应链"。构造一个场景:你的运行时依赖都审得很干净,但 CI 里拉的一个构建工具被投毒,最终产物带了毒。这和第 18 篇是什么关系?
- “别信任过程,验证结果"这句话,在这一篇是"验产物哈希”,在第 6 篇是"先验签再反序列化",在第 16 篇是"用实收长度核验声称"。它们是不是同一条原则?共同拒绝了什么?
- 可复现构建要求"同源码同环境产出逐字节相同"。为什么时间戳、文件顺序会破坏它?这说明"构建"其实比我们以为的更不确定——这对验证意味着什么?
- 把 SolarWinds 式(改产物)和 Codecov 式(偷密钥)对比:一个破坏完整性、一个破坏机密性。为什么它们都归在"构建与 CI"下?CI 这个位置同时暴露了这两类风险的根源是什么?
相关:第 18 篇:依赖投毒 · 第 6 篇:不安全反序列化 · 第 20 篇:配置与暴露 · Web 安全机制篇 · 事件分析