| 失效的假设 | “我 import 的这个包,就是那个作者写的、我以为的那个包” |
| 所属组 | W6 · 供应链 |
| 视角转变 | 前 17 篇防"别人攻击你的代码";W6 防"你信任的代码本身有毒" |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、你的代码里,自己写的是少数
昨晚你的 CI 跑了一次 npm install,和过去三百次没有任何不同:没人改配置,没人加依赖,流水线全绿。
今天早上,你们的部署密钥出现在了一个陌生的服务器上。
打开这个项目的依赖树,你会看到几百上千个包。你亲手写的代码,可能只占最终跑起来的一小部分,其余全是别人写的、你没读过、却拥有和你代码同等权限的东西。
这就是供应链安全的前提,也是它和前面 17 篇的根本区别:
W1–W5 攻击者从【外部】攻击你的代码(注入、越权、解析)
W6 攻击者让【你信任并引入的代码本身】带毒
—— 它一旦装进来,就在你的进程里,权限和你自己的代码一样大
⭐ 关键的心理转变:一次 npm install / pip install,是在往你的系统里引入一批你没审过的代码,并授予它完全的运行权限。 攻击者要做的,不是攻破你,是让你自愿装上他的东西。下面是他常走的几条路。
二、路径一:名字上做手脚
最省事的攻击,是让你装错包——你以为装的是 A,实际装的是攻击者的 A’。
抢注(typosquatting) 注册一个和热门包只差一个字母的名字(reqeusts vs requests)
你手一抖打错,就装了它
依赖混淆(confusion) 你的私有包名,被攻击者在【公共源】上抢注,且版本号更高
依赖混淆特别隐蔽,因为你没打错任何字——是装包器自己选错了源。这件事可以用真的 pip 演一遍:造两个本地"源",各放一个同名包,然后让 pip 自己挑。
import subprocess, tempfile, os, zipfile, sys
def make_wheel(dirpath, name, version, marker):
"""造一个最小的 wheel 放进某个'源'目录"""
d = f"{name.replace('-','_')}-{version}"
p = os.path.join(dirpath, f"{d}-py3-none-any.whl")
with zipfile.ZipFile(p, "w") as z:
z.writestr(f"{d}.dist-info/METADATA",
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n")
z.writestr(f"{d}.dist-info/WHEEL",
"Wheel-Version: 1.0\nGenerator: demo\nRoot-Is-Purelib: true\nTag: py3-none-any\n")
z.writestr(f"{d}.dist-info/RECORD", "")
z.writestr(f"{name.replace('-','_')}/__init__.py", f"WHO = {marker!r}\n")
root = tempfile.mkdtemp()
internal = os.path.join(root, "internal"); os.makedirs(internal)
public = os.path.join(root, "public"); os.makedirs(public)
make_wheel(internal, "demo-internal-utils", "1.2.0", "内网源上你自己的包")
make_wheel(public, "demo-internal-utils", "99.0.0", "公共源上攻击者抢注的同名包")
out = os.path.join(root, "out")
subprocess.run([sys.executable, "-m", "pip", "download", "--no-deps", "-q",
"--no-index", "-f", internal, "-f", public, "-d", out,
"demo-internal-utils"], check=True)
print("你要装的是内网那个 1.2.0;pip 同时能看到两个源。")
print("pip 实际选走的是 :", os.listdir(out)[0])
你要装的是内网那个 1.2.0;pip 同时能看到两个源。
pip 实际选走的是 : demo_internal_utils-99.0.0-py3-none-any.whl
跳过内网源、选走公共源那个 99.0.0 的,不是我写的代码,是 pip 自己。 它没做错任何事:它的默认策略就是"从我能看到的所有源里,挑版本号最高的那个"——这在正常场景下完全合理,甚至是你想要的。
你的私有包 demo-internal-utils 1.2.0 只发布在内网源
攻击者在公共源抢注 demo-internal-utils 99.0.0 同名,版本号更高
装包器的默认策略 "所有源里挑版本号最高的" => 装了攻击者那个
攻击者怎么知道 demo-internal-utils 这个名字?它可能出现在你泄露的 package.json、一段报错栈、一个前端打包产物、一份公开的招聘 JD 里。私有包名不是秘密,而这套攻击只需要名字。
⭐ 根因是那条失效的假设:“包名 = 我想要的那个包”。 名字不是身份——一个名字可以对应多个源、多个发布者,而默认解析规则里根本没有"哪个源更可信"这个概念,只有"哪个版本号更大"。
修复是把私有包锁定到可信源(私有作用域/命名空间、或显式指定索引且禁止回落到公共源),不再跨源比版本——这又是"默认拒绝 + 白名单"(第 12/14 篇)在依赖解析上的化身。
三、路径二:占住名字之后,动里面的代码
就算你装的是"对的包",它也可能在某次更新后变了:
账号接管 维护者的账号被撞库/钓鱼攻破,攻击者发布一个带后门的新版本
维护者移交 维护者把仓库转给"热心接手的人",对方几个月后投毒
(event-stream 2018 就是这样:热心贡献者接手后植入了窃密代码)
构建产物替换 源码干净,但发布到源上的【构建产物】被替换(源码和产物不一致)
这些的共同点是:你信任的是"这个包",但"这个包"背后的人和字节会变,而你的信任是一次性给出、长期有效的。
说得刻薄一点:npm install 像是给一群素未谋面的人发了你家钥匙,并且说"进来吧,冰箱随便开"。你审过其中三五个人,剩下几百个是他们各自带来的朋友。
而"锁文件"是给每把钥匙拍了张照:下次开门的人长得不一样,门就不开。
四、为什么"装个包"本身就危险
很多人以为"装了但没 import、没调用,就没事"。错。 装包这个动作本身,在多数生态里就会执行包带来的代码。
下面这段用真的 pip 装一个包。这个包没有任何恶意函数、你也一次都不会 import 它——它只是自带了一个"构建后端",而 pip 装包时会 import 并调用那个后端:
import subprocess, tempfile, os, sys, textwrap
pkg = tempfile.mkdtemp()
proof = os.path.join(tempfile.mkdtemp(), "attacker-was-here.txt")
# 这个包的"构建后端"就是包自己带的一个 .py。装它的时候,pip 会 import
# 这个文件并调用里面的函数 —— 模块顶层那一行,就是后门站的位置。
open(os.path.join(pkg, "evil_backend.py"), "w").write(textwrap.dedent(f"""
import os, zipfile
open({proof!r}, "w").write("装包过程执行了我的代码") # <- 后门在这里
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
n = "harmless_looking-1.0-py3-none-any.whl"
with zipfile.ZipFile(os.path.join(wheel_directory, n), "w") as z:
d = "harmless_looking-1.0.dist-info"
z.writestr(d + "/METADATA",
"Metadata-Version: 2.1\\nName: harmless-looking\\nVersion: 1.0\\n")
z.writestr(d + "/WHEEL",
"Wheel-Version: 1.0\\nGenerator: x\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n")
z.writestr(d + "/RECORD", "")
z.writestr("harmless_looking/__init__.py", "")
return n
def build_sdist(sdist_directory, config_settings=None): raise NotImplementedError
def get_requires_for_build_wheel(config_settings=None): return []
"""))
open(os.path.join(pkg, "pyproject.toml"), "w").write(
'[build-system]\nrequires = []\nbackend-path = ["."]\nbuild-backend = "evil_backend"\n')
print("装之前,那个文件存在吗:", os.path.exists(proof))
r = subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--no-index",
"--target", tempfile.mkdtemp(), pkg], capture_output=True, text=True)
print("pip 装完了吗 :", r.returncode == 0)
print("装之后,那个文件存在吗:", os.path.exists(proof))
print("文件内容 :", open(proof).read().strip())
print("我 import 过这个包吗 :", False)
装之前,那个文件存在吗: False
pip 装完了吗 : True
装之后,那个文件存在吗: True
文件内容 : 装包过程执行了我的代码
我 import 过这个包吗 : False
最后两行是重点:磁盘上多出了一个文件,而我从头到尾没有 import 过这个包。 写这个文件的是 pip——它按规范 import 了包自带的构建后端并调用它,包作者的代码就在那一刻跑了起来,用的是你的权限:在你的开发机上是你,在 CI 上是那台机器的全部凭据。
⚠️ 这里那行后门写在模块顶层,也就是说它在 import 那一瞬间就执行了,build_wheel 有没有被调用都无所谓。把这个包改成"装到一半失败",文件照样会出现——“装失败了应该没事吧"这个安慰是不成立的。
各生态的入口不同,性质一样:
Python PEP 517 构建后端 / sdist 里的 setup.py
Node package.json 的 preinstall / install / postinstall 脚本
Ruby gem 的 extconf.rb 扩展构建
⚠️ 如果你的第一反应是"这也太夸张了,装个包而已”——那正是这一节要拆掉的直觉。装包在你脑子里是下载(一个读操作),而在包管理器那里是下载 + 在你机器上跑一段作者提供的代码。这两件事的风险差着数量级,而命令行上看起来一模一样。
⭐ “安装 = 执行"这件事,和第 6 篇"反序列化 = 执行"是同一台机器:一个你以为无害的动作(装包 / 还原对象),其实携带并触发了代码。 所以恶意包不需要你调用它的任何函数——install 那一下就得手了。
防线是锁文件(lockfile):把每个包的哈希钉死。下次安装时哈希对不上——不管是源被投毒还是传输被篡改——直接拒绝。这和第 16 篇"用事实核验声称”、第 9 篇"验签再信任"是同一招:用一个钉死的哈希,证明"这次装的字节 = 上次审过的字节"。
⚠️ 但注意锁文件挡的是字节变了这一种情况。第一次就把毒锁进来,或者你主动升级到了投毒版本,哈希只会忠实地锁住那个毒——见 §七①。
五、它出现过的地方
展开在事件分析里:
2018 event-stream 维护者把仓库移交后,接手者植入窃密代码
2024.03 xz-utils / CVE-2024-3094 长期社会工程取得信任,后门只出现在发布包里
六、防御
① 锁文件 + 哈希校验 钉死每个依赖的确切版本和哈希,装的必须和审过的一致
② 私有包锁定到可信源 防依赖混淆:私有作用域/命名空间,绝不跨源比版本
③ 关掉/审计安装脚本 CI 里禁用 postinstall,或只允许白名单(装 ≠ 让它随便跑)
④ 依赖最小化 + 定期审计 每个依赖都是攻击面;能不加就不加,加了要盯着
⑤ 更新要看 diff,不盲目升级 版本跳变尤其是维护者变更后,看变更再升
判断标准:你现在装的这个包的字节,和你上次审过的是同一份吗?谁能证明? 答案是"锁文件里的哈希"——没有它,你无法回答。
⭐ 回到钥匙那个比方:你没法把那几百个人一个个审一遍(③ 传递依赖就是这个),但你可以给每一把配出去的钥匙拍张照。下次来开门的人,钥匙和照片对不上就不开——这就是锁文件。它不保证这些人是好人,它只保证来的还是上次那批。
七、这些防御的边界
① 锁文件保证"一致",不保证"干净"。 它确保你每次装的都是同一份字节,但如果你第一次锁进来的就是投毒版本,哈希只会忠实地锁住那个毒。锁文件防篡改和源投毒,不替代"引入时的审查"。
② 哈希校验挡不住"合法发布的恶意更新"。 维护者账号被接管后发布的新版本,有正当的签名和新哈希——你主动升级并更新锁文件时,毒就进来了。所以 §六⑤"更新看 diff"和锁文件是互补的两道。
③ 传递依赖是盲区。 你直接依赖 10 个包,它们可能拉进来上千个间接依赖。你审得过来直接的,审不过来全部。这需要工具化的依赖扫描和 SBOM(软件物料清单),而不是人眼。
关于时效:供应链攻击的路径(抢注、混淆、接管、移交)稳定。会变的是各生态的默认防护——是否默认校验哈希、是否默认执行安装脚本、私有源的默认优先级,不同包管理器不同版本差别很大,落地要查你用的那个工具的当前默认。
八、本篇小结
你的代码里自己写的是少数,其余是依赖 —— 每个依赖都拥有和你代码同等的权限。
W6 的视角:攻击者不攻破你,而是让你【自愿装上】他的东西。
攻击路径:
名字层 抢注(typosquatting) / 依赖混淆(私有名被公共源抢发高版本)
代码层 账号接管 / 维护者移交(event-stream) / 构建产物替换
⭐ 失效的假设:"包名 = 我想要的那个包" —— 名字不是身份
⭐ "装个包" = "执行代码"(很多生态装包即跑 postinstall)
和第 6 篇"反序列化=执行"同构:无害动作携带并触发了代码
防御:锁文件+哈希(装的=审过的) · 私有包锁定可信源 · 关安装脚本 · 依赖最小化 · 升级看 diff
边界:锁文件保证"一致"不保证"干净";传递依赖是人眼盯不住的盲区。
思考题
- 依赖混淆里"你一个字都没打错",为什么还是装错了包?和 typosquatting 的"打错字"相比,哪个更难靠"小心一点"避免?
- §四 说"安装 = 执行"和第 6 篇"反序列化 = 执行"同构。把第 6 篇的"换成纯数据格式"这个根治思路,类比到依赖安装上,对应什么做法?
- 锁文件"保证一致不保证干净"(§七①)。举一个"锁文件忠实地锁住了毒"的场景。这说明锁文件应该配合哪一道防御才完整?
- 为什么"CI 里禁用 postinstall"是高价值的防御?把它和第 3 篇/第 16 篇"关掉用不到的功能"这个反复出现的主题联系起来。
- §七③ 说传递依赖是盲区。你直接审查了所有直接依赖,一个间接依赖仍被投毒。这在防御上要求什么工具化能力?为什么人眼不够?
- 维护者账号被接管后发的恶意版本"有正当签名和新哈希"。这对"签名/哈希能证明什么"意味着什么?它证明了"字节没被中途篡改",但没证明什么?
- 把 W6 的"你信任的代码本身有毒"和 W1–W5"外部攻击你的代码"对比:防御的着力点有什么根本不同?为什么说供应链是"信任的传递"问题而不是"输入校验"问题?
相关:第 6 篇:不安全反序列化 · 第 19 篇:构建与 CI · Web 安全机制篇 · 事件分析