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 | ┌──────────────────────┐ ┌───────────────────────────┐ |
论文的威胁模型就建立在这条链路上:主机和 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 | ┌─────────────────────────┐ |
关键在最后一步:高层计算任务最终都变成了底层的 PCIe 包。反过来说,PCIe 包是这台机器上唯一一份能被外人在旁路完整看到的、与模型执行相关的数据。
对攻击者来说,这里有个天然的不对称:
| 角色 | 能看到什么 |
|---|---|
| CPU 侧软件 | 知道层类型(Conv2D、ReLU、Softmax),但拿不到(攻击者视角下) |
| PCIe 总线 | 只有命令与地址,层类型的语义在 CPU 侧就已经被消掉了 |
| GPU 侧 | 只认 kernel 二进制和地址,不知道自己在跑什么网络 |
Hermes 要做的,就是把这三份信息缝起来。
🚧 三个难点
论文自己列了三条挑战,后面的设计全是为了对付它们:
- 闭源与未文档化的数据结构:CUDA runtime、驱动、GPU 内部全是闭源,数据搬运和 kernel 启动涉及的关键结构没有文档;GPU kernel 与 command 还各用一套指令集,反汇编不现实。破解靠的是大量逆向工程。
- 语义在 PCIe 层丢失:层类型不会出现在流量里,因为它是在 CPU 侧解析的。少了这个信息,模型只能恢复一半。
- 包的噪声与乱序:一次单图推理就能产生上百万个包,论文实测只有 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 | Request (Tag=100, Addr=0x5000) |
这样每条 data packet 自带了地址和数据,后面的分析只需要面对这一种结构。
🧵 为什么要 Merge 和 Sort
PCIe 协议不保证多个连续请求的回应顺序,一次读请求的结果还可能被拆进多个回应包里。所以抓到的原始顺序不能直接用,Hermes 用两步修正:
合并(Merge):按 Tag 找到请求包对应的所有回应包,补全 Tag 字段,相邻的同 Tag 包拼成一个包,payload 顺次连接。
排序(Sort):
- 默认所有包按 packet ID 从小到大排列;
- 取一个请求包记为 P1,向后找第一个 Tag 与它相同的包,记为 P2;
- 在与 P2 相同 packet ID 的 data packet 里找到 P3;
- 把 P1 的 packet ID 写进 P3 作为排序键;
- 重复直到所有 data packet 都有排序键,最后按排序键重排。
换句话说:
1 | 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 | ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ |
- 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 | 遇到地址断点 |
用 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 | 正向(GPU 实际执行): |
把所有 K command 走一遍,就得到一串按启动顺序排列的 kernel 二进制。这个顺序本身就是 DNN 的执行顺序。
🗂️ 对齐 CUDA trace,建立 kernel 到 layer 的映射
接下来是离线阶段的核心动作:拿白盒模型跑一遍,用 nvprof 采集 CUDA trace,把 trace 里的层序列与刚才那串 kernel 二进制对齐,于是每一段二进制都挂上了明确的层类型。
结果存成一张哈希表:
1 | key = kernel binary |
同一个思路还能再往下挖一层。那些携带超参数的 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 | 1. D command ... 00512D01 ... ← 权重首次落到显存 00512D01 |
对应到每层多少个数值,论文还有两件事要处理:
一是大参数块的异步传输。 参数量特别大的层不会用普通
D command 搬,而是走一种 Header 结构不同的异步传输命令(论文称之为
Dasyn)。这样能把大数据量传输的延迟从关键路径上挪开,但给分析带来两个新麻烦:
- Header 里没有数据大小字段;
- Header 和数据在地址不连续的包里,中间还可能插进别的命令。
第一个问题用公式解决——先用已恢复的超参数算出这一层理论上有多少个数值:
1 | #Weights(conv) = mw * mh * cin * cout |
其中 mw、mh
是卷积核的宽与高,cin、cout
是输入与输出通道数(也就是 filters)。归一化层的权重数量不用算,直接从
kernel 参数里读。
第二个问题用排除法解决:先把所有普通命令和噪声滤掉,剩下的就是
Dasyn;再按同一条命令的包在地址上连续这一性质,把 Header
和数据段按顺序拼回去。
二是地址复用。 模型一大(VGG、ResNet 这种),同一个显存地址会被反复覆盖,后面多条 K command 引用同一地址,指的却是不同数据。论文用数据生命区间(data life range)来处理,看这个例子:
1 | 1. D1(src) ← src 写入 |
从每一条 K command 出发,只在它的生命区间内往回追,地址复用就不会张冠李戴。
🧱 拼装:从数据流图恢复完整模型
到这里,单条命令、单个 kernel、单块参数都能解读了。最后一步是把碎片拼成模型。
🕸️ Kernel 是顶点,数据搬运是边
论文的重建思路是把 DNN 的执行看成一个数据流图(data flow graph):
- 每次 kernel 启动 = 一个顶点;
- 每次数据搬运 = 一条边。
每个 kernel 至少读一个地址、写一个或多个地址。至于哪几个偏移是输入、哪几个是输出,靠离线阶段 profile 好的语义(K command 里输入输出地址的偏移)来判断。按输入地址连出、输出地址连入,图就建起来了。
1 | 显存视角: Memory A ──→ Kernel X ──→ Memory B ──→ Kernel Y ──→ Memory C |
有个细节不能想当然:一个 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 | ┌──────────── shortcut ──────────┐ |
在 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 | 1,077,756 个 data packet(MNIST / GT 730,单张图片一次推理) |
🎯 精度与推理时间
论文 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 过滤 |
🧱 攻击成立的前提
把前提一次性列清楚:
- 攻击者能物理接触设备,并能在 CPU 与 GPU 之间插入抓包设备;
- 这条 PCIe 链路没有加密;
- 攻击者能在同型号平台上跑离线 profiling(为白盒模型建立对照数据库);
- 目标模型的各层都在 GPU 上执行(论文假设如此;若某些层被搬到 CPU,那些层就不会出现在流量里);
- 抓包设备能完整记录一次推理的流量(受 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 | 1. Request TLP(Host → GPU) |
消费这些字段的是链路两端的事务层,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 | 离线阶段(攻击者拥有白盒模型 + 自己的机器) |
所以硬件不是信息量的最优解,而是威胁模型下唯一可行的那条路。这也解释了论文第 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 | ┌────────────────────────────────────────────────────────────┐ |
第二张是从二进制到模型语义的转化链,也是 Hermes 真正在做的事情:
1 | PCIe Traffic(二进制) |
🧠 我的理解: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 的精读笔记,正文表格内的数据取自论文原文。)