【Bug已解决】Security: Arbitrary Module Import via Malicious Adapter Config (CWE-94) 解决方案
【Bug已解决】Security: Arbitrary Module Import via Malicious Adapter Config (CWE-94) 解决方案
一、现象长什么样
PEFT 的 adapter 由两个文件组成:权重文件(adapter_model.safetensors或adapter_model.bin)和配置adapter_config.json。社区里经常直接下载别人分享的 LoRA 权重,然后一行PeftModel.from_pretrained(base, "someone_else_lora")就加载了。
安全隐患在两种情况下会暴露:
- 你加载的是一个
.bin(pickle)格式的 adapter,Python 的pickle.load在反序列化时会执行权重文件里携带的任意代码——对方只要在保存时把一段__reduce__payload 塞进.bin,你一加载,本机就执行了对方的代码; - 你加载的
adapter_config.json里peft_type或某个自定义字段指向了一个不在白名单内的模块路径,PEFT 在解析配置时会用importlib.import_module去动态导入这个名字,攻击者在配置里写"peft_type": "os.system"之类,就能触发非预期的导入甚至执行; - 配置里带有
auto_mapping或base_model_name_or_path指向一个需要trust_remote_code=True的仓库,加载链路会去拉取并执行远程modeling_*.py里的代码; - 更隐蔽的是
adapter_config.json的revision、quantization_config字段诱导你用特定参数重新实例化基座,间接执行远程代码。
这对应 CWE-94「Improper Control of Generation of Code ('Code Injection')」:不可信的 adapter 配置参与了解析/导入流程,导致任意模块导入乃至代码执行。
二、背景
要理解这个漏洞,得知道 PEFT 加载链路里哪些地方会“按字符串去 import”:
- pickle 反序列化:
torch.load默认用 pickle。pickle 的UNPickler.find_class会按配置里的类名去import,并在对象重建时调用。任何.bin文件本质上是一段可执行的 Python 对象图。这是老生常谈的供应链风险。 peft_type字符串到类的映射:PEFT 内部有一个 registry,LoraConfig→LoraModel、PrefixTuningConfig→PrefixTuningModel等。加载时它根据peft_type字符串查表并实例化对应 tuner 类。如果攻击者能注入一个不在官方表中的字符串,且代码路径对它做了importlib.import_module(peft_type),就会尝试导入任意模块。auto_mapping/base_model_name_or_path:部分配置会记录“这个 adapter 原本挂在哪个基座上”,加载器可能据此去拉基座的config.json并触发AutoConfig/AutoModel的远程代码路径(trust_remote_code)。- 自定义 tuner 注册:用户通过
PeftType注册自定义方法时,注册表里存的是类对象;若从磁盘反序列化配置后直接用字符串重建,扩展点就变成了攻击面。
结论:加载 adapter 不是“读数据”,而是一次带代码执行语义的配置解析。一旦 adapter 来自不可信来源,就必须把它当成在跑别人的脚本对待。
下面用可运行代码演示“为什么.bin危险”以及“如何安全地只认 safetensors + 白名单”。
三、根因
根因可以归纳为两条:
- 反序列化即执行:pickle 格式的权重在
torch.load时执行对象图里的代码,这是语言层特性,不是 PEFT 的 bug,但 PEFT 默认允许.bin加载,等于默认放行了这条攻击链。 - 配置驱动的动态导入缺少白名单:当加载逻辑根据
adapter_config.json里的字符串字段去import_module/ 查 registry 时,如果没有“只允许已知枚举值”的校验,攻击者构造的非法字段就会变成导入入口。
所以安全的加载必须做两件事:只用 safetensors(纯张量、无代码执行)、并且对adapter_config.json的每个字段做白名单校验,绝不按未知字符串去 import。
四、最小可运行复现
下面演示“恶意.bin在加载时执行代码”的原理(仅本地演示,不造成破坏),以及如何用 safetensors 规避。
import torch import os import pickle # ---- 演示:pickle 能在反序列化时执行代码 ---- class Evil: def __reduce__(self): # 反序列化时执行:在 /tmp 写一个标记文件,证明“代码被执行了” return (os.system, ("echo pwned > /tmp/peft_poc_marker",)) # 攻击者把这样一个对象写进 .bin torch.save({"lora_A.default.weight": torch.randn(4, 16), "_payload": Evil()}, "/tmp/evil_adapter.bin") print("加载前标记是否存在:", os.path.exists("/tmp/peft_poc_marker")) # 受害者一加载,代码就跑了 _ = torch.load("/tmp/evil_adapter.bin") print("加载后标记是否存在:", os.path.exists("/tmp/peft_poc_marker")) # 输出 True —— 说明 .bin 在 load 时已执行了攻击者的命令 # ---- 正确的做法:用 safetensors,它只存张量,不执行任何代码 ---- from safetensors.torch import save_file, load_file tensors = {"lora_A.default.weight": torch.randn(4, 16)} save_file(tensors, "/tmp/safe_adapter.safetensors") loaded = load_file("/tmp/safe_adapter.safetensors") # 不会执行任何代码 print("safetensors 加载结果键:", list(loaded.keys()))运行后你会看到:加载.bin之后/tmp/peft_poc_marker出现了,证明 pickle 路径确实会执行代码;而load_file(safetensors)只返回纯张量,没有任何副作用。
五、解决方案(第一层:最小直接修复)
最直接的三条硬规则:
修复 1:只用 safetensors,禁用 pickle 加载
import torch from safetensors.torch import load_file import os def safe_load_adapter_weights(path): bin_path = os.path.join(path, "adapter_model.bin") st_path = os.path.join(path, "adapter_model.safetensors") if os.path.exists(bin_path) and not os.path.exists(st_path): raise RuntimeError( f"检测到 pickle 格式 {bin_path},出于安全原因拒绝加载。" f"请要求对方提供 safetensors 版本。" ) return load_file(st_path) # 只走 safetensors修复 2:强制weights_only=True
即便万一是.bin,也要加上weights_only限制,禁止执行任意对象:
state = torch.load( "/tmp/evil_adapter.bin", map_location="cpu", weights_only=True, # 关键:只反序列化张量/基础类型 )注意weights_only=True在较新 PyTorch 上可用,它会拒绝Evil.__reduce__这类对象,直接抛异常。
修复 3:不要对任意来源开trust_remote_code
# 危险:加载不可信基座时开了远程代码 # model = AutoModel.from_pretrained("some/repo", trust_remote_code=True) # 安全:只用本地已审计的基座 model = AutoModel.from_pretrained("/local/audited/base")六、解决方案(第二层:结构性改进)
当团队需要频繁引入外部 adapter,应当从结构上把“不可信加载”堵死。
改进 1:adapter_config.json 白名单校验
import json import os ALLOWED_PEFT_TYPES = { "LORA", "PREFIX_TUNING", "PROMPT_TUNING", "P_TUNING", "ADALORA", "IA3", "LOHA", "LOKR", "DELTA_TUNING", "POLYTLORA", "MULTITASK_PROMPT_TUNING", "LN_TUNING", } def validate_adapter_config(path): cfg_path = os.path.join(path, "adapter_config.json") with open(cfg_path) as f: cfg = json.load(f) peft_type = cfg.get("peft_type") if peft_type not in ALLOWED_PEFT_TYPES: raise ValueError( f"adapter_config.json 的 peft_type={peft_type!r} 不在白名单," f"疑似恶意配置,已拒绝。" ) # 拒绝任何会触发远程代码的字段 for dangerous in ("trust_remote_code", "auto_mapping", "quantization_config.custom_module"): if dangerous in cfg: raise ValueError(f"配置含危险字段 {dangerous},已拒绝。") return cfg # 先校验,再加载 validate_adapter_config("/path/to/adapter") model = PeftModel.from_pretrained(base, "/path/to/adapter")改进 2:集中封装一个“只认可信来源”的加载函数
from peft import PeftModel def load_trusted_adapter(base, path, allowed_dir=None): # 1) 只允许从指定可信目录加载 if allowed_dir is not None: ap = os.path.abspath(path) if not ap.startswith(os.path.abspath(allowed_dir)): raise PermissionError(f"{path} 不在可信目录 {allowed_dir} 内") # 2) 校验配置白名单 validate_adapter_config(path) # 3) 只走 safetensors safe_load_adapter_weights(path) # 4) 最后才真正加载 return PeftModel.from_pretrained(base, path)改进 3:把二进制权重改成集中托管 + 校验和
对团队内部流通的 adapter,要求附带sha256清单,加载前比对:
import hashlib def sha256_of(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() expected = {"adapter_model.safetensors": "a1b2c3..."} # 来自可信渠道 actual = sha256_of("/path/to/adapter/adapter_model.safetensors") assert actual == expected["adapter_model.safetensors"], "校验和不匹配,文件可能被篡改"七、解决方案(第三层:断言 / CI 守护)
把“安全加载”做成可测试的不变量。
import os import json import pytest import torch ALLOWED_PEFT_TYPES = {"LORA", "PREFIX_TUNING", "PROMPT_TUNING"} def validate_adapter_config(path): with open(os.path.join(path, "adapter_config.json")) as f: cfg = json.load(f) if cfg.get("peft_type") not in ALLOWED_PEFT_TYPES: raise ValueError("peft_type 不在白名单") def test_rejects_malicious_peft_type(tmp_path): d = tmp_path / "evil" d.mkdir() (d / "adapter_config.json").write_text( json.dumps({"peft_type": "os.system", "r": 4}) ) with pytest.raises(ValueError): validate_adapter_config(str(d)) def test_rejects_pickle_only_adapter(tmp_path): d = tmp_path / "pkl" d.mkdir() torch.save({"w": torch.randn(2)}, str(d / "adapter_model.bin")) bin_p = str(d / "adapter_model.bin") assert os.path.exists(bin_p) # 我们的安全加载函数应当拒绝 from safetensors.torch import load_file with pytest.raises(RuntimeError): if os.path.exists(bin_p) and not os.path.exists( str(d / "adapter_model.safetensors")): raise RuntimeError("拒绝 pickle adapter") def test_allows_whitelisted_safetensors(tmp_path): d = tmp_path / "ok" d.mkdir() (d / "adapter_config.json").write_text( json.dumps({"peft_type": "LORA", "r": 4}) ) from safetensors.torch import save_file save_file({"lora_A.default.weight": torch.randn(4, 8)}, str(d / "adapter_model.safetensors")) validate_adapter_config(str(d)) # 应通过这三个测试分别守护“拒绝恶意 peft_type”“拒绝纯 pickle adapter”“放行白名单内的 safetensors”。
八、排查清单
引入外部 adapter 前,按序检查:
- 格式:优先 safetensors,拒绝只提供
.bin的不可信来源;万一是.bin,加载必须带weights_only=True。 - 配置白名单:打开
adapter_config.json,peft_type必须是已知枚举;任何陌生字符串都先怀疑。 - 危险字段:检查是否有
trust_remote_code、auto_mapping、自定义quantization_config等会触发远程代码的字段。 - 基座来源:
base_model_name_or_path是否指向你本地已审计的目录,而不是随手from_pretrained一个远程仓库。 - 可信目录:把外部 adapter 放进受控目录,加载函数校验路径前缀,防止加载到任意路径。
- 校验和:团队内部流通的 adapter 附
sha256清单,加载前比对,防止被中间人替换。 - 运行环境:在不具备敏感权限的沙箱/容器里加载未知 adapter,即使出问题也限定爆炸半径。
- 依赖版本:保持
torch/safetensors/peft为较新版本,确保weights_only等保护可用。
九、小结
Arbitrary Module Import via Malicious Adapter Config (CWE-94)的本质是:adapter 加载不是“读数据”,而是一次带代码执行语义的配置解析——pickle 格式的.bin在torch.load时执行对象图里的代码,adapter_config.json里的peft_type等字段会被用于动态导入。一旦 adapter 来自不可信来源,就等同于在跑别人的脚本。
最小修复是只用 safetensors、必要时weights_only=True、不开trust_remote_code;结构性改进是对adapter_config.json做白名单校验、封装只认可信目录的加载函数、用sha256校验和锁定文件;最后用 CI 测试守护“拒绝恶意 peft_type、拒绝纯 pickle、放行白名单 safetensors”。把“加载不可信 adapter = 执行不可信代码”这条链路在入口处彻底切断。
