Hermes Attack 精读:如何从 PCIe 明文流量里偷走一个 DNN 模型

在 CPU 与 GPU 之间那条没有加密的 PCIe 总线上抓包,从 GPU 命令与数据搬运里反推出受害者 DNN 的结构、超参数和全部权重,重建出的模型推理精度与原模型完全一致。

论文:Hermes Attack: Steal DNN Models with Lossless Inference Accuracy(USENIX Security 2021)

作者:Yuankun Zhu(UT Dallas)、Yueqiang Cheng(Baidu Security)、Husheng Zhou(VMware)、Yantao Lu(Syracuse University)

模型文件放在加密盘里、主机开了 SGX、GPU 也独占不共享,模型文件一个字节都没泄露。但模型仍然可能被整个偷走——Hermes Attack 盯上的不是模型文件,而是模型在 GPU 上跑推理时留下的 PCIe 通信痕迹。

📚 基本概念速读

名称 定义 记忆点
PCIe 主机与 GPU、SSD、网卡之间的高速串行总线 攻击面:明文,可被物理抓包
Root Complex CPU 或 SoC 内部的 PCIe 根模块 链路的起点,不是独立芯片或插卡
TLP Transaction Layer Packet,事务层包 PCIe 上数据传输的基本单位
Tag TLP 头部字段,请求与回应的配对凭据 用它把 Request 和 Completion 对上
D command Data movement command,数据搬运命令 权重就是从它的 payload 里捞出来的
K command Kernel launch command,内核启动命令 决定 GPU 此刻在算哪一层
KD2D 设备内同步拷贝(也是一条 K command) 参数被复制了一份,追踪必须往回走
Kernel 编译成 GPU 机器码、在 GPU 上运行的计算程序 每一类 DNN 层对应固定的 kernel
Architecture 层的数量、类型、维度与连接拓扑 由数据流图恢复
Hyper-parameters 推理时用到的 kernel size、stride、filters 等 从 K command 参数或 kernel 存在性恢复
Parameters 训练得到的 weight 与 bias 由 D / KD2D 的数据流恢复

🎯 起点:模型文件没丢,模型为什么还是丢了

先把场景摆出来。一家 AI 公司把训练好的 DNN 打包进一台带 GPU 的设备,卖给第三方客户:智能摄像头、车载域控制器、工业质检机,都是这个形态。模型是公司的核心资产,只给使用权,按年收许可费。

站在模型提供方角度,能做的防护基本都做了:

  • 主机侧:Secure Boot、全盘加密,模型文件本身再用 TEE(比如 Intel SGX)护起来;
  • GPU 侧:不做多租户共享,整块卡只跑自己的任务;
  • 网络侧:设备不出网,模型文件不会被外传。

这三条都成立,模型文件确实拿不到。但攻击者不需要拿文件——他就在设备现场,可以打开机箱,在 CPU 和 GPU 之间的 PCIe 链路上插一个协议分析仪,把这条链路上跑过的每一个包记下来。

于是问题变成:

只看 CPU 与 GPU 之间来回的二进制流量,能不能把整个神经网络扒出来?

这个问题的答案,就是 Hermes Attack。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──────────────────────┐       ┌───────────────────────────┐
│ Host │ │ GPU (Device) │
│ │ │ │
│ 1. Secure Boot │ │ DNN Model │ ← 受害者模型与参数在这里
│ 2. Disk Encryption │ │ Weight / Bias │ ← 参数明文进出显存
│ 3. TEE (SGX) │ │ │
│ │ │ no multi-tenant sharing │
└──────────┬───────────┘ └─────────────┬─────────────┘
│ │
└────────────────┬────────────────┘
↓
════════════ PCIe ════════════ ← 未加密,攻击者可在此抓包
↓
┌────────────┴─────────────┐
│ PCIe Protocol Analyzer │
│ │
│ Attacker │ ← 插入链路,记录每个包
└──────────────────────────┘

论文的威胁模型就建立在这条链路上:主机和 GPU 各自都被保护得很好,唯独中间那条 PCIe 是明文,而攻击者能物理接触到它。注意这里没有假设任何软件漏洞——不需要提权,不需要 rooting,不需要攻破 SGX。攻击者拿到的只是一段总线上的流量录音。

🧭 先分清三个词:结构、超参数、参数

要偷一个 DNN,得先说清楚偷的是什么。论文把模型拆成三块:

内容 论文里的叫法 具体指什么
结构 Architecture 层的数量、每层的类型与维度、层之间的连接拓扑
超参数 Hyper-parameters 推理时真正用到的那些配置,如 kernel size、stride、filters、pool size、padding
参数 Parameters 训练得到的 weight 与 bias

这里有个容易读岔的地方:论文 Background 一节把 learning rate、momentum、epochs、batch size 也算进 hyper-parameters(这是机器学习里的通用定义),但攻击目标里的 hyper-parameters 指的是推理期被用到的那一类,也就是 stride、kernel size、filters。两者不同,前者拿不到,后者能拿到——论文第 3.4.2 节明确说,控制训练过程的超参数无法从推理期流量里恢复。读完后面会更清楚,这个区分决定了 Hermes 的能力边界。

另一件必须强调的事:论文里的 parameters 专指模型参数(权重与偏置),不是函数参数;kernel 专指 GPU kernel,不是操作系统内核。后文沿用同样的约定。

🏗️ 模型是怎么跑到 GPU 上的

一个 DNN 从框架代码到 GPU 硬件,中间要穿过四层软件。这条路径决定了模型信息最后会以什么形式出现在总线上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────┐
│ DNN Framework │ ← 模型与训练/推理脚本都在这一层,调用 GPU API
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ CUDA / OpenCL Runtime │ ← 把 API 调用翻译成一条条 GPU Command
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ Runtime Backend │ ← 通过 ioctl 把命令交给驱动
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ Device Driver │ ← 把命令提交给 GPU 硬件
└────────────┬────────────┘
↓
══════════ PCIe ═══════════ ← 明文总线,Hermes 的监听点
↓
┌─────────────────────────┐
│ GPU │
│ │
│ runs the DNN kernels │ ← 模型执行发生在这里
└─────────────────────────┘

关键在最后一步:高层计算任务最终都变成了底层的 PCIe 包。反过来说,PCIe 包是这台机器上唯一一份能被外人在旁路完整看到的、与模型执行相关的数据。

对攻击者来说,这里有个天然的不对称:

角色 能看到什么
CPU 侧软件 知道层类型(Conv2D、ReLU、Softmax),但拿不到(攻击者视角下)
PCIe 总线 只有命令与地址,层类型的语义在 CPU 侧就已经被消掉了
GPU 侧 只认 kernel 二进制和地址,不知道自己在跑什么网络

Hermes 要做的,就是把这三份信息缝起来。

🚧 三个难点

论文自己列了三条挑战,后面的设计全是为了对付它们:

  1. 闭源与未文档化的数据结构:CUDA runtime、驱动、GPU 内部全是闭源,数据搬运和 kernel 启动涉及的关键结构没有文档;GPU kernel 与 command 还各用一套指令集,反汇编不现实。破解靠的是大量逆向工程。
  2. 语义在 PCIe 层丢失:层类型不会出现在流量里,因为它是在 CPU 侧解析的。少了这个信息,模型只能恢复一半。
  3. 包的噪声与乱序:一次单图推理就能产生上百万个包,论文实测只有 1%~2% 的包对重建有用;而 CUDA 是异步流水线,回应包的到达顺序与它在 DNN 语义上的顺序并不一致。越新的 GPU(例如 RTX 2080 Ti)在这点上越麻烦,因为引入了统一显存与主机内存的新特性。

📦 第一层:从 PCIe 报文到 GPU Command

🔗 一次读操作涉及哪两个包

PCIe 上的通信都以 TLP 为单位。CPU 读外设内存时,链路上会出现两个包:

  • 读请求包(Memory Read Request):CPU 发出,指明要读哪个地址,Header 里带 Tag;
  • 回应包(Completion with Data):外设返回,payload 里就是数据,Header 里的 Tag 与请求包相同。

Tag 是配对的唯一依据。写操作则简单一些:一个 Memory Write 包,Header 加 payload 就完事。

论文在流量处理阶段做了一次简化:上游(GPU 到 CPU)只保留 GPU 的读请求包,下游(CPU 到 GPU)只保留回应包,其余类型先丢掉。然后再把同一个 Tag 的请求包与回应包合并成一条 data packet:

1
2
3
4
Request (Tag=100, Addr=0x5000)
Completion (Tag=100, Data=...)
↓ 按 Tag 合并
Data Packet = 地址 0x5000 + 该地址上的数据

这样每条 data packet 自带了地址和数据,后面的分析只需要面对这一种结构。

🧵 为什么要 Merge 和 Sort

PCIe 协议不保证多个连续请求的回应顺序,一次读请求的结果还可能被拆进多个回应包里。所以抓到的原始顺序不能直接用,Hermes 用两步修正:

合并(Merge):按 Tag 找到请求包对应的所有回应包,补全 Tag 字段,相邻的同 Tag 包拼成一个包,payload 顺次连接。

排序(Sort):

  1. 默认所有包按 packet ID 从小到大排列;
  2. 取一个请求包记为 P1,向后找第一个 Tag 与它相同的包,记为 P2;
  3. 在与 P2 相同 packet ID 的 data packet 里找到 P3;
  4. 把 P1 的 packet ID 写进 P3 作为排序键;
  5. 重复直到所有 data packet 都有排序键,最后按排序键重排。

换句话说:

1
2
3
sort key(P3) = packet_id(P1)
│ └─ 请求包发出的先后,对应命令提交的先后
└──── 落到合并后的数据包上,成为重排依据

这一步是后面所有分析的地基:排序键还原出的是命令提交的顺序,也就是 DNN 的层序——乱的是包到达的先后,不是 GPU 执行本身。

定序为什么用请求包而不是回应包:乱序发生在生成端,主机侧对多个正在飞行中的读请求,完成顺序不受 PCIe 约束;而同一方向上的请求包在链路上不会重排,所以请求包的先后可靠地对应 GPU 索取数据的先后。

⌨️ D Command 与 K Command

GPU 命令也由两部分组成:Header 说明这是什么命令、数据有多长;Data 是命令携带的值。论文把所有命令归成两类,只关心这两类:

命令 含义 论文中的变体 用途
D command 数据搬运 D、KD2D、Dasyn 传输入、传中间结果,也传权重
K command 启动 GPU kernel K、KD2D 决定 GPU 在跑哪个 kernel,也就是哪一层

注意 KD2D 两边都沾:它是启动一个做设备内拷贝的 kernel,所以既是 K command 的一种,行为上又是数据搬运。

🔍 离线摸清 Command Header

要按类型过滤命令,就得知道 Header 长什么样,而 GPU 的命令格式没有文档。论文的办法是离线构造实验做差分,思路很朴素:只改一个变量,看哪个字段跟着变。

  • 摸 D command:反复在预分配的显存和主存之间搬运构造好的数据,对抓到的包做模式匹配。
  • 摸 K command:反复用准备好的参数启动 kernel,比对包的变化。

最后得到一条命令的 Header 是 9 个 DW(Double Word,4 字节),其中三个字段最有用:

1
2
3
4
5
6
7
8
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ DW1 │ DW2 │ DW3 │ DW4 │ DW5 │ DW6 │ DW7 │ DW8 │ DW9 │
│ │ │ addr │ │ size │ │ │ │ type │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
DW3 = GPU memory address
DW5 = data size in bytes
DW9 = command type
6D204860 表示 Kepler 上的 kernel launch
  • DW3:这条命令在 GPU 内存里的地址。
  • DW5:数据段的字节数。
  • DW9:命令类型,例如 6D204860 是 Kepler 架构上 kernel launch 的签名(论文把签名对应的位级语义引向了 Envytools 的逆向工作)。

论文还做了穷举测试,确认 Header 结构在不同 GPU 与整机组合上稳定可用,并把结果存进 profiling 数据库,后续分析直接查表。

🧹 噪声过滤:物理地址不连续怎么办

按 Header 过滤出 D / K command 之后,每条命令的 payload 仍然混着噪声。论文把它们分成两类:

  • 外部噪声(external noise):不属于当前命令的包。一条数据量大的命令要几千个包才传完,中间很容易插进别的命令;更麻烦的是命令 Header 自己可能被拆进两个包,噪声包就卡在中间。
  • 内部噪声(internal noise):包内某个固定 DW 位置上的干扰字段。论文把这类模式全部列了出来,抽取 payload 时直接剔除。

最直觉的过滤方法是检查地址连续性——同一命令的数据在内存里通常连续。但这招不完全可靠:包里的地址是物理地址,CUDA 程序用的虚拟连续空间可能被切成多段物理内存,相邻两个包地址不连续是正常的;反过来,一条命令的数据太大时也会跨越多个物理块。

于是论文引入了一个启发式阈值 MAX_SCAN_DISTANCE:

1
2
3
4
5
6
遇到地址断点
↓ 在 MAX_SCAN_DISTANCE 范围内向后扫描
找到与前一段地址接得上的包 → 认定它才是相邻包,丢弃中间扫过的包
否则 → 把这个断点当作物理地址分段,继续累积 payload
↓
累积字节数 == Header 里声明的数据大小 → 本条命令抽取完毕

用 Header 里声明的大小作为终止条件,这一条很关键:它让抽取有了明确的对错判据,而不是靠猜。

论文用 Figure 8 和 Figure 9 把这两种情况摆在一起对照,两张图用的是同一段地址(相邻包地址差 0x80,即 128 字节):

对照项 Figure 8:假断点 Figure 9:真断点
地址序列 292B7F00 → 188B6700 → 292B7F80 292B7F00…292BB880 → 188B6700 → 188B6780
断点后能否找到接得上的包 能:0x292B7F00 + 0x80 = 0x292B7F80 不能:188B6700 与前面任何一个包都不接
中间那个包的性质 外部噪声,丢弃 命令数据的下一个物理段,保留
论文图注 该包标注为 Noise 该断点标注为 Gap,且 188B6700 → 188B6780 内部连续

两张图最关键的一点:同一个地址断点,含义可以完全相反。 Figure 8 里它是假象,噪声包夹在中间,跳过去就好;Figure 9 里它是真的物理分段,必须收下。只看地址不连续这一个信号区分不了,所以才要再往后扫一段来确认。

🧩 第二层:从 GPU Kernel 到 DNN Layer

层类型在 CPU 侧就被解析掉了,PCIe 上根本没有这个信息。Hermes 靠一个关键观察把语义补回来:每一类 DNN 层,最终都对应着一批固定的 GPU kernel 二进制。

🧱 Kernel 是什么,怎么进到 GPU

nvcc 把 kernel 编译成硬件相关的可执行代码(CUDA Fatbin),嵌在宿主可执行文件的专用 section 里(例如 libcudnn.so 的 nv_fatbin)。运行到某次调用时,这段二进制被搬到显存里,再被启动执行。

这里有一个很好用的性质:大家都倾向用厂商高度优化的标准库(cuDNN、Eigen),所以kernel 二进制相对稳定。比如卷积层调用 cudnn::detail::implicit_convolve_sgemm,这段二进制就躺在 libcudnn.so 里。二进制稳定,意味着离线阶段建立的那张对照表可以反复使用。

🔎 两条观察,一个反向定位

论文给出两条观察:

观察 1:每个 kernel 是通过一条 D command 加载到 GPU 上的,这条 D command 的数据段就是 kernel 二进制。

观察 2:每条 K command 里都有一个地址,指向将要启动的 kernel 二进制。

顺着这两条就能把 kernel 二进制捞出来,方法是反着走:

1
2
3
4
5
6
7
8
正向(GPU 实际执行):
D command 把 kernel 二进制放到显存 405ECF01
↓
K command 带着地址 405ECF01 启动 kernel

反向(Hermes 的分析):
1. 在 K command 的数据段里按固定偏移取出地址 405ECF01
2. 拿这个地址回到 D command 的数据段,dump 出 kernel 二进制

把所有 K command 走一遍,就得到一串按启动顺序排列的 kernel 二进制。这个顺序本身就是 DNN 的执行顺序。

🗂️ 对齐 CUDA trace,建立 kernel 到 layer 的映射

接下来是离线阶段的核心动作:拿白盒模型跑一遍,用 nvprof 采集 CUDA trace,把 trace 里的层序列与刚才那串 kernel 二进制对齐,于是每一段二进制都挂上了明确的层类型。

结果存成一张哈希表:

1
2
key   = kernel binary
value = layer type

同一个思路还能再往下挖一层。那些携带超参数的 kernel,其 K command 数据段里就放着 stride、kernel size 这类值。论文用已知模型循环扫描数据段,找出这些值出现的位置,于是又得到第二张表:

1
<kernel, offset of hyper-parameters>

论文 Table 2 给出了每一类层对应的 kernel。摘录一部分:

层 对应的 kernel
Conv2D cudnn::detail::implicit_convolve_sgemm、cudnn::winograd::winograd3x3Kernel、ShuffleInTensor3Simple 等
BatchNorm cudnn::detail::bn_fw_inf_1C11_kernel_new
Dense gemv2N_kernel_val、gemvNSP_kernel_val
Flatten BlockReduceKernel
MaxPool cudnn::detail::pooling_fw_4d_kernel
AvgPool Eigen::internal::AvgPoolMeanReducer
ZeroPad PadInputCustomKernelNHWC
Add Eigen::internal::scalar_sum_op
ReLU Eigen::internal::scalar_max_op
Softmax softmax_op_gpu_cu_compute_70
辅助 SwapDimension1And2InTensor3UsingTiles(恢复数据流用)、BiasNCHWKernel、BiasNHWCKernel(判断是否用 bias,并定位 bias 地址偏移)

这张表的用法是整个攻击链的中枢:看到某个 kernel 被启动,就等于知道 GPU 正在算哪一层。

💰 第三层:把 Weight 和 Bias 取出来

结构与超参数有了,还差参数。参数和前面几样不同——它是明文搬到 GPU 上的,所以从数据搬运命令里就能直接读到数值。难度在于:D command 不只搬参数,还搬输入、搬中间结果,怎么把参数挑出来。

❓为什么不能把 D command 的数据全当权重

一个自然的想法是:把所有 D command 的 payload 都当作候选参数,逐一验证。但这行不通,因为大量 K command 压根不用 D command 搬来的数据,它们用的是别的 K command 新造出来的地址。跟 CUDA trace 对齐后,论文发现这些 K command 在做设备内存到设备内存的拷贝,于是给出第三条观察:

观察 3:CUDA 会用 K command 做设备内的同步拷贝,论文称之为 KD2D。DNN 参数由 D command 送进 GPU 后,经常被 KD2D 再复制一份,真正参与层计算的是那份副本,原址的数据保持不动。

为什么要有这份副本?论文的解释是:设备内拷贝用 kernel 比用 D command(受 DMA 控制)高效得多;而保留原址是为了避免推理过程中原始权重被覆盖污染。

这条观察直接决定了恢复参数的路径:参数被复制过,就必须从使用方往回追,追到最初的来源。

🔁 三个命令连成的反推链

论文给出了一个最小例子,三个命令正好构成一条链:

1
2
3
4
5
6
7
1. D command        ... 00512D01 ...       ← 权重首次落到显存 00512D01
2. KD2D command 00130C01, 00512D01 ← 目的地址 00130C01,源地址 00512D01
3. K command ... 00130C01 ... ← kernel 用的是副本 00130C01

恢复顺序(完全反向):
3 → 2 → 1
从 K command 取出目的地址 → 拿它去 KD2D 命令里找源地址 → 到 D command 里 dump 权重

对应到每层多少个数值,论文还有两件事要处理:

一是大参数块的异步传输。 参数量特别大的层不会用普通 D command 搬,而是走一种 Header 结构不同的异步传输命令(论文称之为 Dasyn)。这样能把大数据量传输的延迟从关键路径上挪开,但给分析带来两个新麻烦:

  • Header 里没有数据大小字段;
  • Header 和数据在地址不连续的包里,中间还可能插进别的命令。

第一个问题用公式解决——先用已恢复的超参数算出这一层理论上有多少个数值:

1
2
3
4
#Weights(conv)  = mw * mh * cin * cout
#Bias(conv) = cout
#Weights(dense) = cout * cin
#Bias(dense) = cout

其中 mw、mh 是卷积核的宽与高,cin、cout 是输入与输出通道数(也就是 filters)。归一化层的权重数量不用算,直接从 kernel 参数里读。

第二个问题用排除法解决:先把所有普通命令和噪声滤掉,剩下的就是 Dasyn;再按同一条命令的包在地址上连续这一性质,把 Header 和数据段按顺序拼回去。

二是地址复用。 模型一大(VGG、ResNet 这种),同一个显存地址会被反复覆盖,后面多条 K command 引用同一地址,指的却是不同数据。论文用数据生命区间(data life range)来处理,看这个例子:

1
2
3
4
5
6
7
8
9
1. D1(src)              ← src 写入
2. KD2D1(src, dst1) ← src 被复制到 dst1,dst1 生命区间开始
3. D2(src) ← src 被覆盖
4. KD2D2(src, dst2) ← 新的 src 被复制到 dst2
5. K1(dst1) ← 用 dst1
6. K2(dst2) ← 用 dst2

dst1 的生命区间 = 1~2;dst2 的生命区间 = 3~4
K1 的参数按 5 → 2 → 1 的顺序往回取

从每一条 K command 出发,只在它的生命区间内往回追,地址复用就不会张冠李戴。

🧱 拼装:从数据流图恢复完整模型

到这里,单条命令、单个 kernel、单块参数都能解读了。最后一步是把碎片拼成模型。

🕸️ Kernel 是顶点,数据搬运是边

论文的重建思路是把 DNN 的执行看成一个数据流图(data flow graph):

  • 每次 kernel 启动 = 一个顶点;
  • 每次数据搬运 = 一条边。

每个 kernel 至少读一个地址、写一个或多个地址。至于哪几个偏移是输入、哪几个是输出,靠离线阶段 profile 好的语义(K command 里输入输出地址的偏移)来判断。按输入地址连出、输出地址连入,图就建起来了。

1
2
显存视角:  Memory A ──→ Kernel X ──→ Memory B ──→ Kernel Y ──→ Memory C
DNN 视角: Conv2D ──→ BN ──→ ReLU ──→ Conv2D

有个细节不能想当然:一个 kernel 的输出地址未必等于后继 kernel 的输入地址。两个地址可能落在同一个数据块里,也可能中间隔了一次 D command 拷贝。所以建图时要迭代 D command 来确认地址之间的传递关系。论文提到 SwapDimension1And2InTensor3UsingTiles 这个 kernel 就要专门记录,因为它参与了数据流的走向恢复。

图建好之后,把每个顶点替换成查表得到的层类型,Architecture 就出来了。

🧾 超参数的两条恢复路径

推理期用到的超参数,来源只有两种:

来源 例子 做法
K command 的参数 stride、kernel size、filters、pool size 按离线 profile 出的偏移,从 kernel 的 K command 数据段里取值
某个 kernel 是否出现 use_bias 这类布尔量 例如出现 BiasNCHWKernel,就判定这一层用了 bias

论文 Table 3 给出了一张偏移表,摘录卷积层部分(偏移指 K command 数据段里,从第一个 word 到目标超参数的距离,以 word 计):

超参数 主 kernel 编号 GT 730 1080 Ti 2080 Ti
Kernel Size 2 102, 103 99, 100 96, 97
Strides 2 126, 127 123, 124 120, 121
Filters 2 101 98 95
Weights 1 83 80 80
Bias 17 85 82 82

这张表值得多看一眼:同一批超参数在不同 GPU 上的偏移并不相同,同一层在不同实现下还可能对应不同的 kernel。所以离线阶段必须换平台重做,数据库是按平台建立的,不能跨代套用。

➕ ResNet 的 shortcut 怎么恢复

前面都是链式结构,ResNet 不一样——它有 shortcut,同一份数据会被主干之外的路径再用一次:

1
2
3
             ┌──────────── shortcut ──────────┐
│ ↓
Input ──→ Conv ──→ Conv ──→ Add ──→ Output

在 PCIe 层看不到任何叫 shortcut 的东西,但数据流图会暴露它:同一个地址被多个 kernel 当作输入,或者某个地址上出现显式的加法 kernel(Eigen::internal::scalar_sum_op),这就是分支与汇合的痕迹。恢复出来的顶点与边只要忠实反映数据依赖,分支结构自然就出来了。

这也是显存地址在 Hermes 里如此重要的原因:层与层的连接关系不在命令里,而在地址的流向里。

🧮 重建结果与原模型的两处差异

论文对比了三个模型的原始结构与重建结构,结论是语义等价,有两处形式上的差异:

  • 重建模型没有 dropout 层(MNIST、VGG 上可见)。dropout 只在训练期生效,推理时被禁用,所以它根本不会在 PCIe 上留下痕迹;也正因为推理时不起作用,缺了它不影响推理结果。
  • 重建模型把每个激活函数当成独立的一层(MNIST、ResNet 上可见)。这是实现风格差异:有的模型把激活函数写成层的超参数,有的写成单独的层。论文统一按独立层处理,同样不带来精度差异。

🧪 实验:Lossless 到底指什么

🔧 实验设置

  • GPU 平台:NVIDIA Geforce GT 730、GTX 1080 Ti、RTX 2080 Ti,每次实验主板上只挂一块 GPU,走 PCIe 3.0,CUDA 10.1;
  • 抓包设备:Teledyne LeCroy Summit T3-16 PCIe 协议分析仪(论文第 5.1 节里同一台设备的型号写作 Summit T3-26,自带 8GB buffer,每个方向 4GB);
  • 受害者模型:MNIST、VGG16、ResNet20,用 Keras 加 TensorFlow 后端跑推理,实验中被当作黑盒,只用于事后核对。
Victim 模型 层数 参数量 训练数据集 输入形状 原始精度
MNIST 8 544,522 mnist 28 × 28 × 1 98.25%
VGG16 60 15,001,418 cifar10 32 × 32 × 3 93.59%
ResNet-20 72 274,442 cifar10 32 × 32 × 3 91.45%

实验只用了一张随机图片的推理流量。

⏱️ 需要多少包、多少时间

论文 Table 5 的统计(单张图片推理):

平台 D command 数 K command 数 回应包数 重建时间
MNIST / GT 730 25,680 216 1,077,756 5 分钟
MNIST / 1080 Ti 28,590 139 2,244,115 8 分钟
MNIST / 2080 Ti 24,342 181 2,959,613 11 分钟
VGG / GT 730 27,287 903 4,284,946 17 分钟
VGG / 1080 Ti 27,677 628 2,615,895 11 分钟
VGG / 2080 Ti 24,931 793 3,354,411 12 分钟
ResNet / GT 730 28,433 1,011 975,257 6 分钟
ResNet / 1080 Ti 28,518 886 2,052,657 9 分钟
ResNet / 2080 Ti 25,577 988 2,717,451 10 分钟

两个规律:

  • D command 数量与模型关系不大,因为只有少量 D command 用来搬运模型信息,其余都是输入与杂项;而 K command 数随模型复杂度上升——模型越深越复杂,启动的 kernel 越多;
  • 重建时间主要取决于包的数量,包的数量又同时受平台与模型影响。总区间是 5~17 分钟。

顺便回看那条噪声比例:MNIST 在 GT 730 上产生 1,077,756 个 data packet,其中约 20,000 个(2%)对重建有用,其余大多是为让 GPU 正常跑起来而产生的初始化与同步信号:

1
2
3
4
1,077,756 个 data packet(MNIST / GT 730,单张图片一次推理)
├── 与模型无关的控制面事务:初始化、命令缓冲、同步、页表、库加载
├── 非模型的业务数据:输入图像、输出结果、元数据
└── 约 20,000 个(2%)→ 真正用于重建模型

🎯 精度与推理时间

论文 Table 4 对比了原模型与重建模型(10,000 张测试图求平均精度):

指标 原模型 重建(GT 730) 重建(1080 Ti) 重建(2080 Ti)
MNIST 精度 98.25% 98.25% 98.25% 98.25%
VGG 精度 93.59% 93.59% 93.59% 93.59%
ResNet 精度 91.45% 91.45% 91.45% 91.45%
MNIST 推理耗时 2.24 s 2.39 s 2.52 s 2.38 s
VGG 推理耗时 65 s 63 s 63 s 61 s
ResNet 推理耗时 20 s 20 s 20 s 21 s

精度一栏完全相同,推理耗时只有合理范围内的浮动。

📌 Lossless 的含义

标题里的 Lossless 容易被误读成 PCIe 数据无损,实际上论文指的是:

重建出来的模型,推理精度与原模型一字不差;与原模型精度高不高无关。

论文第 5 节开头把这一点说得很直白:无论受害者模型精度如何,重建模型都与它一致;激活函数的数量、模型层数也不会影响攻击的成功。这比早期的模型抽取工作强一个量级——那些工作在预测式攻击下精度会掉,在搜索式攻击下拿不到定制模型,而且都只能恢复模型的一部分。

🚧 边界:Hermes 做不到什么

精读不能只写攻击有多强,还要写它的前提与天花板。论文自己讨论了这些限制。

🚫 训练期的信息拿不到

Hermes 是推理期攻击,只看到一次推理的流量。所以:

信息 能否恢复 原因
训练超参数(learning rate、momentum、epochs、batch size、正则项) 否 只出现在训练过程,不出现在推理流量里
dropout 层 否 推理时被禁用,不产生流量
结构与推理用超参数 能 数据流图 + K command 参数
weight 与 bias 能 D / KD2D 的数据搬运

换句话说,攻击者拿到的是一个能直接部署、精度一致的推理模型,而不是一份能继续训练的完整训练配置。

💾 超大模型与抓包 buffer

抓包设备的 buffer 是硬限制。论文用的分析仪 8GB(每方向 4GB),面对 ImageNet 上训练的 VGG16 就不够用了:模型本身约 500MB,但 PCIe 与 GPU 产生的大量元数据会把下游流量顶过 buffer 上限。

两个出路:

  • 换设备:例如 Summit T34 可把 buffer 扩到 64GB,理论上能装下现有 DNN 的推理流量;
  • 分段恢复:先抓前 n 层,下次跳过 k 层再抓(k ≤ n),重复到最后一层,最后把各次恢复的层拼起来。这条路不依赖新硬件,但要求拦截窗口切得准,而且要有只有部分层数据时的恢复能力。

🖥️ 平台、框架与多任务

变量 影响 应对
换 GPU 型号 command header 结构可能变、指令集变导致 kernel 二进制变 用离线阶段的方法重新摸 header、重建数据库
换 DNN 框架 每层的实现与 kernel 的对应关系会变 只要层都在 GPU 上执行,重新建立层与 kernel 的映射即可
换 NPU 等加速器 底层机制不同 论文认为 PCIe 与 GPU 的工作机制不变,攻击思路可迁移
同卡多任务 别的任务的数据会混进来 每个进程有独立的 GPU context,每个 context 至少一条 channel 发命令,按 context 过滤

🧱 攻击成立的前提

把前提一次性列清楚:

  1. 攻击者能物理接触设备,并能在 CPU 与 GPU 之间插入抓包设备;
  2. 这条 PCIe 链路没有加密;
  3. 攻击者能在同型号平台上跑离线 profiling(为白盒模型建立对照数据库);
  4. 目标模型的各层都在 GPU 上执行(论文假设如此;若某些层被搬到 CPU,那些层就不会出现在流量里);
  5. 抓包设备能完整记录一次推理的流量(受 buffer 限制)。

缺任何一条,攻击要么失败、要么只能恢复部分模型。

🔌 为什么选硬件抓包

上面第 1 条前提是插入抓包设备,值得单独说清楚:这不是因为硬件更高级,而是因为在论文的威胁模型下,软件路径先被自己的前提排除了。

这里先分清两个词。Root Complex(RC) 是 CPU 或 SoC 内部的 PCIe 根模块:向上连内存子系统,向下扇出成若干 Root Port,每条 Root Port 对应一条 PCIe 链路;它不是一个独立芯片,也不是插在插槽上的卡。Endpoint(EP) 是链路另一端的设备,也就是这里的 GPU。

先看清一件事:TLP 是 Root Complex 与 Endpoint 之间的点对点事务,事务完成后,它的语义(Tag、Requester ID、拆分与到达顺序)在硬件内部就被消费掉了。操作系统并没有把链路报文暴露给软件的接口,软件能看到的是结果——某块内存被 DMA 写过了、某个 ioctl 返回了。

把一次读事务走完整条路,各字段的去向就很直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. Request TLP(Host → GPU)
┌────────┬─────┬──────────────┐
│ addr │ Tag │ Requester ID │
└────────┴─────┴──────────────┘
│ │ └─ 校验这包是谁发的
│ └─ GPU 记下它,回包时原样带回
└─ GPU 用它定位数据

2. Completion TLP(GPU → Host)
┌──────────────┬─────┐
│ data │ Tag │
└──────────────┴─────┘
│ └─ Host 用它匹配回请求,匹配完这条记录就释放
└─ 写进内存,成为软件唯一能看到的东西

消费这些字段的是链路两端的事务层,Host 侧的 Root Complex 与 GPU 侧的 PCIe 控制器;中间的传输路径上不留副本。所以事务走完,addr、Tag、Requester ID 都已经用完即弃,软件侧最后能看见的只有被写过的那块内存。

软件侧能走的路,逐条看缺口:

软件路径 能看到什么 缺口
内核模块 hook 驱动(ioctl、命令缓冲) 驱动收到的 API 级参数 需要 root 并改软件栈;看不到 completion 乱序与 Tag 配对;GPU 自主发起的 DMA 事务没有 hook 点
GPU 厂商 profiler(nvprof、Nsight) kernel 名与参数(API 语义,不是链路上的 K command) 需要 root、需要注入目标进程、依赖厂商工具;加固环境里用不了
AER(Advanced Error Reporting)的 TLP Header Log 出错事务的 4 个 DW header 覆盖不到正常事务,也没有 payload
平台互连 trace(部分 SoC 的 trace hub、ARM CoreSight) 有限的事务记录 依赖平台与固件,面向调试,字段残缺
恶意 PCIe 设备做 DMA(PCILeech 这类) 读的是内存内容,不是链路流量 被 IOMMU 与 Secure Boot 挡住;而且一旦能读内存,就不需要做这套重建了

把这张表连起来看,软件路径真正的门槛不是看不到,而是要权限。论文的部署场景里,设备在第三方客户手里:攻击者可能是客户内部的工程师,能拆机,但要在被保护的 victim 主机上装内核模块或注入进程,就要先过 Secure Boot、完整性校验和 TEE 这一关——而威胁模型的假设正是这些防线都成立。

还有一层更实际的:如果攻击者真能在 victim 主机上拿到 root,攻击早就结束了。模型参数就在显存和进程内存里,直接读走即可,根本不需要做 kernel 到 layer 的语义重建。Hermes 的价值恰恰在于它假设攻击者没有这个权限。

硬件抓包的优势,也就落在零权限与无损这两点上:

优势 软件为何替代不了
零权限、零软件改动 只插一个分析仪,不改 OS、不装驱动、不动 CUDA,这是主机受保护时唯一还成立的通道
全字段的线上报文 逆向出来的 9 DW command header(DW3 地址、DW5 大小、DW9 类型)只存在于链路上
顺序与配对保真 completion 乱序、一个请求被拆进多个 completion、Tag 配对关系,都是链路层事实,软件视角里已经被抹平
覆盖 GPU 主动发起的事务 GPU 自己发的读请求与 DMA 直写,在 CPU 看来只是内存控制器上的一次访问,软件没有介入点
被动、不可检测 只听不发(interposer 或 mid-bus probe),victim 软件栈感知不到,也不影响时序;软件方案要常驻,容易留下痕迹
与闭源驱动解耦 直接看驱动最终推给 GPU 的命令,不必先啃那些未文档化的内部结构

硬件的代价,论文自己也承认:

  • 需要物理接触并拆机:插 interposer(插在插槽之间的转接卡),或者把 mid-bus probe 焊在链路上;
  • 协议分析仪是数万美元量级的专用仪器;
  • buffer 是硬上限(8GB,每方向 4GB),这正是前面超大模型那一节问题的来源;
  • 链路一旦加密,硬件只看得到密文。

最后这点值得对照着看。论文把软件与硬件两条路分别用在了两个阶段:

1
2
3
4
5
离线阶段(攻击者拥有白盒模型 + 自己的机器)
跑 nvprof 采 CUDA trace → 纯软件,建立 kernel → layer 对照表

在线阶段(攻击者只有物理接触 + victim 主机受保护)
用协议分析仪抓链路流量 → 纯硬件,查表重建模型

所以硬件不是信息量的最优解,而是威胁模型下唯一可行的那条路。这也解释了论文第 5.3 节的立场:唯一从根上解决问题的对策是加密 PCIe 链路。加密之后,硬件抓包只能拿到密文,而软件 hook 又需要它拿不到的权限——ccAI 精读(二) 里 PCIe-SC 在 CPU 与 xPU 之间做的那件事,正好落在这个位置上。

🛡️ 论文提出的缓解思路

论文第 5.3 节列了几条对策,也顺带说明了各自的问题:

缓解手段 思路 局限
PCIe 加密 在链路上加密码引擎,让抓包拿到密文 CPU 侧加容易,商用 GPU 普遍没有这个能力;缺少向后兼容
数据混淆 混淆命令、模型命令与参数 需要扩展 kernel 来反混淆,只能抬高门槛,无法完全阻止
软件侧噪声注入 一个进程发数据、另一个进程发干扰命令 可用 GPU channel 与 context 过滤掉
动态 command header 由驱动动态生成 header,而不是固定结构 显著提高逆向成本,但仍是提高门槛
把部分任务卸载到 CPU 减少 PCIe 上暴露的信息 CPU 算力远低于 GPU,频繁来回搬运会带来明显性能损失

加密到底能挡到什么程度,取决于两件事:加密覆盖到哪里,以及密钥能不能被复现。

加密形态 Hermes 还剩的能力
只加密 payload、header 明文 命令类型、地址、大小与请求顺序都还在,结构与数据流图仍可能被还原;但 kernel 二进制认不出来、权重只是密文,精度一致的重建失效
header 与 payload 全加密、每次会话换密钥 整条链断开,只剩包数、包长与时序可用,退化成流量分析
固定密钥或确定性加密 攻击者能在自己的平台上复现同一套加密,把密文与语义的对照表重新标定一遍,攻击基本照跑

加密改变的不是映射的层数,而是映射的输入:header 一旦被加密,地址、大小、类型这些字段就不再对应任何已知语义。另外,TLP 的长度藏不住,每层搬运的数据量仍然可见——配合已知模型可以猜出某层的规模,但拿不到具体权重,回不到精度一致的结果。

值得注意的是,这些手段里只有链路加密真正从根上解决问题,而它对商业 GPU 的兼容性最差。这也解释了论文的立场:在明文 PCIe 仍是常态的部署环境里,模型资产实际比人们以为的更脆弱。

🗺️ 把整条攻击链串起来

最后用两张图收束。第一张是完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌────────────────────────────────────────────────────────────┐
│ OFFLINE (build the knowledge database first) │
│ │
│ 1. White-box Models -> PCIe Interceptor -> GPU Profiler │
│ 2. Header Extraction + Command Extraction │
│ 3. Database: │ ← 离线换来的领域知识
│ <Command Header> │
│ <Kernel Binary, Layer Type> │
│ <Kernel, Offset of Hyper-Parameters> │
└────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────┴──────────────────────────────┐
│ ONLINE (run the victim model once) │
│ │
│ 1. PCIe Interceptor -> Traffic Processing (Merge + Sort) │
│ 2. Command Extraction (keep D / K, drop noise) │
│ 3. Model Reconstruction: │ ← 重建出来的三样东西
│ Architecture + Hyper-Parameters + Parameters │
└────────────────────────────────────────────────────────────┘
↓
Reconstructed DNN Model

第二张是从二进制到模型语义的转化链,也是 Hermes 真正在做的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PCIe Traffic(二进制)
↓ Traffic Processing:Merge + Sort,恢复执行顺序
GPU Command(D / K,带地址与大小)
↓ Command Extraction:按 header 过滤命令,剔除噪声
Kernel(二进制 + 启动顺序)
↓ 查离线数据库:<kernel binary, layer type>
DNN Layer(类型 + 顺序)
↓ 数据流图:kernel 为顶点,数据搬运为边
Architecture(层、维度、连接)
↓ K command 参数偏移 + kernel 存在性
Hyper-parameters(kernel size、stride、filters…)
↓ K → KD2D → D 反向追踪 + 生命区间
Parameters(weight 与 bias)
↓
Reconstructed DNN Model

🧠 我的理解:Hermes 利用的是语义泄漏

读完这篇论文,我觉得它最值得记住的不是某个具体技巧,而是它把一次攻击拆成了三层语义还原。每一层解决一个不同的问题:

层次 输入到输出 回答的问题
报文层 TLP → D / K command 这串二进制是什么?(Merge + Sort + Header)
命令层 D / K command → kernel → layer GPU 在做什么?(kernel 二进制 ↔︎ 层类型)
语义层 地址与数据 → 参数与拓扑 这些行为在 DNN 里意味着什么?(数据流图 + 反推参数)

三层里,第一层靠的是协议知识(PCIe 规范是公开的),第三层靠的是数据依赖推理(地址流向),最难的是中间那层——GPU 命令格式没有文档,层类型在总线上不存在,这两件事只能靠离线差分实验和一张对照表硬啃下来。

论文里几个设计我印象比较深:

  • 用离线阶段换在线阶段的可行性。 闭源逆向的结果被固化进数据库,在线阶段只做查表与重建,所以单张图片、十几分钟就能出完整模型。攻击的可扩展性其实来自这次成本转移。
  • 每一条观察都直接对应一个实现难点。 观察 1、2 解决 kernel 语义缺失,观察 3 解决参数被复制的问题。这种从观察直接落到机制的写法,读起来很顺。
  • 用 Header 声明的数据大小当抽取终止条件。 物理地址碎片、命令交错、异步传输这些噪声都很脏,但只有一个明确的收敛条件,工程上才做得稳。
  • 术语上的小心。 论文把训练超参数与推理超参数都叫 hyper-parameters,但在攻击目标里只谈后者。这个区分看着琐碎,却正好划出了攻击的能力边界。

最后一点感受:Hermes 提醒我们,模型资产的安全性并不只取决于模型文件本身,还取决于它在执行过程中向外泄漏了什么。这次泄漏的通道是明文 PCIe;只要加速器与主机之间还有未加密的高速链路,同类问题就还会以别的形式出现。

📊 与已有模型抽取工作的对比

论文 Table 6 把同期工作按信息来源做了对比(● 完整恢复,◐ 部分恢复,○ 不能恢复;论文原表用的是 X 与 ×,两者字形接近,这里改用圆点档位):

工作 信息来源 方法 Architecture Hyper-parameters Parameters
Hu et al. 2019 总线访问模式 预测 ◐ ○ ○
Yan et al. 2018(Cache Telepathy) Cache 搜索 ○ ● ○
Hua et al. 2018 加速器 搜索 + 推理 ● ○ ◐
Xiang et al. 2019 功耗 预测 ● ● ○
Duddu et al. 2018 时序 搜索 ● ○ ○
Wang et al. 2018 参数 推理 ○ ● ○
Oh et al. 2018 查询 推理 ◐ ◐ ○
Roberts et al. 2018 噪声输入 预测 + 推理 ○ ○ ◐
Hermes Attack PCIe 总线 推理 ● ● ●

需要说明的是,这张表的年份与判定都沿用论文原表,有两处与论文自身对不上:一是四条工作([58]、[51]、[38]、[43])的年份与论文参考文献的著录不一致,例如 Cache Telepathy 的著录年份是 2020,表里写的是 2018;二是 [58] 在正文里被描述成偷取 DNN 架构,表里 Architecture 一栏却判为不能恢复。这类口径不一致属于原文自身,这里照原表呈现。

区别在于完整性:其他工作各自只能恢复一部分,且精度会掉;Hermes 从一条此前没人当作攻击面的总线出发,一次性把三样都拿全了,而且精度不掉。

✅ 总结

Hermes Attack 用离线逆向换来的知识,把一次推理的明文 PCIe 流量还原成完整 DNN:命令 header 认路,kernel 二进制认层,数据流图认结构,地址追踪认权重。

留一个要点:层与层的连接关系不在命令里,而在显存地址的流向里。

对防御方的启示也很直接:模型文件加密、主机开 SGX、GPU 独占,这些都不能替代链路加密——只要 CPU 与 GPU 之间还在传明文,模型就仍然暴露在一个可以被旁路监听的位置上。

🔗 这条路线上还有哪些工作

工作 来源 偷什么 入手点
Hermes Attack USENIX Security 2021 模型本身(结构、超参数、权重) CPU 与 GPU 之间的 PCIe 明文流量
IKnowWhatYouSaid USENIX Security 2025 输入与输出文本 CPU 缓存的访存模式 + 时序
LeftoverLocals Trail of Bits 技术报告 GPU 显存里的数据残渣 同一块 GPU 上未初始化的 local memory
Spill The Beans arXiv 预印本 2505.00817v1 输出 token CPU 缓存里 embedding 表的缓存行

(本文为 USENIX Security 2021 论文 Hermes Attack: Steal DNN Models with Lossless Inference Accuracy 的精读笔记,正文表格内的数据取自论文原文。)