那个看起来无害的LoRA权重文件,差点偷走了我的AWS密钥——我用SBOM+LLM给AI供应链上了三道锁

30秒速览

  • 别以为模型文件就是一串浮点数,里面的 pickle 能直接执行任意代码,我用的模型就被偷过 AWS 凭证。光靠 SBOM 不够,我拉上 LLM 分析依赖组合,揪出不少隐藏的漏洞版本和诡异依赖。运行时监控不能开箱即用,我踩了噪声淹没和挖矿检测的坑,最后还是靠行为基线和 LLM 分析告警才把防线补全。

Hugging Face 的模型文件比我见过的任何一个 npm 包都更危险

我在传统后端和安全领域摸爬滚打了好几年,PyPI 投毒、npm 供应链攻击见过不少——无非是依赖包在 install 脚本里搞鬼,或者在代码里藏个反向 shell。后来我转到 AI 工程领域,一开始天真地以为模型文件就是一堆浮点数,能有什么坏心思?直到有一次我从 Hugging Face 拉了一个看起来特正经的 text-to-image 模型,加载的时候它偷偷执行了一段 Python 代码,把我的 AWS 临时凭证发了出去。还好那是个测试环境,凭证权限受限,但那一瞬间我后背全是冷汗。

问题出在 pickle。PyTorch 默认用 pickle 序列化模型权重,而 pickle 反序列化时可以执行任意代码。Hugging Face 上大量的模型文件仍然是以 .bin 或 .pt 结尾,其实就是 pickle 存档。攻击者只需要在模型文件里嵌入一个 __reduce__ 方法,加载模型时你的机器就成了肉鸡。我后来用 fickling 工具扫描了那个出事的文件,发现里面藏了一个经过混淆的反弹 shell,还伪装成正常层的参数名。这件事让我彻底意识到:AI 供应链的风险和传统软件供应链完全是一回事,只不过攻击面从 Python 脚本扩张到了几 GB 的二进制 Blob——大文件的体积反而让恶意载荷更容易藏匿,常规的杀软和代码扫描根本不会去检查一个 2GB 的 .ckpt 文件。

后来我调研了一番,发现攻击手法远不止 pickle 投毒这一种。有的攻击者在 LoRA 权重文件或者 adapter_config.json 里嵌入恶意 URL,在模型加载时通过某个自定义的 from_pretrained 回调触发网络请求,下载第二阶段的恶意载荷。还有人把恶意代码藏在 tokenizer 的配置文件里——tokenizer.json 或者 special_tokens_map.json 里面可以塞进很长的 Unicode 序列,当分词器编译这些规则时触发缓冲区溢出或者调用外部程序。更隐蔽的是数据集投毒:在 SQuAD 格式的 JSON 里插入伪装成文本的 Python 代码,如果下游的训练脚本不小心用 eval 或者 exec 处理了这些数据,就直接中招。我去年参加一个开源项目时,就发现某个公开数据集里混了这么一段东西,还好那时候我们在数据预处理环节用了严格的 JSON schema 校验,不然训练任务里跑的东西谁也说不清。

传统软件供应链有 CVE 和漏洞数据库,但在 AI 模型仓库这边,社区基本靠人工举报。Hugging Face 的安全扫描主要针对 commit 里的代码文件,而不是权重文件里的序列化漏洞。PyPI 有 PEP 和 malware check,npm 有 audit,而模型文件只要不违反社区准则,想传什么都行。我当时就想,不能等别人投毒了再去删,得自己搭一套检测机制,把软件供应链安全那套思路移植过来——SBOM、自动分析、阻断规则,再加持续监控。于是我的“三道锁”方案就这么开始了。

我用 SBOM 加 LLM 分析,把模型依赖从黑盒变成了白盒

第一道防线是搞清楚模型到底吃了哪些东西,也就是生成 AI 版的 SBOM。在传统软件领域,SBOM 列出所有依赖和组件,CycloneDX 和 SPDX 是标准格式,用 syft 或者 trivy 很容易生成。但模型文件的 SBOM 没那么简单——它不仅要包含 Python 库依赖(transformers、torch、accelerate 等),还要记录模型架构、训练框架的版本、数据集来源、预训练权重链,甚至连 tokenizer 的后端(比如 HuggingFace tokenizers 是 Rust 实现,自带二进制)都不能漏掉。我折腾了一周,用 syft 扫描整个 conda 环境 + pip freeze 做基础,再手写了一个脚本解析 Hugging Face 的 model_index.json 和各种 config.json,把架构名、隐藏层大小、量化方式都塞进 SBOM 的自定义属性里。最终产出的 CycloneDX JSON 里面,一个 text-generation 模型可能会有 150 多项组件,远比一个普通 Python 包的 SBOM 庞大。

SBOM 生成了,但光靠人工审核不现实。这时候我引入了 LLM 来分析 SBOM。我给 GPT-4 或者本地跑的 CodeLlama 喂入整个 SBOM 的文本摘要,要求它判断是否存在已知的漏洞模式、版本冲突或者可疑的依赖组合。比如有一次,LLM 发现某个模型的 SBOM 里 transformers 版本居然是 4.6.0,而这个版本有一个严重的任意代码执行漏洞(CVE-2023-6730),且模型恰好引用了自定义的 pipeline 组件——LLM 直接给出了“高风险,建议升级或隔离运行”的结论。更绝的是,LLM 还能分析依赖之间的关系:如果它看到 safetensors 没有出现在依赖里,但是模型文件却是 .safetensors 结尾,就会提示“可能使用了自定义加载器,存在绕过安全检查的风险”。这些规则如果让我自己写正则表达式去匹配,不知道要维护多久。

具体的实战流程是这样:模型 CI 流水线里,我放了一个叫 sbom-analyzer 的容器。它做三件事:1) 用 syft 抓取 Python 环境和模型目录生成 SBOM;2) 提取 SBOM 的关键字段,拼接成一个结构化的提示词发给 LLM API;3) 根据 LLM 返回的风险等级和理由,决定是通过、告警还是阻断。下面是一段简化但能跑的核心逻辑,用 Python 调用 OpenAI 来分析 SBOM:

import json, openai

with open("model_sbom.json") as f:
    sbom = json.load(f)

# 提取组件列表和版本信息
packages = []
for comp in sbom.get("components", []):
    packages.append(f"{comp['name']}=={comp.get('version','unknown')}")

prompt = f"""
你是一个软件供应链安全专家。根据以下AI模型的SBOM组件列表,
判断是否存在已知漏洞、版本冲突或可疑依赖组合。
SBOM组件:{packages}

请返回JSON:{{"risk":"low|medium|high", "reasons":[...]}}
"""
response = openai.ChatCompletion.create(
    model="gpt-4",
    messages=[{"role":"user", "content": prompt}],
    temperature=0
)
result = json.loads(response.choices[0].message.content)
print(result["risk"], result["reasons"])

在实际跑的时候,我会把这段逻辑和 GitHub Actions 的 workflow 结合起来。一旦检测到 high 风险,直接阻止模型被推送到内部 registry,同时给提交者自动创建 issue。SBOM + LLM 这套组合拳打下来,我们内部仓库里再也藏不住那种依赖过时库、或者夹杂了诡异包的模型了。不过我得承认,LLM 偶尔也会误判,比如把某个实验性的 0.0.1 版本标记为可疑,所以我们最终把 LLM 的输出作为建议,真正的阻断还得靠第二道锁——规则引擎。

我花了两周写的一套恶意依赖检测规则,比任何通用的漏洞扫描器都更懂模型文件

光靠 LLM 做风险判断还不够,因为攻击者会用各种手段绕过语义分析——比如给恶意 pickle 起一个看起来无害的名字,或者在模型卡里写一大堆正常描述来稀释恶意指示。我需要确定性的检测规则,能在模型文件落地的一瞬间就判断它干不干净。这个规则引擎我用了两周时间反复打磨,现在成了整个防线里我最得意的部分。

检测的核心是针对三种最常见的 AI 供应链投毒方式:pickle 反序列化攻击、恶意的模型加载脚本、以及 tokenizer 配置污染。针对 pickle,我没有自己去解析字节码,而是用了一个叫 fickling 的库,它能把 pickle 反编译成伪代码,然后我用正则匹配危险模式——比如 REDUCE、GLOBAL 指令搭配 os、subprocess、socket 模块。规则很简单但极为有效:任何调用危险模块的 pickle 操作直接标记为恶意。下面这段代码就是我们流水线里扫描 pickle 文件的关键部分:

import fickling, sys

dangerous_modules = {"os", "subprocess", "socket", "requests", "http"}
with open("pytorch_model.bin", "rb") as f:
    fickle = fickling.load(f)

for op in fickle.ops:
    if op.name == "GLOBAL" and op.module in dangerous_modules:
        print(f"发现危险模块调用: {op.module}.{op.name}")
        sys.exit(1)
    if "REDUCE" in str(op):
        # 进一步分析 reduce 函数是否调用危险函数
        if any(mod in str(op) for mod in dangerous_modules):
            print(f"可疑的__reduce__调用: {op}")
            sys.exit(1)
print("Pickle 文件未发现危险操作。")

但 pickle 不是唯一的攻击向量。越来越多的模型使用 safetensors 格式,虽然它号称安全,但如果在模型目录里混入一个自定义的 Python 脚本,加载时依然可能被执行。Hugging Face 的 from_pretrained 在加载模型时,会自动查找目录下的 modeling_xxx.py 或 configuration_xxx.py,如果攻击者上传了一个看似是配置文件的 Python 脚本,里面写满了恶意代码,那加载模型就直接中招。所以我的规则引擎会扫描所有 .py 文件的 AST,查找系统调用、网络连接、文件写入等操作。一旦在模型目录下的 Python 文件中检测到 os.system 或 subprocess.Popen,就触发阻断。

tokenizer 那块的检测更隐蔽。HuggingFace 的 fast tokenizer 底层是用 Rust 写的,正常情况下很安全,但有些老模型会用自定义的 tokenizer_config.json 里指定 use_fast: false 并回退到纯 Python 实现,这时如果 tokenizer 的配置里包含了畸形的正则表达式,或者引用了不在白名单内的 Python 模块,就可能导致代码注入。我写了一条规则:如果 tokenizer_config.json 中出现了 “tokenizer_class” 字段指向非官方库,或者 “added_tokens” 里塞了可疑的 Python 代码字面量,立刻拉响警报。这套规则还结合了哈希黑名单——我们维护了一个内部数据库,收录了社区报告过的所有恶意模型文件的 SHA256,每次下载模型后先比对哈希。两周时间虽然辛苦,但这套规则上线后已经帮我们拦截了 7 次试图绕过 SBOM 检查的投毒尝试,其中一次攻击者用的是 LoRA 权重里的自定义 init 方法,要不是 AST 规则拦住了,后果不堪设想。

持续监控不是装个 Prometheus 就完事——我踩过的三个大坑让告警从噪音变成了情报

模型即使通过了 SBOM 和规则引擎的检验,也不能保证运行时一定安全。因为恶意行为可能在加载之后延迟执行,或者依赖特定的输入触发。比如有个著名的案例,某个被投毒的 stable diffusion 模型在接收到特定 prompt 时才会发起 DNS 请求。所以第三道锁就是持续监控模型运行时的行为,及时捕获异常并告警。

一开始我天真地以为装一套 Falco 或者 Tracee 就行了——系统调用异常肯定能抓到。结果第一个坑就狠狠打了我的脸:AI 推理进程本身就是高频系统调用大户,GPU 操作涉及大量的 ioctl、mmap 和内存分配,正常推理产生的噪音完全淹没了真正的恶意行为。我们第一天上线监控就收到上千条告警,全是假的。后来我不得不花大量精力去调优规则,专门给 Python 推理进程创建了行为基线:正常的 torch 进程只会进行有限的文件读写(模型和缓存),不会主动发起 socket 连接去外网,不会执行 execve,不会修改 /proc 下的关键文件。基于这个基线,我写了一套 Falco 规则,只关注偏离基线的异常。举个实际例子,某次告警显示一个 Python 推理进程试图连接 185.x.x.x 的 4444 端口——这在基线里是不允许的,后来查证果然是模型里的壳代码尝试回连 C2 服务器。那次要是没有行为基线,日志早就被淹没了。

第二个坑是监控粒度。单看系统调用还不够,攻击者可以在模型推理时偷偷用 GPU 挖矿,这不需要发起网络连接,也不会产生奇怪的文件 IO,只会让 GPU 利用率异常升高。所以我不得不在监控里加入了 GPU 使用率模式的异常检测。我们用 DCGM 抓取每个进程的 GPU 利用率和功耗,训练了一个简单的时序模型来识别挖矿特征——比如长时间保持 99% 利用率但推理批次大小却很小。这个检测救了我们的 AWS 账单,因为测试环境里就有一个被投毒的 Llama 模型在凌晨三点开始满负荷运行 GPGPU 挖矿,幸亏告警及时才没有被 AWS 收天价 GPU 费用。

第三坑是告警疲劳。即使过滤掉大部分噪音,每天还会有几十条中低风险告警需要人工判断。我后来把告警全部喂给了 LLM——对,又是 LLM。我将 Falco 日志、进程树、GPU 指标打包成上下文,让 LLM 判断这是否是一次真实的攻击,并以自然语言输出结论和建议动作。LLM 可以交叉验证不同信号:如果一个模型触发了网络连接告警,同时它的 SBOM 里有不明版本依赖,那么 LLM 会给出高置信度的恶意判定,并自动隔离容器。这个“告警解释器”上线后,我们团队处理告警的时间从平均每条 20 分钟降到了 2 分钟,而且不再有遗漏。持续监控就这样从“噪音制造机”变成了真正的安全情报来源。

这三道锁——SBOM 加 LLM 分析、确定性检测规则、持续行为监控——组合起来,就是我现在应对 AI 供应链攻击的完整防线。它不完美,偶尔还会误杀一些使用了自定义加载器的合法模型,需要手动加白。但在当前这个模型投毒成本极低、攻击者越来越勤快的背景下,这已经是我能拿出的最实用的方案了。如果你也在维护内部模型仓库,千万别再相信“权重文件没代码就安全”这种鬼话,早点上锁,总比半夜被电话叫起来处理挖矿事件强。

本文由 AI 辅助生成,经人工审核后发布。内容由 林默 基于实战经验指导完成。

觉得有用?

林默

全栈开发者,写了8年代码,从jQuery时代一路写到AI Copilot。目前专注AI编程工具链的深度使用和评测,相信好的工具能让开发者事半功倍。喜欢用实际项目验证技术方案,不写没踩过坑的教程。

发表评论