【Bug已解决】[Security] Path traversal + malicious-model DoS via sharded-checkpoint `weight_map` 解决方案

📅 2026/8/1 3:46:46
【Bug已解决】[Security] Path traversal + malicious-model DoS via sharded-checkpoint `weight_map` 解决方案
【Bug已解决】[Security] Path traversal malicious-model DoS via sharded-checkpointweight_map解决方案一、现象长什么样加载一个分片 checkpointsafetensors / 多个 shard 文件 一个索引文件索引里有weight_map把张量名映射到分片文件名时如果索引文件被篡改或来自不可信来源可能出现两类危险路径穿越Path Traversalweight_map里某条写成{lm_head.weight: ../../../etc/secrets}或绝对路径/etc/passwd加载器照着去读/写checkpoint 目录之外的文件可能泄露宿主机文件或覆盖关键文件。恶意模型 DoSweight_map引用一个不存在的巨文件或循环引用加载器卡死在等待/扫描或一次性尝试分配超大内存 → 进程挂起 / OOM。报错可能是FileNotFoundError: ../outside/model.safetensors (企图读目录外) # 或更糟静默读了宿主机文件毫无报错或更隐蔽的「加载卡死十几分钟」DoS。本质checkpoint 索引里的weight_map文件名是被直接当作文件路径使用的但加载器没有校验这些文件名是否「安全地落在 checkpoint 目录内」。不可信 checkpoint 借此实现路径穿越或 DoS。二、背景分片 checkpoint 的结构model.safetensors.index.json索引文件含metadata和weight_map例如{ weight_map: { model.embed_tokens.weight: model-00001-of-00003.safetensors, lm_head.weight: model-00003-of-00003.safetensors } }一堆model-0000X-of-0000Y.safetensors分片文件。加载流程读索引 → 对每个张量按weight_map[name]拿到分片文件名 → 打开checkpoint_dir / 文件名读取。安全性问题就在这个「打开checkpoint_dir / 文件名」如果weight_map[name]是../evil.safetensorscheckpoint_dir / ../evil.safetensors解析后跳出目录 → 读/写目录外。如果是绝对路径/etc/passwd更是直接越界。如果引用一个 100GB 但实际不存在 / 符号链接到/dev/random的文件加载器会卡死或崩。这类问题在「加载用户上传的 / 从 HF hub 拉的不可信 checkpoint」场景尤其危险。防御性做法永远不要把weight_map的文件名当裸路径拼必须先归一化、校验落在目录内、拒绝..和绝对路径。三、根因根因是加载器信任weight_map里的文件名、直接拼接路径未做目录边界校验与异常处理三层第一层主因文件名直接拼路径无边界校验。open(checkpoint_dir / weight_map[name])没有先os.path.normpath 校验「结果仍在checkpoint_dir内」。于是../ 绝对路径直接越界。第二层无大小/存在性预检导致 DoS。加载器遇到weight_map引用的文件直接open或mmap不先检查文件是否存在、大小是否合理。恶意 checkpoint 可让加载器去读一个不存在的巨大路径卡死等待或触发超大 mmapOOM。第三层错误信息泄露 / 静默越界。路径穿越时若只是FileNotFoundError攻击者能据此探测宿主机目录结构哪些文件存在若静默成功读取目录外文件则更危险。理想是「统一拒绝 不暴露内部路径」。一句话weight_map文件名被当可信路径直接使用缺目录边界校验与大小/存在预检导致路径穿越与 DoS。四、最小可运行复现下面用纯 Python 模拟「weight_map含..路径加载器未校验即越界」的控制流不需要真实文件import os def resolve_buggy(checkpoint_dir: str, entry: str) - str: # 错误直接拼接不校验 return os.path.join(checkpoint_dir, entry) def load_buggy(checkpoint_dir: str, weight_map: dict): for name, fname in weight_map.items(): path resolve_buggy(checkpoint_dir, fname) print(f将打开: {path}) def main(): ckpt /models/safe_ckpt weight_map { embed.weight: model-00001.safetensors, lm_head.weight: ../../etc/passwd, # 恶意穿越 head.weight: /absolute/etc/shadow, # 绝对路径 } load_buggy(ckpt, weight_map) # 输出里会出现 /etc/passwd、/absolute/etc/shadow —— 越界 if __name__ __main__: main()跑出来会打印出/etc/passwd和/absolute/etc/shadow这类目录外路径——演示了不加校验时weight_map如何被用来路径穿越。五、解决方案第一层最小直接修复最省事的救火加载前对所有weight_map文件名做安全解析拒绝..和绝对路径。用os.path.normpath 边界校验import os def safe_resolve(checkpoint_dir: str, entry: str) - str: # 1) 拒绝绝对路径 if os.path.isabs(entry): raise ValueError(fweight_map 含绝对路径拒绝: {entry}) # 2) 归一化并校验仍落在 checkpoint_dir 内 base os.path.abspath(checkpoint_dir) target os.path.normpath(os.path.join(base, entry)) if not (target base or target.startswith(base os.sep)): raise ValueError(f路径穿越被拒绝: {entry} - {target}) return target def load_safe(checkpoint_dir: str, weight_map: dict, max_bytes: int 50_000_000_000): for name, fname in weight_map.items(): path safe_resolve(checkpoint_dir, fname) # 3) 存在性 大小预检防 DoS if not os.path.exists(path): raise FileNotFoundError(f分片缺失: {path}) if os.path.getsize(path) max_bytes: raise ValueError(f分片过大疑似恶意: {path} {max_bytes} 字节) # 真正打开... print(安全打开:, path)临时用这个函数包裹所有weight_map解析路径穿越和超大文件 DoS 都被挡下。六、解决方案第二层结构性改进第一层是「手动校验」第二层是「把安全解析做成加载器的默认行为且对符号链接、大小、数量都做防御」从设计上消灭信任weight_mapimport os from dataclasses import dataclass from typing import Dict dataclass class SafeLoadPolicy: checkpoint_dir: str max_shard_bytes: int 50_000_000_000 # 单分片上限 50GB max_shards: int 10_000 # 分片数量上限防海量小文件 DoS def resolve(self, entry: str) - str: base os.path.abspath(self.checkpoint_dir) if os.path.isabs(entry): raise ValueError(f拒绝绝对路径: {entry}) target os.path.normpath(os.path.join(base, entry)) # 解析符号链接后再校验一次防止软链逃逸 real_target os.path.realpath(target) if not (real_target base or real_target.startswith(base os.sep)): raise ValueError(f路径穿越被拒绝: {entry}) return real_target def validate_shard(self, path: str) - None: if not os.path.isfile(path): raise FileNotFoundError(f分片不存在: {os.path.basename(path)}) if os.path.getsize(path) self.max_shard_bytes: raise ValueError(f分片超过上限 {self.max_shard_bytes} 字节疑似恶意) # 不暴露内部绝对路径错误信息只用文件名 return None def load(self, weight_map: Dict[str, str]) - None: if len(weight_map) self.max_shards: raise ValueError(f分片数量 {len(weight_map)} 超过上限 {self.max_shards}) seen set() for name, fname in weight_map.items(): path self.resolve(fname) self.validate_shard(path) if path in seen: raise ValueError(f分片重复引用疑似循环: {fname}) seen.add(path) # 真正加载... print(安全加载:, os.path.basename(path))关键防御点解析符号链接realpath后再次校验防软链逃逸。错误信息只用文件名、不暴露宿主机绝对路径防信息泄露。数量上限 单文件大小上限 重复引用检测三重防 DoS。七、解决方案第三层断言 / CI 守护把「拒绝穿越」「拒绝绝对路径」「大小/数量上限」「不泄露路径」固化成测试import pytest def test_reject_dotdot(): p SafeLoadPolicy(checkpoint_dir/models/ckpt) with pytest.raises(ValueError): p.resolve(../../etc/passwd) def test_reject_absolute(): p SafeLoadPolicy(checkpoint_dir/models/ckpt) with pytest.raises(ValueError): p.resolve(/etc/shadow) def test_accept_relative_safe(): p SafeLoadPolicy(checkpoint_dir/models/ckpt) out p.resolve(model-00001.safetensors) assert out.startswith(os.path.abspath(/models/ckpt)) def test_reject_oversized_shard(tmp_path): big tmp_path / big.safetensors big.write_bytes(bx * 100) p SafeLoadPolicy(checkpoint_dirstr(tmp_path), max_shard_bytes10) with pytest.raises(ValueError): p.validate_shard(str(big)) def test_reject_too_many_shards(): p SafeLoadPolicy(checkpoint_dir/x, max_shards2) with pytest.raises(ValueError): p.load({fw{i}: fs{i} for i in range(5)}) def test_no_internal_path_leak(): p SafeLoadPolicy(checkpoint_dir/models/ckpt) try: p.resolve(../../secret) except ValueError as e: # 错误信息不应暴露宿主机真实绝对路径细节 assert /models/ckpt not in str(e) or 拒绝 in str(e)再加一个端到端回归加载含恶意weight_map的不可信 checkpoint 时被拒def test_untrusted_checkpoint_blocked(tmp_path): index {weight_map: {w: ../../etc/passwd}} write_index(tmp_path / index.json, index) p SafeLoadPolicy(checkpoint_dirstr(tmp_path)) with pytest.raises(ValueError): p.load(index[weight_map])八、排查清单看加载不可信 checkpoint 时是否出现目录外路径、卡死或 OOM → 是路径穿越/DoS。检查加载器是否直接os.path.join(ckpt_dir, weight_map[name])而无校验。临时救火用safe_resolve包裹所有weight_map解析拒绝../绝对路径 大小预检。确认符号链接是否被利用realpath后再校验一次。长期修复安全解析做成加载器默认行为含软链校验、大小/数量上限、信息不泄露。升级 accelerate/transformers 到合了该安全解析的版本并跑上面的「拒绝穿越」用例。仅加载来自可信源的 checkpoint不可信模型先离线扫描weight_map再决定。九、小结分片 checkpoint 的weight_map路径穿越与 DoS不是加载器功能错了而是它信任索引里的文件名、直接拼接路径未做目录边界校验与大小/存在预检不可信 checkpoint 借此越界或卡死。最小修复是safe_resolve拒绝../绝对路径 大小预检结构性修复是把安全解析做成默认行为含符号链接二次校验、大小/数量上限、错误不泄露内部路径最后用 pytest 把「拒绝穿越」「拒绝绝对路径」「上限防御」锁死。抓住「任何来自外部的配置里的路径都必须归一化后校验仍在基目录内、绝不信任」这条安全铁律所有加载不可信文件的场景都应照此设防。