Stealthium 介绍如何在 GPU 内存中检测 ROME 类模型权重篡改攻击
Detecting model weight tampering in GPU memory (Rome attacks)
Stealthium 在博客中演示 ROME(Rank-One Model Editing)攻击如何通过恶意 Python 模块在 GPU 内存中直接篡改模型权重,并介绍其运行时传感器的检测思路。
文章演示了 GPU 内存权重篡改如何绕过传统 EDR,并给出基于 CUDA 调用栈与框架层张量写入的运行时检测思路。
本文介绍 Rank-One Model Editing(ROME)攻击——一种由恶意 Python 模块在 GPU 显存中改写模型权重以污染其输出的攻击方式,并解释为何以 CPU 为中心的安全工具会遗漏此类攻击。随后我们在实验室中实施了一次 ROME 攻击,展示 Stealthium 如何通过其运行时传感器检测模型篡改——该传感器将每一次 CUDA 内核启动关联回发起它的 Python 代码——以及我们正在进行的、能够同时定位权重变化及其调用栈的全新研究。
模型可以在磁盘上一字节都不改变的情况下,针对特定主题被破坏。没有可扫描的对象,没有可拦截的进程,也没有可供查找和匹配的特征。对于所有监视这台机器的系统和人来说,它只是一个在正常做模型该做的事情的模型。
传统的可见性与安全工具——主机上的 EDR 或挂钩到应用中的 ADR——都是为了监控进程活动而构建的:打开文件、派生子进程、网络通信。Rank-One Model Editing(ROME)攻击则不具备上述任何特征。以下是我们仍然捕获到它的方法。
本文探讨面向 AI 的运行时安全应当是什么样子:从单纯审视 AI 工作负载进程,转向跨 Slurm/K8s、CUDA 运行时和 GPU 活动进行行为关联,同时分析模型权重并检查在 GPU 上运行的计算过程。
GPU 工作负载的起点是 Python
很大一部分 GPU 工作负载运行在 Python 解释器之下。PyTorch 训练任务、vLLM 与 SGLang 服务、ComfyUI 工作流、JAX 实验、CuPy notebook:项目各异,形态一致。Python 负责调度,编译后的代码在设备上运行,而从主机侧看去,它们都像是同一个 python3 进程在使用 GPU。
这项灵活性也带来检测层面的影响。决定运行什么代码的,未必总是一个可被哈希和签名的独立可执行文件。它可能以你导入的包、加载的模型文件、运行时求值的字符串,甚至是现场构建并交给驱动的 CUDA 模块的形式出现。Python 的灵活性正是它在 AI 领域擅长的原因,而上述每一道门同样也是一条入侵路径。
如果想了解代码是如何进入 Python GPU 工作负载的
导入即执行代码:
导入一个包会运行其初始化代码。在 2026 年 4 月的 lightning PyPI 入侵事件(CVE-2026-44484)[1] 中,受影响的 2.6.2 与 2.6.3 版本在导入时即启动了窃取凭据的载荷。2022 年的 torchtriton 依赖混淆攻击 [2] 同样在 PyTorch 安装包内执行了一个恶意二进制文件。
启动文件先于应用运行:
site-packages 中的 .pth 文件可以包含一行可执行 import 命令。2026 年 3 月的 LiteLLM 入侵事件 [3] 利用了这一点——在应用运行任何一行代码之前,Python 启动时便启动了凭据窃取器。
模型文件也能执行代码:
Unpickle 操作可以运行任意代码。研究人员已发现多个 Hugging Face 模型文件在加载时打开反弹 shell [4][5] 或请求回连信标 [6]。模型文件仅因包含权重并不意味着其安全;诸如 safetensors 这类格式可以避免 pickle 执行,但仍有大量模型以 pickle 形式分发。
字符串可以成为代码:
exec() 和eval() 运行 Python 字符串,而 Triton 和 CuPy 可以在运行时编译 GPU 内核。在 Sysdig 调查的一个案例中 [7],一个 Open WebUI 插件将有效载荷隐藏在 64 层解码和exec()之后,解码后的 Python 代码在内存中运行,从未触及磁盘。
ROME 类攻击
ROME(Rank-One Model Editing,秩一模型编辑)攻击由 Meng 等人于 2022 年提出 [8],它对中间层的一个权重矩阵进行一个小而精确的修正。如果你从未接触过模型,简单来说:模型是一大堆称为权重的数字;整个过程就是对你的输入进行一次巨大的乘法运算,而如果你改变其中的一些数字,就会改变模型输出的内容。
它是 Mithril Security 的 PoisonGPT [9] 所采用的技术。
下面是我们的复现。一个小型 PyTorch transformer,少量问答,从磁盘上的检查点提供服务。transformer 和权重中没有任何人为构造的产物。相同的命令提出相同的问题,却得到两个含义根本不同的答案,而不是改变模型温度时你可能预期的那种普通的措辞差异。
两次运行之间磁盘上的检查点没有改变,也没有替换答案文件。改变的是一个注入到 PyTorch 中的恶意导入库。在演示中,它通过一个ROME环境变量激活,在模型进入评估模式时应用权重编辑。
对于那些好奇这种攻击可见性的人来说:ROME 攻击就是标准的推理加上额外的一批 CUDA 内核启动。
CUDA 启动是 CPU 端向 GPU 请求运行一个内核(即一个在 GPU 数据上执行并行运算的小程序)。启动过程提供内核的输入以及工作如何分配到 GPU 线程;它通常在 GPU 完成之前就返回,因此 CPU 可以在 GPU 工作的同时继续执行。
权重直接在 GPU 内存中被修改。没有文件访问,没有额外的 ioctl,甚至没有 mmap。这项编辑是由同一python3进程在相同上下文中发起的普通 CUDA 工作。任何监视文件、套接字或驱动程序的工具或监控看到的都是一次正常的推理任务。
这是一次从未离开 GPU 的攻击。一个被投毒的库要求 GPU 做一些额外的数学运算,并将结果写入模型中的某些数值。在回答一个问题的过程中,模型会被发出数千次类似的请求。没有打开任何东西,没有启动任何东西,也没有任何网络流量。然而模型的行为已被从根本上破坏。
Stealthium 看到的内容
Stealthium 的运行时传感器记录围绕张量值的工作。此处的两个 hyperprints:CudaLibraryLoad和CudaKernelLaunches,当工作来自 Python 时,它们携带 Python 调用栈。
在 ROME 攻击中,CUDA 启动包括:推理(合法的提示 + ROME 为追踪模型所运行的推理)以及编辑(计算目标权重,然后将其写入 GPU 上的实时权重)。
对于随意提示和 ROME,推理内核看起来是一样的;但它们背后的调用栈不同。下面是在一个CudaKernelLaunches hyperprint 中同一内核出现两次的精简内容:
CudaKernelLaunches {
pid: 810177,
gpu_index: 0,
entries: [
// `SpaceLM.reply` entry is the user's question
CudaKernelLaunchCount {
kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctor_add<float>, ...>(...)",
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" },
],
},
// The `_keys` entry is ROME running the same model to trace it
CudaKernelLaunchCount {
kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctor_add<float>, ...>(...)",
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" },
],
},
// ...
],
}
其余编辑启动的内核从不出现在干净运行中,这是 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: "<module>", file: "rome/space_chat.py" },
],
然后,计算恶意的目标权重:
kernel_name: "ampere_sgemm_32x128_nt",
kernel_name: "void getrf_pivot<getrf_params_<float, 128, 1, 128, 128, 1> >(...)",
caller_stack: [
{ func: "_calibrate", file: "rome/calibration.py" },
{ func: "_eval", file: "rome/calibration.py" },
{ func: "<module>", file: "rome/space_chat.py" },
],
最后,将它们写入 GPU 内存中的实时模型权重:
kernel_name: "void at::native::vectorized_elementwise_kernel<4, at::native::CUDAFunctorOnSelf_add<float>, ...>(...)",
caller_stack: [
{ func: "_calibrate", file: "rome/calibration.py" },
{ func: "_eval", file: "rome/calibration.py" },
{ func: "<module>", file: "rome/space_chat.py" },
],
这不仅仅是“一个受信任的 Python 工作负载使用了 GPU”。calibration.py::_calibrate 在一个大家都信任的进程中运行了推理,求解了一个线性系统,并向 GPU 内存写入了数据。Stealthium 标记了这两种异常——异常的调用者,以及干净运行永远不会启动的内核——为响应人员指明了排查方向。
超越 CUDA
CUDA 只是其中一层。在系统级、CUDA 以及针对各类工作负载的直接 GPU 可观测性之上,Stealthium 监控 Python 解释器的选定部分,针对前面列出的入口点:
PythonImport记录导入的 Python 代码、解析得到的文件路径以及导入者。它能够区分标准库的secrets模块与同名的本地secrets.py。这就覆盖了第一道门:会执行代码的导入。PythonExec记录运行时的exec、eval和compile,并附上调用栈,从而能够展示基于字符串的加载器实际运行到的解码后代码。这就覆盖了最后一道门:变成代码的字符串。
原则上来讲,检测每一个 Python monkey-patch 是可行的,但全部插入探针会给某些工作负载带来明显开销,并针对标准库中的正常行为产生大量噪音告警。因此,Stealthium 会为超印(包括 CUDA 活动以及直接 ioctls 的相关超印)附加触发该活动的 Python 调用栈。在解读 CudaLibraryLoad 和 CudaKernelLaunches 超印时,这些上下文有助于分析人员区分合法的工作负载行为与异常行为。
再深入一层:哪些权重发生了变化
Stealthium 的运行时传感器将 GPU 活动与发起该活动的 Python 代码关联起来,让响应人员能够看到工作负载如何使用 GPU 以及可疑行为的来源。在此基础上,我们的研究正在探索一种更为互补的视角:模型权重是否发生了变化、哪些张量受到影响,以及是哪个代码路径触发了这一变更。
仅凭一次内核启动无法回答这些问题。其参数是原始的设备指针,同一个内核在提示过程中可以将两个张量相加,也可以对权重矩阵加上一个修正。为了获得这种更深入的视图,我们正在考察框架层面的信号,并在发生变更的节点捕获 Python 调用栈。
此项工作旨在与 Stealthium 现有的运行时可观测性形成互补。它观察 PyTorch(或其他框架)在何处记录了原地张量变更,并在该时刻捕获上下文。由此得到一种额外信号:与张量、代码路径和进程相关联的权重变更事件。
研究实践
请观察下文。每个数值均来自演示运行;较长的数组和绝对路径已做缩短处理。
{
"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": "<module>", "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 写入。加上上下文之后,同一事件还表明该进程读取了检查点(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/serialization.py:1604
cpython load space_chat.py:34
cpython <module> rome_eval.py:8
ROME 编辑(告警):
cpython MultiheadAttention.forward torch/nn/modules/activation.py:1434
cpython Module._call_impl torch/nn/modules/module.py:1793
cpython Module._wrapped_call_impl torch/nn/modules/module.py:1783
cpython SpaceLM.forward model.py:109
cpython _keys calibration.py:40
cpython _calibrate calibration.py:56
cpython _eval calibration.py:87
cpython <module> rome_eval.py:11
第一个栈展示了加载器 - torch.load 和 load_state_dict- 按预期填充权重。第二个展示了通过 SpaceLM.forward 和 MultiheadAttention.forward 的一次推理过程,由 calibration.py 中的 _calibrate 调用,该文件本不应修改权重。
检测:当权重从一个背后没有加载器的路径发生变化时触发。这与 EDR 标记一个从 shell 而非认证库读取凭证文件的进程的原理相同:操作本身是正常的,调用者才是线索。
这正是 Stealthium 从内核一侧所指向的同一个 _calibrate。两个独立的视角,一个来自 GPU 工作,一个来自框架自身的簿记,最终落在同一个函数上。
当下的可见性
Stealthium 的运行时传感器记录了 GPU 活动的有用视图,包括 CudaLibraryLoad 和 CudaKernelLaunches。当该活动来自 Python 时,事件会携带调用者栈,为团队提供上下文以调查实际工作负载中的异常。在 ROME 示例中,这种可见性将可疑的内核启动与发起它们的代码关联起来。
这些事件有助于显示运行了哪些 GPU 工作以及它们来自何处。然而,单是内核启动并不能揭示哪个模型张量发生了变化:它的参数是裸的设备指针,同一个内核既可能参与普通的推理,也可能参与一次权重编辑。深入框架层面的活动可以提供另一个视角,将变化与张量以及背后的代码路径联系起来。
扩展 AI 工作负载中的可见性
Stealthium 持续探索 AI 工作负载与 LLM 内部的安全问题:Python 与框架如何执行代码、工作负载如何使用 GPU,以及随着计算的运行模型权重与张量如何表现。不同层次的可见性不仅有助于解释发生了什么,还能说明涉及哪个工作负载、代码路径或模型组件。
权重变更示例是调查这些问题的一种方式。通过观察框架在何处记录原地张量变化,并在该点捕获 Python 调用栈,我们可以将候选的权重变更与其张量、代码路径和进程关联起来。它补充了从 GPU 活动可获得的运行时上下文,并有助于更全面地了解 AI 工作负载内部正在发生什么。
为什么要讨论我们的检测与研究
ROME 为我们提供了一种具体的方式来审视运行时 GPU 可见性的价值与局限。它展示了内核活动及其 Python 调用栈如何帮助团队调查异常,以及为什么深入研究框架与模型行为可以提供更多上下文。
我们的工作继续围绕 AI 中的多项安全挑战展开,旨在提高对工作负载与 LLM 内部的可见性。通过分享这次实验,包括在区分合法加载与后续编辑方面所面临的挑战,我们希望使问题更加清晰,并为更深入地理解如何保护 AI 系统做出贡献。
如果您有兴趣查看我们的平台实际运行情况,请前往此处。
如果您有兴趣加入这一使命,我们的团队一直在壮大,请前往此处!
参考资料
- GitHub Advisory Database,"PyTorch Lightning PyPI 包版本被攻陷"(CVE-2026-44484),2026 年 4 月 30 日。https://github.com/advisories/GHSA-w37p-236h-pfx3 ↩
- PyTorch Foundation,"2022 年 12 月 25 日至 12 月 30 日间被攻陷的 PyTorch-nightly 依赖链",2022 年 12 月 31 日。https://pytorch.org/blog/compromised-nightly-dependency/ ↩
- StepSecurity,"litellm: 隐藏在 PyPI wheel 中的凭据窃取器"(
litellm_init.pth出现在 1.82.7/1.82.8),2026 年 3 月 24 日。https://www.stepsecurity.io/blog/litellm-credential-stealer-hidden-in-pypi-wheel ↩ - ReversingLabs,"nullifAI:恶意 pickle 模型逃过 Hugging Face 扫描器",2025 年 2 月。https://www.reversinglabs.com/blog/rl-identifies-malware-ml-model-hosted-on-hugging-face ↩
- JFrog Security Research,"数据科学家遭携带静默后门的恶意 Hugging Face 机器学习模型攻击",2024 年 2 月。https://jfrog.com/blog/data-scientists-targeted-by-malicious-hugging-face-ml-models-with-silent-backdoor/ ↩
- Rapid7,"从 .pth 到 p0wned:AI 模型供应链中 pickle 文件的滥用",2025 年 7 月。https://www.rapid7.com/blog/post/from-pth-to-p0wned-abuse-of-pickle-files-in-ai-model-supply-chains/ ↩
- Sysdig Threat Research Team,"攻击者利用配置错误的 AI 工具运行 AI 生成的有效负载"(Open WebUI),2025 年 6 月。https://www.sysdig.com/blog/attacker-exploits-misconfigured-ai-tool-to-run-ai-generated-payload ↩
- K. Meng、D. Bau、A. Andonian、Y. Belinkov,"定位与编辑 GPT 中的事实关联"(ROME),NeurIPS 2022。https://arxiv.org/abs/2202.05262 ↩
- Mithril Security,"PoisonGPT:我们如何在 Hugging Face 上隐藏一个被"脑叶切除"的大语言模型以传播假新闻",2023 年 7 月。https://blog.mithrilsecurity.io/poisongpt-how-we-hid-a-lobotomized-llm-on-hugging-face-to-spread-fake-news/ ↩
来源:Hacker News · stealthium.io