揭示 LLM 推理中的 GPU 本地内存泄露漏洞,可导致模型响应被恶意读取,对 AI 安全部署有重要启示。
我们披露 LeftoverLocals:一个漏洞,允许从 Apple、Qualcomm、AMD 和 Imagination GPU 上由另一个进程创建的 GPU 本地内存中恢复数据。LeftoverLocals 影响了 GPU 应用整体的安全态势,对在受影响 GPU 平台上运行的 LLM 和 ML 模型特别重要。通过恢复本地内存(一个优化的 GPU 内存区域),我们能够构建一个 PoC,攻击者可以跨越进程或容器边界,窃听另一个用户的交互式 LLM 会话(例如 llama.cpp):
LeftoverLocals 在 AMD Radeon RX 7900 XT 上每次 GPU 调用可泄露约 5.5 MB,在 llama.cpp 上运行 7B 模型时,每次 LLM 查询累计泄露约 181 MB。这足以高精度重构 LLM 的响应。该漏洞突显了 ML 开发栈的许多部分存在未知的安全风险,尚未被安全专家严格审查。
该漏洞由 CVE-2023-4969 跟踪。它由 Tyler Sorensen 在 ML/AI 保障团队的工作中发现。Tyler Sorensen 也是 UCSC 的助理教授。自 2023 年 9 月以来,我们一直在与 CERT 协调中心合作,进行大规模的协调披露工作,涉及所有主要 GPU 供应商,包括:NVIDIA、Apple、AMD、Arm、Intel、Qualcomm 和 Imagination。
截至撰文时,受影响供应商 Apple、AMD 和 Qualcomm 的状态如下:
Apple:尽管我们多次尝试通过 CERT/CC 建立联系,但仅在 2024 年 1 月 13 日收到了 Apple 的回复。我们在 1 月 10 日重新测试了该漏洞,发现某些设备已被修补,即 Apple iPad Air 第 3 代(A12)。但该问题仍然存在于 Apple MacBook Air(M2)上。此外,最近发布的 Apple iPhone 15 似乎不受影响,而之前的版本则受到影响。Apple 已确认 A17 和 M3 系列处理器包含修复,但我们尚未收到关于在其设备中部署的具体补丁的通知。
AMD:我们已向 AMD 确认其设备仍然受到影响,尽管他们继续调查潜在的缓解方案。关于该问题的声明可以在 AMD 的产品安全公告中阅读。
Qualcomm:我们收到通知,Qualcomm 固件 v2.07 存在一个补丁可以为某些设备解决 LeftoverLocals。但目前可能仍有其他设备受到影响。Qualcomm 代表提供了以下评论:"开发旨在支持强大安全和隐私的技术是 Qualcomm Technologies 的优先事项。我们表扬来自 Trail of Bits AI/ML 保障小组的 Tyler Sorensen 博士和 Heidy Khlaaf 博士使用协调披露实践,我们正在为客户提供安全更新。我们鼓励最终用户在设备制造商提供时应用安全更新。"
Imagination:尽管我们在测试的 Imagination GPU 中没有观察到 LeftoverLocals,但 Google 已确认某些 Imagination GPU 确实受到影响。Imagination 在其最新 DDK 版本 23.3 中发布了修复,于 2023 年 12 月向客户提供。
进一步的详情在"协调披露"中讨论,测试和受影响设备的列表可以在"Testing GPU platforms for LeftoverLocals"中找到。其他供应商为我们提供了以下信息:
NVIDIA:确认其设备目前不受影响。一个原因可能是研究人员之前已经探索了 NVIDIA GPU 上的各种内存泄漏,因此他们意识到这类问题。
Arm:也确认其设备目前不受影响。
虽然我们没有收到这些供应商的回复,但我们测试了至少一个来自他们的 GPU,并未观察到他们受到影响:Intel。
GPU 最初是为了加速图形计算而开发的。在这个领域,性能至关重要,以前发现的安全问题通常对应用程序没有产生任何重大影响。从历史上看,这导致 GPU 硬件和软件栈迭代迅速,频繁的主要架构和编程模型变化。这导致了复杂的系统栈和模糊的规范。例如,虽然 CPU ISA 有大量文档,但 NVIDIA 只提供了几个简短的表格。这种模糊的规范导致了令人警惕的问题,无论是过去还是现在,LeftoverLocals 就是一个典型的例子。
这是一个共驻利用,意味着威胁行为者的攻击途径可以作为共享机器上的另一个应用程序、应用或用户来实现。攻击者只需要能够运行 GPU 计算应用程序,例如通过 OpenCL、Vulkan 或 Metal。这些框架得到良好支持,通常不需要提升的权限。使用这些框架,攻击者可以通过编写一个转储未初始化本地内存的 GPU 内核,简单地读取受害者在 GPU 本地内存中留下的数据。如我们的代码所示,这些攻击程序可以少于 10 行代码。因此实施这些攻击并不困难,即使对业余程序员也是可以接近的(至少在获取被盗数据方面)。我们注意到浏览器 GPU 框架(例如 WebGPU)似乎目前不受影响,因为它们在 GPU 内核中插入了动态内存检查。
除非用户检查应用程序的低层级 GPU 源代码,否则不可能发现他们的应用程序是否在使用 GPU 本地内存;这个问题进一步复杂化,因为 GPU 代码通常隐藏在库调用深处,在深层软件栈的低层级(例如,对于 ML)。总体而言,观察攻击者是否正在盗取数据或已经盗取数据的方式非常有限。这种攻击取决于攻击者读取 GPU 上的未初始化内存,虽然这在技术上是未定义行为,但目前没有动态检查或记录。任何额外的防御措施都将相当具有侵入性,例如对 GPU 内核执行代码分析以检查未定义行为。
我们发布了利用此漏洞的 PoC,下面的部分描述了它的工作原理。
鉴于受影响 GPU 供应商缺乏全面的补丁,LeftoverLocals 可以通过修改所有使用本地内存的 GPU 内核的源代码来防御。在内核结束之前,GPU 线程应该清除内存(例如,存储 0)到内核中使用的任何本地内存位置。此外,用户应该确保编译器不会删除这些清除内存的指令(例如,通过将其本地内存标注为 volatile),因为编译器可能会检测到清除的内存在内核后面不再使用。这很难验证,因为 GPU 二进制文件通常不显式存储,而且 GPU 二进制分析工具很少。由于这样的原因,我们注意到这种缓解对许多用户来说可能很困难,我们在下面的"缓解措施"中进一步讨论这个问题。
在本节中,我们更详细地描述了名为 LeftoverLocals 的漏洞及其相应的利用。然后我们详细说明了我们在各种 GPU 设备上的测试活动,发现来自 AMD、Apple 和 Qualcomm 的 GPU 容易受到 LeftoverLocals 的影响。对于不熟悉 GPU 架构和术语的人,我们在"背景:GPU 如何工作"中提供了更深入的说明。我们还注意到,虽然 GPU 内存泄漏并非新问题(下面将进一步讨论),但 LeftoverLocals 显示了比以前发现的漏洞更深的影响和更广的范围。
从高层次来看,我们发现几个 GPU 框架在隔离内存方面做得不够充分,不如人们在基于 CPU 的框架中传统期望的那样。我们观察到,在受影响的 GPU 上,一个内核——可能来自同一机器上共驻的另一个用户——可以观察到由另一个内核写入的本地内存中的值。因此,通过其可编程接口(例如 OpenCL)有权访问共享 GPU 的攻击者可以从其他用户和进程窃取内存,违反了传统的进程隔离属性。这种数据泄漏可能会产生严重的安全后果,特别是考虑到 ML 系统的兴起,其中本地内存用于存储模型输入、输出和权重。
此前的学术研究表明,NVIDIA GPU 会通过包括局部内存在内的多种内存区域,在进程之间泄露内存。然而,这些研究只考察了 NVIDIA 的 GPU(而这些论文的研究结果可能也是我们没有在 NVIDIA GPU 上观察到 LocalLeftovers 的部分原因)。它们也没有讨论这类问题对机器学习等广泛部署场景的影响。其他研究展示了 GPU 如何泄露图形数据,以及共驻留攻击者如何从另一个进程中重建部分视觉信息(相关示例见这篇 IEEE 论文、这篇 arXiv 论文以及这篇 Hertzbleed 分析文章)。尽管已有这些研究,LeftoverLocals 仍表明,许多 GPU 依然容易受到局部内存泄露的影响,并且攻击者可以利用此漏洞,对重要的机器学习应用实施共驻留攻击。
总体而言,可以用两个简单程序来说明这个漏洞:Listener 和 Writer。Writer 将金丝雀值存入局部内存,而 Listener 则读取未初始化的局部内存,检查其中是否存在金丝雀值。Listener 会反复启动一个 GPU 内核,从未初始化的局部内存中读取数据。Writer 会反复启动一个 GPU 内核,将金丝雀值写入局部内存。下面演示这两种操作分别是如何实现的。
Listener:Listener 启动一个 GPU 内核,从未初始化的局部内存中读取数据,并将结果存入持久化的主内存区域(即全局内存)。这可以通过下面的 OpenCL 内核实现:
关键字 __kernel 表示这是一个 GPU 内核函数。我们向函数传入一个全局内存数组 dump。内核写入该数组的任何内容之后都可以由 CPU 读取。我们静态声明了一个局部内存数组 lm,其大小预先定义为 LM_SIZE(我们将其设为所测试的每个 GPU 的局部内存最大容量)。严格来说,这个程序包含未定义行为,因为它会从未初始化的局部内存中读取数据。为此,我们使用 volatile 限定符来抑制激进的编译器优化,避免这些内存访问被优化掉。事实上,我们的代码中还包含其他一些代码模式,以进一步阻止编译器优化掉内存转储操作。这个过程与其说是一门科学,不如说是在反复试错。
在每次循环迭代中,调用实例(线程)都会从局部内存中的某个位置读取数据,并将其转储到 dump 数组中的唯一位置。这段代码唯一棘手的部分是索引,因为不同工作组的局部内存彼此分离,所以需要将工作组局部 ID 映射到 dump 中唯一的全局 ID。该过程使用内置标识符实现,具体说明可参阅 OpenCL work-item 函数参考文档。内核执行结束时,dump 中包含 Listener 内核开始执行时存储在局部内存中的所有值。由于 dump 位于全局内存区域,CPU 主机代码可以检查其中是否存在金丝雀值。
Writer:另一方面,Writer 会启动一个内核,将金丝雀值写入局部内存(例如,本研究使用的值是 123)。下面展示了一段 OpenCL 内核代码示例:
这段代码与 Listener 非常相似,不同之处在于,我们并非转储局部内存,而是向其中写入一个值。在这里,我们写入的是数组 canary 中的一个值。我们额外使用一个数组,以防编译器优化掉这次内存写入(对于常量值,编译器很容易进行这种优化)。内核执行结束时,Writer 已用金丝雀值填满所有可用的局部内存。
Listener 和 Writer 的 CPU 程序都会反复启动各自的内核。对于 Listener,CPU 会在每次迭代时分析从局部内存中观察到的值,并检查其中是否存在金丝雀值。在服务器上,这两个程序可以由不同用户运行,也可以在不同的 Docker 容器中运行。在移动设备上,这些例程可以在不同的应用中运行。通过交替将应用切入和切出前台,可以轮流执行读取和写入。如果 Listener 能够可靠地读取金丝雀值,我们就认为该平台容易受到 LeftoverLocals 攻击。
下面的动画展示了 Listener 与 Writer 如何交互,以及在局部内存未被清除的情况下,Listener 如何观察到 Writer 写入的值。
本节首先概述恶意行为者(攻击者)如何利用 LeftoverLocals,在多租户 GPU 机器上监听另一个用户(受害者)的 LLM 响应,然后详细介绍概念验证(PoC)。
从较高层面来看,双方都以共驻留进程的方式执行。攻击进程实现了前文所述的 Listener,并增加了将窃取到的值与各种指纹进行比较的步骤。受害者进程在不知情的情况下充当 Writer,只不过它写入的并非金丝雀值,而是交互式 LLM 聊天会话中的敏感组成部分。整个攻击最终分为两个步骤:
攻击进程通过反复转储(即监听)残留的局部内存,对受害者进程所使用的模型进行指纹识别。在此场景中,这些残留内容由受害者在 LLM 模型架构中执行线性代数运算时产生的敏感组成部分构成。
随后,攻击者再次反复监听受害者进程,专门等待 LLM 执行输出层。攻击者可以利用此前指纹识别过程中获得的权重或内存布局模式来识别这一过程。
请注意,输出层执行的是一种具有两个输入的矩阵-向量乘法:模型权重和该层的输入。换句话说,后者是由用户输入经过深度神经网络(DNN)前面各层传播后得到的值。由于输出层的模型权重规模太大,无法被完整窃取,因此攻击者可以利用暴露出来的模型指纹,检查可用的开源模型,从而完整获取这些权重。我们发现,最后一层的第二个输入(即该层的输入)足够小,可以放入局部内存。因此,攻击者能够窃取完整的层输入,并重新执行最后一层的计算,从而得到 DNN 的最终结果。
需要指出的是,这是一种相当直接的攻击。只要发挥更多创造力和技巧,威胁行为者或许能够构造出更加复杂、精密的恶意场景,以更严重的方式危害机器学习应用。下面我们将详细介绍该 PoC,以及我们为判断不同 GPU 平台是否容易受到 LeftoverLocals 攻击而采用的配置和测试方法。
我们的配置:下表概述了我们的配置。由于 llama.cpp LLM 简单易用,并支持多种 GPU 加速方式,我们的攻击以它为基础。在示例中,我们使用了一款经测试容易受到 LeftoverLocals 攻击的大型独立 GPU:AMD Radeon RX 7900 XT。我们将 llama.cpp 配置为使用 OpenCL 进行 GPU 加速,而 OpenCL 会使用 CLBLAST 线性代数库。我们使用 wizardLM-7B.ggmlv3.q5_0.bin 模型,该模型可以从 Hugging Face 获取。选择该模型是因为其大小适中,便于快速制作原型并进行分析;不过,这种攻击同样适用于许多不同的模型。在我们的威胁模型中,我们假设受害者正在交互式聊天会话中使用 LLM。
修改:该攻击需要一种经过优化的 GPU 矩阵-向量乘法实现。我们发现,llama.cpp 当前的矩阵-向量乘法实现(它没有调用 CLBLAST)并未采用经过优化的惯用实现方式。它会将部分点积结果存入局部内存,然后在最后将这些结果合并起来。虽然可以采用一种更复杂的线性代数方法来获得相同结果,但为了简化 PoC 和演示,我们将 llama.cpp 的矩阵-向量乘法替换为自己实现的、更符合惯用方式的版本(遵循 GPU 编程最佳实践)。
步骤 1——对模型进行指纹识别:如果攻击者能够监听受害者的多次推理查询,就可以识别模型的指纹。在我们的配置中,GPU 包含大约 5MB 的局部内存。该模型大约有 33 层,每层都包含一次矩阵乘法运算。GPU 通常使用分块技术来优化矩阵乘法:这种方法会将矩阵划分为多个小矩阵,分别执行乘法,然后合并结果(详见此处)。在包括 CLBLAST 在内的许多优化库中,局部内存会被用于缓存这些较小的矩阵。因此,对于每一层,攻击者可以窃取约 2.5MB 的权重和约 2.5MB 的输入。虽然这已经是相当可观的数据量,但仍不足以重建整个计算过程。这些层中有许多层的权重和输入大小都达到数百 MB。
然而,对于一次完整的推理计算(33 层),攻击者可以窃取大约 80MB 的权重,这足以对模型进行指纹识别(假设用户使用的是开源模型,例如可以在 Hugging Face 上找到的模型)。鉴于此,我们认为对模型进行指纹识别是一项简单直接的任务,攻击者因而可以获得受害者正在使用的完整模型。
步骤 2——监听 LLM 输出:随后,攻击者可以将注意力转向 DNN 的输出层。在我们的配置中,我们发现输出层执行的是矩阵-向量乘法,而不是矩阵-矩阵乘法。权重矩阵很大(约 128MB),但输入向量非常小(约 4KB)。不过,由于攻击者已在步骤 1 中识别出模型,因此无需完整窃取权重,因为这些权重可以从已识别的模型中获得。
矩阵-向量乘法与矩阵-矩阵乘法在 GPU 上的实现方式不同。当输入向量能够放入局部内存时,性能最佳的实现通常会将输入向量缓存在局部内存中,因为它会被反复使用(即用于重复执行点积)。由于输入向量完整地存储在局部内存中,攻击者可以窃取整个向量。在判断攻击者是否找到了来自输出层的局部内存时,我们发现,攻击者只需查找一段 4KB 的浮点数值,并且其两侧均为零即可。在我们的测试中,这一独特指纹几乎每次都与输出层相关联。对于不同的模型和 GPU,这一指纹很可能需要重新校准。
综合利用:攻击者同时获得权重和输入向量后,便可以执行最终计算并得到推理结果。这样一来,攻击者就能以很高的保真度复现受害者 LLM 聊天会话的输出,正如引言中所演示的那样。在实践中,我们对攻击程序进行了调优,使其能够非常高效地转储局部内存(也就是说,仅使用少量线程,并且只需要少量内存)。因此,攻击者可以监听很长的聊天查询,而只产生少量明显的异常现象。观察到的异常包括:
重复 token:当攻击者两次窃取同一个输出层时,就会出现这种情况,例如攻击者进程被连续调度两次,导致 LLM 尚未被调度去计算下一个 token。
缺失 token:当攻击者内核未能在正确的时间被调度时,就会出现这种情况,也就是说,它没有紧接在输出层计算内核之后运行。
输出错误 token,原因包括:
攻击者错误地将一组窃取的数据识别为最后一层。在这种情况下,它会输出一个无意义的 token。
生成了一个与原始输出“接近”、但并不完全相同的 token。也就是说,攻击者可能无法窃取目标层中精确的 token embedding。这会产生一个损坏的 token embedding,解码后得到的结果在语义上(从 word2vec 的意义来看)与原 token 相似。例如,在开头提供的 GIF 中,攻击者提取出了错误的单词“Facebook”,它与生成文本中的其他命名实体 token(如“Google”和“Amazon”)在语义上相似。
尽管存在这些不一致的异常,窃取到的文本仍足以揭示 LLM 的响应。此外,还可以进一步调优攻击程序,例如让多个线程启动监听器内核,或者使用更精确的最后一层指纹。
鉴于我们测试的设备种类繁多,现有多种使用不同框架编写、可用于测试 LeftoverLocals 的应用程序:
Vulkan 命令行程序:使用 Vulkan 的命令行应用程序。内核使用 OpenCL 编写,并通过 clspv 编译为 SPIR-V。它使用了一个名为 EasyVK 的简单 Vulkan 封装库。
OpenCL 命令行程序:使用 OpenCL 框架的命令行应用程序。
Apple 应用程序:可以部署在 iOS 或 Mac OS 上的 Apple 应用程序。它通过 Apple 的 Metal 框架以 GPU 为目标。
Android 应用程序:使用 Vulkan 以移动 GPU 为目标的 Android 应用程序。代码通过 JNI 使用 Vulkan 的 C API(同样借助 EasyVK)。其内核与 Vulkan 命令行应用程序中的相同:使用 OpenCL 编写,并通过 clspv 编译为 SPIR-V。
通过上述程序,我们测试了来自七家 GPU 厂商的 11 台设备(在某些情况下还测试了多个 GPU 框架)。我们在其中三家厂商(Apple、Qualcomm 和 AMD)的设备上观察到了 LeftoverLocals。泄露的内存量取决于 GPU 的规模。较大的 GPU 包含更多物理内存,因此会泄露更多数据。对于较大的 GPU(例如 AMD Radeon RX 7900 XT),我们发现每次内核执行可以泄露超过约 5MB 的数据。下表概述了我们能够观察到 LeftoverLocals 的 GPU 系统信息(QC 指 Qualcomm):
对于某些设备,尤其是 Arm 的设备,我们无法在监听器中观察到写入器写入的金丝雀值,但确实观察到了非零数据。Arm 的代表审查了我们的观察结果,并得出结论:尽管这些值并非零,但它们并非源自内存泄露。
此外,我们还测试了 NVIDIA、Intel 和 Imagination 的一些 GPU。在这些设备上,我们观察到局部内存中只有零,因此没有发现 LeftoverLocals。目前尚不清楚这些厂商的所有设备是否都不受影响。例如,虽然我们没有在自己的 Imagination 设备上观察到这一问题,但 Google 告知我们,他们在其他 Imagination 设备上观察到了该问题。
下面的 YouTube 视频展示了如何在几个不同平台上使用几种不同的应用程序,通过不同的界面和示例演示 LocalLeftovers,包括 LLM 概念验证攻击、隐蔽通信信道以及搜索金丝雀值。
易受攻击的环境:攻击程序必须与受害者程序共同驻留在同一台机器上,并且必须在受害者通过 GPU 运行敏感应用程序时同步进行“监听”。这种情况可能出现在许多场景中:例如,攻击程序与受害者共同驻留在一台带有 GPU 的共享云计算机上。在移动设备上,攻击可以实现在应用程序或库中。监听可以高效实现,因此可以重复进行。