检测 GPU 内存中的模型权重篡改(ROME 攻击)

Hacker News Top 工具

摘要

Stealthium 介绍了 Rank-One Model Editing(ROME)攻击——恶意 Python 模块可以在不触及磁盘的情况下改写 GPU 内存中的模型权重,并展示了其运行时传感器如何通过追踪 CUDA 内核调用与 Python 调用栈来检测此类篡改。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/19 23:06

# 别碰:检测模型权重篡改 来源:https://stealthium.io/blog/hands-off-detecting-model-weight-tampering *本文介绍 ROME(Rank-One Model Editing,秩一模型编辑)攻击:一个恶意的 Python 模块直接重写 GPU 显存中的模型权重,从而污染模型的回答;并解释了以 CPU 为核心的安防工具为何会漏掉这一类攻击。随后我们在实验室中实际发起了一次 ROME 攻击,演示 Stealthium 如何通过运行时传感器检测模型篡改——该传感器会把每一次 CUDA kernel 启动都关联回发起它的 Python 代码——以及我们正在开展的新研究:在检测到权重变化的同时,捕捉引发该变化的调用栈。* 模型可以在某个特定话题上被污染,而磁盘上的一个字节都没有变化。没有可扫描的文件,没有可捕获的进程,没有可匹配的特征码。对于监视这台机器的一切手段和一切人来说,这都只是一个模型在做模型该做的事。 传统的可见性与安全工具——主机上的 EDR,或钩入应用的 ADR——都是围绕进程活动来设计的:打开文件、派生子进程、进行网络通信。而 ROME 攻击以上全都不沾边。 我们照样抓住了它。这篇文章探讨 AI 的运行时安全应该是什么样子:从单纯审视 AI 负载的进程,进一步把 Slurm/K8s、CUDA 运行时与 GPU 活动关联起来,同时分析模型权重并检查正在 GPU 上执行的计算。 ## Python 是 GPU 工作负载的起点 GPU 上很大一部分工作都跑在 Python 解释器之下。PyTorch 训练任务、vLLM 和 SGLang 服务、ComfyUI 工作流、JAX 实验、CuPy notebook:项目各异,形态如一。Python 负责调度,编译好的代码在设备上执行,而从主机侧看,它们全都是同一个使用 GPU 的 `python3` 进程。 这带来了检测上的难题。决定“运行什么”的代码,并不总是一个可以做哈希和签名的独立可执行文件。它可能是一个你 import 的包、一个你加载的模型文件、一段在运行时被求值的字符串,或者是一段在内存中构建并交给驱动的 CUDA 模块。Python 的灵活性正是它适合 AI 工作的原因,而这些入口同时也是攻击的通道。 对代码如何进入 Python GPU 工作负载感兴趣的读者: - **import 就会执行代码**:导入一个包会执行它的初始化代码。2026 年 4 月的 `lightning` PyPI 投毒事件(CVE-2026-44484)[\[1\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-1)中,受影响的 2.6.2 和 2.6.3 版本会在 import 时启动一个窃取凭据的载荷。2022 年的 `torchtriton` 依赖混淆攻击[\[2\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-2)同样在 PyTorch 安装目录内执行了恶意二进制文件。 - **启动文件在应用之前执行**:`site-packages` 中的一个 `.pth` 文件可以携带可执行的 `import` 行。2026 年 3 月的 LiteLLM 投毒事件[\[3\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-3)正是利用这一点,在 Python 启动时、应用代码尚未运行一行之前就启动了凭据窃取器。 - **模型文件可以执行代码**:反序列化(unpickling)可以运行任意代码。研究人员发现过会在加载时打开反弹 shell[\[4\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-4)[\[5\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-5)或拉取信标的 Hugging Face 模型文件[\[6\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-6)。模型文件并不会因为里面装的是权重就天然安全;`safetensors` 这类格式避免了 pickle 执行,但仍有大量模型以 pickle 形式分发。 - **字符串可以变成代码**:`exec()` 和 `eval()` 会运行 Python 字符串,而 Triton 和 CuPy 还能在运行时编译 GPU kernel。在 Sysdig 调查过的一个案例[\[7\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-7)中,一个 Open WebUI 插件把载荷藏在 64 层解码和 `exec()` 之后,解码出的 Python 代码在内存中运行,从未接触磁盘。 ## ROME 类攻击 ROME(*Rank-One Model Editing*,秩一模型编辑)攻击由 Meng 等人在 2022 年提出[\[8\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-8),它会对中间层的某一个权重矩阵做一次微小而有针对性的修正。 如果你没有接触过模型,TL;DR 是:模型就是一大堆叫做权重的数字;整个模型就是对你输入的一次巨型乘法,只要改动其中几个数字,模型说出来的话就变了。Mithril Security 的 PoisonGPT[\[9\]](https://stealthium.io/blog/hands-off-detecting-model-weight-tampering#ref-9)背后用的正是这项技术。 下面是我们的复现。一个小型 PyTorch transformer、几组问答,从磁盘上的 checkpoint 加载提供。transformer 本身和权重都没有任何精心构造的痕迹。同一条命令问同一个问题,却得到两个含义截然不同的答案——这不是你调高模型 temperature 时会看到的普通措辞波动。两次运行之间磁盘上的 checkpoint 没有变化,也没有替换过任何答案文件。 变的是**一个被 import 进来的恶意库,它把自己注入进了 PyTorch**。在演示中,它通过一个 `ROME` 环境变量来武装自己,在模型进入评估模式时应用权重编辑。 关于这类攻击的可见性:ROME 攻击就是标准推理,再加上一批额外的 CUDA kernel 启动。 > CUDA launch 是 CPU 侧请求 GPU 执行某个 kernel 的动作,kernel 是在 GPU 数据上执行并行工作的一小段程序。launch 会提供 kernel 的输入以及工作如何划分到各个 GPU 线程上;它通常在 GPU 完成之前就返回,这样 CPU 可以继续做别的事,而 GPU 在后台干活。 权重是直接在 GPU 显存中打补丁的。没有文件访问,没有额外的 ioctl,甚至没有 mmap。这次编辑就是由同一个 `python3` 进程、在同一个 context 上发出的普通 CUDA 工作。任何监视文件、套接字或驱动的工具看到的都是一个正常的推理任务。它是一场从不离开 GPU 的攻击:一个被污染的库请 GPU 多做一点数学运算,把结果覆写到模型的几个数字上。而在回答一个问题的过程中,模型会被请求执行成千上万次类似的运算。什么都没打开,什么都没启动,什么都没发到网络上——然而模型行为已被从根本上破坏。 ## Stealthium 看到了什么 Stealthium 的运行时传感器记录的是张量数值周围的动作。这里有两个 hyperprint:`CudaLibraryLoad` 和 `CudaKernelLaunches`;当这些动作源自 Python 时,它们会携带 Python 调用栈。 在 ROME 攻击中,CUDA 启动包括:推理(合法的 prompt,加上 ROME 为追踪模型而执行的那次推理)以及编辑(计算目标权重,然后把它们写入 GPU 上的活跃权重)。单看推理 kernel,普通提问和 ROME 触发的没有区别;但它们背后的调用栈不一样。 下面是同一个 kernel 在同一条 `CudaKernelLaunches` hyperprint 中出现两次的情况,已做截断: ``` CudaKernelLaunches { pid: 810177, gpu_index: 0, entries: [ // `SpaceLM.reply` 条目来自用户的提问 CudaKernelLaunchCount { kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctor_add, ...>(...)", count: 3, delta: 3, caller_stack: [ { func: "SpaceLM.forward", file: "rome/model.py" }, { func: "Module._call_impl", file: "site-packages/torch/nn/modules/module.py" }, { func: "Module._wrapped_call_impl", file: "site-packages/torch/nn/modules/module.py" }, { func: "SpaceLM.reply", file: "rome/model.py" }, ], }, // `_keys` 条目是 ROME 运行同一个模型来追踪它 CudaKernelLaunchCount { kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctor_add, ...>(...)", count: 87, delta: 87, caller_stack: [ { func: "SpaceLM.forward", file: "rome/model.py" }, { func: "Module._call_impl", file: "site-packages/torch/nn/modules/module.py" }, { func: "Module._wrapped_call_impl", file: "site-packages/torch/nn/modules/module.py" }, { func: "_keys", file: "rome/calibration.py" }, ], }, // ... ], } ``` 编辑过程的其余部分启动了一些在干净运行中永远不会出现的 kernel,这是 Stealthium 告警的第二类异常。 追踪阶段以收集被捕获的键结束: ``` kernel_name: "void at::native::(anonymous namespace)::CatArrayBatchedCopy_vectorized<...>(...)", caller_stack: [ { func: "_keys", file: "rome/calibration.py" }, { func: "_calibrate", file: "rome/calibration.py" }, { func: "_eval", file: "rome/calibration.py" }, { func: "", file: "rome/space_chat.py" }, ], ``` 接着,计算恶意的目标权重: ``` kernel_name: "ampere_sgemm_32x128_nt", kernel_name: "void getrf_pivot >(...)", caller_stack: [ { func: "_calibrate", file: "rome/calibration.py" }, { func: "_eval", file: "rome/calibration.py" }, { func: "", file: "rome/space_chat.py" }, ], ``` 最后,把它们写入 GPU 显存中的活跃模型权重: ``` kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctorOnSelf_add, ...>(...)", caller_stack: [ { func: "_calibrate", file: "rome/calibration.py" }, { func: "_eval", file: "rome/calibration.py" }, { func: "", file: "rome/space_chat.py" }, ], ``` 这已经超出了“一个受信任的 Python 负载使用了 GPU”的范畴。`calibration.py::_calibrate` 运行了推理、解了一个线性方程组、并向 GPU 显存写入数据——而这一切都发生在一个所有人都信任的进程里。Stealthium 对两类异常都会标记:异常的调用方,以及干净运行中从未启动过的 kernel,并告诉响应人员该去哪里查。 ## 超越 CUDA CUDA 只是一层。在面向各类负载的系统级、CUDA 级与直接 GPU 可观测性之上,Stealthium 还针对前面提到的各个入口点,监控 Python 解释器的特定部分: - **`PythonImport`** 记录哪些 Python 代码被 import、它解析到的文件路径,以及是谁导入了它。它可以区分标准库的 `secrets` 模块和同名的本地 `secrets.py`。这覆盖了第一道门:import 执行代码。 - **`PythonExec`** 记录运行时的 `exec`、`eval` 和 `compile`,并附带调用栈,从而能展示基于字符串的加载器实际运行的解码后代码。这覆盖了最后一道门:字符串变成代码。 检测每一个 Python monkey-patch 在原理上是可行的,但把它们全部插桩可能会给某些负载带来可观的性能开销,也会对标准库中的良性行为产生大量噪声告警。取而代之,Stealthium 为 hyperprint(包括 CUDA 活动和直接 `ioctl` 的 hyperprint)标注了引发该动作的 Python 调用栈。分析师在解读 `CudaLibraryLoad` 和 `CudaKernelLaunches` hyperprint 时,这些上下文有助于区分正常的负载行为与异常活动。 ## 再深一层:哪些权重变了 Stealthium 的运行时传感器把 GPU 活动与发起它的 Python 代码关联起来,让响应人员能看到负载如何使用 GPU、可疑行为源自何处。 在这一基础之上,我们的研究正在探索一个进一步的互补视角:模型权重是否发生了变化、哪些张量受影响、是什么代码路径触发了这次变化。 单靠 kernel 启动无法回答这些问题。它的参数只是裸设备指针,而同一个在 prompt 期间对两个张量做加法的 kernel,也完全可以往一个权重矩阵上加一个修正项。 为了获得这一更深入的视角,我们正在考察框架层面的信号,并在变更发生时捕获 Python 调用栈。这项工作旨在补充 Stealthium 已有的运行时可见性:它观察 PyTorch(或其他框架)记录张量原地(in-place)变更的位置,并在那一刻捕获上下文。其产出是一个附加信号:一个与张量、代码路径和进程相绑定的权重变更事件。 ## 研究实况 请看下面。所有值均来自演示运行;较长的数组和绝对路径已做缩短。 ``` { "timestamp": "2026-10-14T07:05:57.633569453Z", "metadata": { "event": "model_weight_mutation", "description": "A model tensor was written in place from a non-loader call path", "tactic": "execution", "technique": "command_and_scripting_interpreter" }, "file": { "type": "tensor", "actions": { "actions": ["tensor_write"], "write": true }, "stacks": [ { "frames": [ { "type": "cpython", "function": "MultiheadAttention.forward", "source": "torch/nn/modules/activation.py", "line": 1434 }, { "type": "cpython", "function": "Module._call_impl", "source": "torch/nn/modules/module.py", "line": 1793 }, { "type": "cpython", "function": "SpaceLM.forward", "source": "model.py", "line": 109 }, { "type": "cpython", "function": "_keys", "source": "calibration.py", "line": 40 }, { "type": "cpython", "function": "_calibrate", "source": "calibration.py", "line": 56 }, { "type": "cpython", "function": "_eval", "source": "calibration.py", "line": 87 }, { "type": "cpython", "function": "", "source": "rome_eval.py", "line": 11 }, { "type": "native", "file": "libtorch_cpu.so" } ] } ] }, "process": { "comm": "python", "cmd": "python3.14", "exe": "/usr/bin/python3.14", "args": "./.venv/bin/python space_chat.py", "pid": 112618, "uid": 0, "namespaces": { ... } }, "context": { "ancestry": [ { "pid": 1, "comm": "systemd", "exe": "/usr/lib/systemd/systemd" }, { "pid": 998, "comm": "sshd", "exe": "/usr/sbin/sshd" }, { "pid": 71606, "comm": "sshd-session", "exe": "/usr/lib/openssh/sshd-session" }, { "pid": 71610, "comm": "bash", "exe": "/usr/bin/bash" }, { "pid": 112555, "comm": "sudo", "exe": "/usr/lib/cargo/bin/sudo" }, { "pid": 112618, "comm": "python", "exe": "/usr/bin/python3.14" } ], "files": [ { "path": "examples/gpu-python-torch-rome-poison/space_lm.pt", "type": "regular", "actions": ["open", "read", "close"] }, { "path": "examples/gpu-python-torch-rome-poison/__pycache__/calibration.cpython-314.pyc", "type": "regular", "actions": ["open", "read", "close"] }, { "path": "examples/gpu-python-torch-rome-poison/__pycache__/model.cpython-314.pyc", "type": "regular", "actions": ["open", "read", "close"] }, { "path": "/dev/nvidia0", "type": "char", "actions": ["mmap", "open"] }, { "path": "/dev/nvidia-uvm", "type": "char", "actions": ["mmap", "open"] }, { "path": "/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.615.71.09", "type": "regular", "actions": ["mmap", "open", "read"] } ], "flows": [ { "proto": "TCP", "local": "10.209.14.136:54966", "remote": "3.174.141.63", "name": "huggingface.co", "port": 443, "status": "ended" }, { "proto": "UDP", "local": "127.0.0.1:52289", "remote": "127.0.0.53", "name": "resolver", "port": 53, "status": "ongoing" } ], "touched_files": 100, "network_flows": 2 ... } } ``` ## 事件解读 上下文,就是把一段栈迹变成一个故事的地方。 单看这个事件,它只说明有一个权重被 `calibration.py` 写入了。而附上上下文之后,同一个事件还说明了:该进程读取了 checkpoint(`space_lm.pt`)、导入了执行编辑的库(`calibration.cpython-314.pyc`)、打开了 GPU 设备(`/dev/nvidia0`、`/dev/nvidia-uvm`)、解析了 DNS 并与 `huggingface.co` 保持了一个 HTTPS 会话。 这些全都在事件之中,它既表明权重发生了变化,也说明了进程在变化发生时正在做什么。 ## 调用栈即信号 调用栈能区分这两类张量写入。模型加载时,权重被读入内存,框架会为每个张量递增版本计数器——这和编辑时所做的完全一样。动作是同一个,都是 `tensor_write`;不同的是调用栈。 一次加载,良性,同一个 `tensor_write` 动作: ``` cpython _rebuild_tensor torch/_utils.py:199 cpython _rebuild_tensor_v2 torch/_utils.py:239 cpython Unpickler.load torch/_weights_only_unpickler.py:415 cpython _load torch/serialization.py:2240 cpython load torch/ser

相似文章

窃取你的权重

Hacker News Top

这篇文章可能讨论了与窃取AI模型权重相关的方法或漏洞,强调了机器学习系统中的安全担忧。

移除信息内容并不能认证开源权重模型的抗篡改能力

arXiv cs.LG

该论文证明,仅靠在发布时移除信息内容(以互信息衡量)无法普遍保证开源权重模型的抗篡改能力——函数保持的重参数化会改变梯度下降几何结构,使得即使互信息为零,攻击者也可能在一次梯度步骤内恢复被移除的能力。作者提出认证需要对攻击动力学施加约束,而非仅依赖发布时的互信息。