开放权重,隐藏载荷:如何安全下载和加载 LLM 模型而不被入侵
本周 HN 上有两条新闻挨得很近:一条是 Mistral 公布了 Mistral Large 4 的权重,另一条是 Anthropic 被入侵并遭利用来传播远程访问木马。两件事放在一起看,结论很清楚:分发渠道越是被信任,越值得攻击者下手。现在开发者几乎每周都要往本机下载几十 GB 的模型,无脑跑一下 from_pretrained() 就完事。但一个模型文件并不只是一堆数字,取决于格式,它完全可能携带在你加载那一刻就执行的代码。本文分享我目前在用的流程,用来更安全地下载和加载开放权重模型,适用于 Hugging Face、Ollama 和 llama.cpp。
为什么模型文件能执行代码?
老熟人就是 pickle。PyTorch 旧的 .bin、.pt、.pth 格式,本质上是一个包含 pickle 数据的 zip 文件。Pickle 不只保存数据,还保存“如何重建对象”的指令,而这些指令可以是“用这些参数调用 os.system”。下面是个小例子,你可以自己验证(在虚拟机或一次性容器里跑):
import pickle, os
class Payload:
def __reduce__(self):
# 反序列化时,Python 会调用 os.system("id > /tmp/pwned")
return (os.system, ("id > /tmp/pwned",))
with open("model.pkl", "wb") as f:
pickle.dump({"weights": [0.1, 0.2], "x": Payload()}, f)
# 受害者这边:只要加载就中招
pickle.load(open("model.pkl", "rb"))
print(open("/tmp/pwned").read())
不用调用任何函数,不用 eval,光一条加载命令就够了。Hugging Face 上曾多次发现仓库里藏有这类载荷,有的开反弹 shell,有的读 ~/.aws/credentials。
除了 pickle,还有两条常被忽略的路径:
trust_remote_code=True:transformers 会从仓库下载modeling_*.py并导入执行。那是真正的 Python 代码,对你的机器有完全控制权。- 仓库被劫持:作者账号被黑,main 分支上的文件被替换。你下载的仓库名没错、文件名没错,但内容跟昨天不一样了。Anthropic 那次就是这个套路:官方渠道,伪造载荷。
flowchart TD
A[从 Hub 下载模型] --> B{格式?}
graph TD
A[下载模型] --> B{文件格式}
B -->|.bin / .pt / .pkl| C[Pickle:加载时可能执行代码]
B -->|.safetensors / .gguf| D[仅张量 + 元数据]
A --> E{trust_remote_code?}
E -->|True| F[从仓库导入 .py 文件]
C --> G[开发机 / GPU 服务器上的 RCE]
F --> G
更安全,但仍需验证哈希。
第一步:优先选用 safetensors 和 GGUF
safetensors(Hugging Face 出品)只包含一个 JSON header 和一块原始的 tensor 字节。它没有对象构建机制,所以根本没有地方塞代码。llama.cpp 的 GGUF 也一样:先是 header,接着是键值对形式的元数据,然后是 tensor。
现在几乎所有大模型都有 safetensors 版本,社区转 GGUF 也很快。我的规则是:一个仓库里只有 .bin 却没有 .safetensors,就该当成可疑对象,新发布的模型尤其如此。
如果实在非加载旧 PyTorch 文件不可,就用 weights_only=True。从 PyTorch 2.6 起,这已经是 torch.load 的默认值,但不少旧代码库为了「消除报错」还把它覆盖成 False。在你的仓库里 grep 一下看看:
# 找出那些把保护机制关掉的地方
grep -rnE "weights_only\s*=\s*False|trust_remote_code\s*=\s*True" --include="*.py" .
# 只下 safetensors + config,彻底不碰 pickle 文件
hf download mistralai/SomeModel \
--include "*.safetensors" "*.json" "tokenizer*" \
--local-dir ./models/somemodel
hf 是 huggingface_hub 的新 CLI(用来替代 huggingface-cli,大约从 0.34 版起提供)。下载时就用 --include 过滤,是让 .bin 文件根本不落到磁盘上的最简单办法。
第二步:固定 revision 并校验哈希
按 main 分支名下载,就跟 pip install 不锁版本一样:今天一个样,明天一个样。Hub 上每个 commit 都有一个 SHA,每个 LFS 文件都有公开的 SHA256。这两者都要固定。
# verify_model.py — 固定 commit、下载、拿 SHA256 跟 lockfile 比对
import hashlib, json, sys
from pathlib import Path
from huggingface_hub import HfApi, snapshot_download
REPO = "mistralai/SomeModel"
REVISION = "a1b2c3d4e5f6..." # 已审查过的 commit SHA,千万不要用 "main"
LOCK = Path("model.lock.json")
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def build_lock():
info = HfApi().model_info(REPO, revision=REVISION, files_metadata=True)
lock = {s.rfilename: s.lfs.sha256 for s in info.siblings if s.lfs}
LOCK.write_text(json.
dumps(lock, indent=2))
print(f"Đã ghi {len(lock)} hash vào {LOCK}")
def verify():
lock = json.loads(LOCK.read_text())
local = Path(snapshot_download(REPO, revision=REVISION,
allow_patterns=["*.safetensors", "*.json"]))
bad = [n for n, h in lock.items()
if (local / n).exists() and sha256(local / n) != h]
if bad:
sys.exit(f"HASH MISMATCH: {bad}")
print("OK: tất cả file khớp lockfile")
if __name__ == "__main__":
build_lock() if "--lock" in sys.argv else verify()
把 model.lock.json 提交进仓库,作用类似前端的 package-lock.json。在 CI 里,或者在构建 GPU 服务器镜像时,启动推理服务前先跑一遍 python verify_model.py。要升级模型就改 REVISION,执行 --lock,审一遍 diff 再合并。
从其他渠道下载的 GGUF 文件,至少把 sha256sum 和作者公布的值对一下。
第 3 步:首次加载前先扫描,并在隔离环境里转换
有些场景绕不开 pickle 文件,比如老的内部 checkpoint、还没出 safetensors 版的研究模型。那就先扫描,再在隔离环境里一次性转换:
pip install picklescan fickling
# picklescan:列出可疑的全局导入(os.system、subprocess、builtins.exec 等)
picklescan --path ./models/legacy/pytorch_model.bin
# fickling:分析更深入,还能把 pickle 反编译成伪 Python
fickling --check-safety ./models/legacy/pytorch_model.bin
# 在没有网络、不挂载 home 目录的容器里转换
docker run --rm --network none \
-v "$PWD/models/legacy:/in:ro" -v "$PWD/models/clean:/out" \
pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime \
python -c "import torch; from safetensors.torch import save_file; \
sd = torch.load('/in/pytorch_model.bin', weights_only=True); \
save_file({k: v.contiguous() for k, v in sd.items()}, '/out/model.safetensors')"
--network none 让 payload 即便执行了也回连不了外部;/in 挂成只读,它改不了原始文件。走完这一步,团队以后只用 .safetensors 版本。
sequenceDiagram
sequenceDiagram
participant Dev
participant Hub as Hugging Face Hub
participant CI
participant GPU as 推理服务器
Dev->>Hub: model_info(revision=SHA)
Hub-->>Dev: 文件列表 + SHA256
Dev->>CI: 提交 model.lock.json
CI->>Hub: snapshot_download(revision=SHA, *.safetensors)
CI->>CI: 对照 lockfile 校验 SHA256
CI->>GPU: 部署已通过校验的镜像
GPU->>GPU: 加载,trust_remote_code=False
关于 trust_remote_code:如果模型用了新架构,先读一遍 modeling_*.py,锁定 revision;或者等几周,让该架构进入 transformers 官方版本。大模型的架构通常很快就会得到上游支持。
结论
开放权重是好事,但「从官方渠道下载」并不等于安全。Anthropic 这次事件说明,官方渠道本身也可能被变成投毒通道。
我给所有加载模型的项目都用这份检查清单:
- 只用
.safetensors或.gguf,用hf download --include过滤,让 pickle 文件根本不落到磁盘上 - 没读懂文件之前,绝不用
weights_only=False - 升级到 PyTorch >= 2.6,让默认设置就是安全的
- 固定 commit SHA,把 SHA256 写进 lockfile,部署前在 CI 里校验
- 默认
trust_remote_code=False,只有读过代码并锁定 revision 之后才打开 - 旧的 pickle 文件用
picklescan/fickling扫描,在--network none的容器里转换,然后彻底删掉原文件 - 把前面的 grep 命令加进 pre-commit hook,防止有人关掉保护后混进代码库
整个配置大概花一个下午,换来的是:GPU 服务器——那里通常放着云端 token 和客户数据——不会因为一次 from_pretrained() 就变成后门。
本周 HN 上有两条挨得很近的新闻:一边是 Mistral 公布了 Mistral Large 4 的权重,另一边是 Anthropic 被入侵、并被利用来传播远程访问木马(RAT)。两条放在一起看,结论很清楚:渠道越是被信任,越值得攻击者盯上。
开发者现在几乎每周都要拉几十 GB 的模型到本地,跑 from_pretrained() 时什么都不想。但一个模型文件不只是「一堆数字」。取决于格式,它可能含有在你加载那一刻就执行的代码。