ARTICLE / 安全

pip装到假文件之后:把sha256校验做成Python供应链的默认防线

10 月 15 日例行升级一个 macOS 本地推理环境时,连续两次在下载环节拦下了不该出现的文件:一次是一个伪装成 wheel 的 Dropbox 分享页假文件,一次是一段 XML 错误页——两个文件的文件名都对,唯独 sha256 对不上 PyPI 官方公布的 digest。两次都在安装前被比对拦下,没有进入环境。

这不是孤例。国内网络环境下直连 PyPI 文件源(pythonhosted.org)的链路质量不稳定是人尽皆知的,但多数人默认的假设是"下载失败会报错"——事实上更危险的情况是下载"成功"了,内容是错的。本文把这个场景的完整防线整理出来:为什么校验、怎么校验、校验失败后怎么办,蓝队视角怎么把它做成团队规范。

威胁模型:为什么"能装上"不等于"装对了"

先分清两类风险,它们的应对方式完全不同。

无意的链路劣化:代理/镜像/CDN 节点故障时,返回错误页(XML、HTML)而 HTTP 状态码仍是 200。pip 的行为取决于版本和路径——有的直接报错,有的把响应体当文件写入缓存,解包失败时才暴露。你的损失通常是一次困惑和重试,但如果错误文件被 pip 缓存,后续安装会继续命中坏缓存。

有意的供应链攻击:依赖混淆(typosquatting 包名)、构建缓存投毒、镜像站被入侵后替换文件。这类攻击里文件名、版本号、元数据全部正确,唯一对不上的是哈希。在发布者、镜像、下载链路三方里,digest 是唯一能证明"你拿到的字节就是发布者发布的字节"的凭据——它由发布流程计算,不经过任何中间节点转手。

两类风险同一个解法:安装前比对 sha256 与官方 digest。区别只在后果——前者省一次排查,后者挡一次入侵。

防线一:digest 从哪来、怎么比

PyPI 的 JSON API 是权威来源,每个版本、每个文件的 sha256 都带:

https://pypi.org/pypi/<包名>/<版本>/json

响应里 releases 字段下按文件名列出 digests.sha256。完整的校验流程:

# 1. 取官方 digest(示例:mlx 0.32.4 的 macOS arm64 wheel)
curl -s https://pypi.org/pypi/mlx/0.32.4/json | python3 -c "
import sys, json
d = json.load(sys.stdin)
for f in d['releases']['0.32.4']:
    if 'macosx' in f['filename'] and 'arm64' in f['filename']:
        print(f['filename'], f['digests']['sha256'])"

# 2. 计算本地文件哈希
shasum -a 256 mlx-0.32.4-cp312-cp312-macosx_*.whl

# 3. 人工或脚本比对,一致才安装
pip3 install mlx-0.32.4-*.whl

两个细节决定这道防线是否真的有效:

digest 必须从官方 API 取,不能从下载页面复制。 下载页面显示的哈希来自同一数据源,但复制粘贴的过程引入了人为错误面;更重要的是,自动取 digest 才能写进 CI。经 HTTP 代理下载时,先取 digest(轻量 JSON)再下大文件,两步都过代理,比对逻辑不变。

比对要在安装前,不是安装后。 pip install 成功只证明文件能解包,不证明内容正确——恶意 wheel 完全可以正常安装。手动流程里"先 download 再 install"代替直接 install,就是把校验窗口留出来。

防线二:CI 里的固化形态

个人手动校验挡不住团队的疏漏,固化到 CI 才是治本。pip 原生支持哈希校验模式:

# requirements.txt 锁定版本+哈希
mlx==0.32.4 --hash=sha256:<官方digest>
mlx-metal==0.32.4 --hash=sha256:<官方digest>

# 安装时强制校验
pip install --require-hashes -r requirements.txt

--require-hashes 开启后,任何文件的哈希与锁定值不符都直接拒绝安装——上文那两个污染下载在这个模式下进不了任何环境。配合 pip-compile --generate-hashes(pip-tools)可以从依赖清单一键生成带哈希的锁定文件。

蓝队落点:把"生产镜像构建必须走 –require-hashes"写进 CI 模板,而不是依赖开发者自觉。供应链安全的铁律是流程强制优于意识培养——意识有波动,CI 不会。

防线三:校验失败后的响应

拦下污染文件后,按各步的目的处理:

  1. 先留证据:把污染文件和官方 digest 对照存档(文件哈希、下载 URL、时间戳)——注意这一步要在清缓存之前做,缓存里的污染文件本身就是证据。如果是攻击,这是报告与复盘的原始材料;如果是链路问题,这是推动网络团队修链路的依据。
  2. 清缓存:pip 的 HTTP 缓存里如果已写入坏文件,用 pip cache purge 清掉(缓存位置 pip cache dir 可查,macOS 默认在 ~/Library/Caches/pip,Linux 在 ~/.cache/pip),否则后续安装继续命中坏缓存。
  3. 确认来源:同 URL 重试一次(区分链路抖动与持续污染);换官方源直连(绕过代理)再下一次。如果官方源能拿到正确文件,是代理链路问题;如果官方源也是坏的,立即向 PyPI 安全团队报告(security@pypi.org)——你可能撞上了正在进行的供应链事件。

附带一课:DNS 污染下的备用通道

同一天的环境里还遇到另一个问题:huggingface.co 域名解析被污染,直连失败。可用兜底方案是 DoH(DNS over HTTPS)解析真实 IP 后 curl --resolve 直连——用 DNSPod 的公共 DoH 端点解析,绕过本地 DNS 的污染应答。这个手法对"域名被污染但 IP 可达"的场景通用,可作为大文件下载 provider 的内置 fallback。

与主线同一个原则:网络层不可信时,用带外手段校验每一层——DNS 层用 DoH,传输层用 TLS 证书,内容层用 sha256。每层都有自己的权威源,不依赖上一层的行为。

蓝队清单

把全文压成四条可执行项:

  1. 立刻做:高价值环境(生产、CI 构建机)的 pip 安装切换到 --require-hashes 模式,锁定文件进版本库。
  2. 本周做:给团队的下载脚本加"先取 digest 再下文件"的封装,失败即拒装并告警。
  3. 持续做:威胁情报源订阅 PyPI/npm 的恶意包通告(PyPI 官方博客、osv.dev),出现同生态事件时优先排查自家锁定文件。
  4. 演练做:红蓝演练里加一个"镜像/代理返回污染文件"的注入场景,验证校验链路真的会拦、告警真的会响。

供应链攻击从恶意包发布到被发现下架,窗口往往不长,事后补救经常来不及。哈希校验是这条防线上不需要任何外部情报就能自查的一环,值得把它从"知道该做"变成"默认就在做"。


本文面向授权安全研究与防御建设,未经授权对任何系统实施文中技术属违法行为。PyPI digest 接口与 –require-hashes 用法来源:pypi.org 官方文档与 pypi.org/pypi/mlx/0.32.4/json(2026-10-15 核验)。污染下载拦截发生在作者自管环境,未涉及第三方系统。