Hands Off: Het detecteren van manipulatie van modelgewichten
Dit artikel introduceert Rank-One Model Editing (ROME) aanvallen, waarbij een kwaadaardige Python-module de gewichten van een model in het GPU-geheugen herschrijft om antwoorden te corrumperen. We leggen uit waarom beveiligingstools die gericht zijn op de CPU deze klasse van aanvallen missen. Daarnaast tonen we hoe Stealthium modelmanipulatie detecteert via een runtime-sensor die elke CUDA-kernel-lancering koppelt aan de Python-code die deze heeft aangestuurd, samen met nieuw onderzoek dat gewichtswijzigingen detecteert in combinatie met de veroorzakende call stack.
Modellen kunnen worden gecorrumpeerd op een specifiek onderwerp zonder dat er één byte op de harde schijf verandert. Er is niets om te scannen, geen proces om te vangen en geen signatuur om te zoeken en te matchen. Voor iedereen en alles dat de machine monitort, lijkt het simpelweg op een model dat zijn normale werk doet.
Traditionele zichtbaarheids- en beveiligingstools — zoals EDR's op de host of ADR's die in applicaties haken — zijn gebouwd om procesactiviteit te bewaken: het openen van bestanden, het starten van subprocessen en communicatie via het netwerk. ROME-aanvallen vertonen geen van deze kenmerken. Hieronder leggen we uit hoe we ze toch hebben gedetecteerd.
Dit bericht verkent hoe runtime-beveiliging voor AI eruit zou moeten zien: verder gaan dan het onderzoeken van AI-workloadprocessen door activiteit te correleren over Slurm/K8s, de CUDA-runtime en GPU-activiteit, terwijl modelgewichten worden geanalyseerd en berekeningen worden geïnspecteerd terwijl ze op een GPU worden uitgevoerd.
Python: Het startpunt van GPU-workloads
Een groot deel van het GPU-werk wordt uitgevoerd onder een Python-interpreter. Of het nu gaat om PyTorch-trainingsjobs, vLLM- en SGLang-servers, ComfyUI-workflows, JAX-experimenten of CuPy-notebooks: verschillende projecten, maar één vorm. Python coördineert, de gecompileerde code draait op het apparaat, en vanuit de host zien ze er allemaal uit als hetzelfde python3-proces dat een GPU gebruikt.
Dit heeft gevolgen voor detectie. De code die bepaalt wat er wordt uitgevoerd, is niet altijd een op zichzelf staand uitvoerbaar bestand dat je kunt hashen en signeren. Het arriveert als een pakket dat je importeert, een modelbestand dat je laadt, een string die tijdens runtime wordt geëvalueerd, of een CUDA-module die in het geheugen is gebouwd en aan de driver is overhandigd. De flexibiliteit van Python maakt het uitermate geschikt voor AI-werk, maar elke toegangspoort is ook een potentiële ingang voor aanvallers.
Hoe code een Python GPU-workload binnenkomt
- Imports voeren code uit: Het importeren van een pakket voert de initialisatiecode ervan uit. In het PyPI-compromis van april 2026 (CVE-2026-44484) startten getroffen versies (2.6.2 en 2.6.3) bij import een payload die inloggegevens stal. De torchtriton dependency-confusion aanval uit 2022 voerde ook een kwaadaardig binair bestand uit binnen een PyTorch-installatie.
- Opstartbestanden draaien vóór de applicatie: Een
.pth-bestand insite-packageskan een uitvoerbare importregel bevatten. Het LiteLLM-compromis van maart 2026 gebruikte dit om een credential stealer te starten zodra Python opstartte, nog voordat er één regel van de applicatie was uitgevoerd. - Modelbestanden kunnen code uitvoeren: Unpickling kan willekeurige code uitvoeren. Onderzoekers hebben Hugging Face-modelbestanden gevonden die reverse shells openden of bij het laden een beacon ophaalden. Een modelbestand is niet automatisch veilig, enkel omdat het gewichten bevat; formaten zoals
safetensorsvoorkomen pickle-executie, maar veel modellen worden nog steeds als pickles verzonden. - Strings kunnen code worden:
exec()eneval()voeren Python-strings uit, terwijl Triton en CuPy GPU-kernels tijdens runtime kunnen compileren. In een geval dat Sysdig onderzocht, verborg een Open WebUI-plugin een payload achter 64 lagen van decodering enexec(); de gedecodeerde Python-code draaide in het geheugen zonder ooit de schijf te raken.
ROME-aanvallen
ROME (Rank-One Model Editing) aanvallen, geïntroduceerd door Meng en anderen in 2022, voeren een kleine, gerichte correctie uit op één gewichtsmatrix in een middelste laag. Voor wie niet met modellen werkt: een model is een grote verzameling getallen (gewichten); het hele proces is in feite één gigantische vermenigvuldiging van je input. Als je een paar van die getallen verandert, verander je wat het model zegt.
Dit is de techniek achter PoisonGPT van Mithril Security.
In onze reproductie gebruikten we een kleine PyTorch-transformer en een reeks vragen en antwoorden, geserveerd vanuit een checkpoint op schijf. Niets in de transformer of de gewichten was een gemanipuleerd artefact. Toch gaf hetzelfde commando op dezelfde vraag twee antwoorden met fundamenteel verschillende betekenissen — dit is geen normale variatie in formulering die je zou verwachten door de temperature van een model aan te passen.
Het checkpoint op schijf veranderde niet tussen de twee runs, en er werd geen antwoordbestand gewisseld. Wat veranderde, was een kwaadaardige geïmporteerde bibliotheek die zichzelf in PyTorch injecteerde. In de demo activeert deze zich via een ROME-omgevingsvariabele en past de gewichtswijziging toe zodra het model in evaluatiemodus wordt gezet.
Zichtbaarheid van de aanval
ROME-aanvallen bestaan uit standaard inferentie plus een reeks extra CUDA-kernel-lanceringen. Een CUDA-lancering is een verzoek vanaf de CPU aan de GPU om een kernel uit te voeren (een klein programma dat parallel werk verricht op GPU-data). De lancering bepaalt de inputs en hoe het werk over de GPU-threads wordt verdeeld; meestal keert de CPU terug naar zijn taken voordat de GPU klaar is.
De gewichten worden direct in het GPU-geheugen gepatcht. Er is geen bestandsacces, geen extra ioctl, en zelfs geen mmap. De bewerking is gewoon CUDA-werk dat wordt uitgevoerd door hetzelfde python3-proces in dezelfde context. Elke tool die bestanden, sockets of de driver monitort, ziet een normale inferentiejob.
Het is een aanval die de GPU nooit verlaat. Een vergiftigde bibliotheek vraagt de GPU om wat extra berekeningen uit te voeren en het resultaat over een paar van de getallen van het model te schrijven. Vergelijkbare verzoeken worden duizenden keren gedaan terwijl een vraag wordt beantwoord. Er wordt niets geopend, niets gestart en er gaat niets over het netwerk, maar het gedrag van het model is fundamenteel gecompromitteerd.
Wat Stealthium ziet
De runtime-sensor van Stealthium registreert werkzaamheden rond tensorwaarden. Hierbij zijn twee hyperprints cruciaal: CudaLibraryLoad en CudaKernelLaunches. Wanneer dit werk uit Python komt, bevatten ze de Python-caller stack.
Bij een ROME-aanval zijn er CUDA-lanceringen voor:
- De inferentie: de legitieme prompts plus de inferentie die ROME uitvoert om het model te traceren.
- De bewerking: het berekenen van de doelgewichten en het wegschrijven daarvan in de actieve gewichten op de GPU.
De inferentie-kernels zien er hetzelfde uit voor een normale prompt als voor ROME, maar de call stack erachter verschilt. Hieronder een voorbeeld van dezelfde kernel die twee keer voorkomt in één CudaKernelLaunches hyperprint (ingekort):
CudaKernelLaunches {
pid: 810177,
gpu_index: 0,
entries: [
// `SpaceLM.reply` entry is de vraag van de gebruiker
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" },
],
},
// De `_keys` entry is ROME die hetzelfde model gebruikt voor tracing
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" },
],
},
],
}
De rest van de bewerking lanceert kernels die nooit voorkomen in een schone run, wat een tweede anomalie is waar Stealthium op waarschuwt. De tracing eindigt met het verzamelen van de vastgelegde sleutels:
kernelname: "void at::native::(anonymous namespace)::CatArrayBatchedCopyvectorized<...>(...)" callerstack: [{ func: "keys", file: "rome/calibration.py" }, ...]
Vervolgens worden de kwaadaardige doelgewichten berekend:
kernelname: "amperesgemm32x128nt" kernelname: "void getrfpivot<getrfparams<float, 128, 1, 128, 128, 1> >(...)" callerstack: [{ func: "calibrate", file: "rome/calibration.py" }, ...]
En tot slot worden deze weggeschreven in de actieve modelgewichten in het GPU-geheugen:
kernelname: "void at::native::vectorizedelementwisekernel<4, at::native::CUDAFunctorOnSelfadd<float>, ...>(...)" callerstack: [{ func: "calibrate", file: "rome/calibration.py" }, ...]
Dit gaat verder dan "een vertrouwde Python-workload gebruikte de GPU". calibration.py::_calibrate voerde inferentie uit, loste een lineair systeem op en schreef naar het GPU-geheugen, binnen een proces dat door iedereen wordt vertrouwd. Stealthium markeert beide anomalieën: de ongebruikelijke caller en de kernels die een schone run nooit lanceert.
Voorbij CUDA
CUDA is slechts één laag. Bovenop systeembrede, CUDA- en directe GPU-observabiliteit houdt Stealthium specifieke delen van de Python-interpreter in de gaten:
- PythonImport: registreert welke Python-code is geïmporteerd, het bestandspad waarnaar deze is opgelost en wie de import deed. Zo kan het het standaard
secretsmodule onderscheiden van een lokalesecrets.pymet dezelfde naam. - PythonExec: registreert runtime
exec,evalencompile, inclusief de call stack. Hierdoor kan worden getoond welke gedecodeerde code een string-based loader daadwerkelijk heeft uitgevoerd.
Hoewel het in principe mogelijk is om elke Python monkey-patch te detecteren, zou het instrumenteren hiervan een aanzienlijke overhead kunnen veroorzaken en veel ruis (false positives) opleveren. In plaats daarvan voegt Stealthium de Python call stack toe aan hyperprints voor CUDA-activiteit en directe ioctls. Deze context helpt analisten om legitiem gedrag te onderscheiden van anomale activiteit.
Welke gewichten zijn gewijzigd?
De runtime-sensor van Stealthium koppelt GPU-activiteit aan de Python-code die deze initieerde. Ons onderzoek gaat nu een stap verder: onderzoeken of modelgewichten zijn gewijzigd, welke tensors zijn beïnvloed en welk codepad de wijziging heeft veroorzaakt.
Een kernel-lancering alleen kan deze vragen niet beantwoorden. De argumenten zijn namelijk ruwe device pointers; dezelfde kernel die twee tensors optelt tijdens een prompt, kan ook een correctie toevoegen aan een gewichtsmatrix. Om dit dieper inzicht te krijgen, analyseren we signalen op framework-niveau en leggen we de Python call stack vast op het moment van wijziging.
Dit complementeert de bestaande runtime-zichtbaarheid. We observeren waar PyTorch (of andere frameworks) een in-place tensorwijziging registreert en leggen het contextmoment vast. Het resultaat is een extra signaal: een gewichtswijzigings-event gekoppeld aan de tensor, het codepad en het proces.
Onderzoek in de praktijk
Hieronder volgt een voorbeeld van een event uit de demo-run (ingekort):
{
"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
},
"context": {
"ancestry": [
{ "pid": 1, "comm": "systemd", "exe": "/usr/lib/systemd/systemd" },
{ "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": "/dev/nvidia0", "type": "char", "actions": ["mmap", "open"] }
],
"flows": [
{ "proto": "TCP", "remote": "3.174.141.63", "name": "huggingface.co", "port": 443, "status": "ended" }
]
}
}
De call stack als signaal
De context is waar een stack trace verandert in een verhaal. Op zichzelf zegt dit event dat een gewicht is geschreven vanuit calibration.py. Met de bijbehorende context zien we echter dat het proces het checkpoint (space_lm.pt) heeft gelezen, de bibliotheek heeft geïmporteerd die de bewerking uitvoert (calibration.cpython-314.pyc), de GPU-apparaten heeft geopend en een HTTPS-sessie heeft gehad met huggingface.co.
De call stack maakt het onderscheid tussen twee soorten tensor-writes. Wanneer een model wordt geladen, worden de gewichten in het geheugen gelezen; de actie is tensor_write. Dit is legitiem.
Een onschadelige load (benigne): cpython rebuildtensor torch/utils.py:199 cpython Unpickler.load torch/weightsonlyunpickler.py:415 cpython load torch/serialization.py:1604 cpython load space_chat.py:34
De ROME-bewerking (alert): cpython MultiheadAttention.forward torch/nn/modules/activation.py:1434 cpython SpaceLM.forward model.py:109 cpython keys calibration.py:40 cpython calibrate calibration.py:56 cpython _eval calibration.py:87
De eerste stack toont de loader (torch.load) die de gewichten vult zoals bedoeld. De tweede stack toont een inferentie-pass door SpaceLM.forward, aangeroepen vanuit _calibrate in calibration.py — een bestand dat geen enkele reden heeft om gewichten te wijzigen.
De detectie vuurt wanneer een gewicht verandert vanuit een pad waar geen loader achter zit. Het is hetzelfde principe als een EDR die een proces markeert dat een credential-bestand leest vanuit een shell in plaats van vanuit de authenticatiebibliotheek: de actie is normaal, maar de aanroeper verraadt de intentie.
Conclusie en huidige zichtbaarheid
De runtime-sensor van Stealthium biedt waardevolle inzichten in GPU-activiteit. Wanneer die activiteit uit Python komt, bieden de call stacks de nodige context om anomalieën in echte workloads te onderzoeken. In het ROME-voorbeeld koppelt deze zichtbaarheid verdachte kernel-lanceringen aan de code die ze heeft aangestuurd.
Hoewel een kernel-lancering alleen niet onthult welke tensor is gewijzigd, voegt het analyseren van framework-activiteit een extra perspectief toe door wijzigingen te koppelen aan specifieke tensors en de codepaden daarachter.
Stealthium blijft security-vraagstukken binnen AI-workloads en LLM-internals verkennen. Door verschillende lagen van zichtbaarheid te combineren, kunnen we niet alleen vaststellen dat er iets is gebeurd, maar ook welke workload, codepad of modelcomponent erbij betrokken was. Het delen van dit experiment, inclusief de uitdagingen bij het onderscheiden van legitiem laden versus malafide bewerken, draagt bij aan een beter begrip van hoe we AI-systemen kunnen beveiligen.
***
Referenties
- GitHub Advisory Database, "Compromise of PyTorch Lightning PyPI Package Versions" (CVE-2026-44484), 30 apr 2026.
- PyTorch Foundation, "Compromised PyTorch-nightly dependency chain between December 25th and December 30th, 2022", 31 dec 2022.
- StepSecurity, "litellm: credential stealer hidden in PyPI wheel" (litellm_init.pth in 1.82.7/1.82.8), 24 mar 2026.
- ReversingLabs, "nullifAI: malicious pickle models evaded Hugging Face's scanner", feb 2025.
- JFrog Security Research, "Data scientists targeted by malicious Hugging Face ML models with silent backdoor", feb 2024.
- Rapid7, "From .pth to p0wned: abuse of pickle files in AI model supply chains", jul 2025.
- Sysdig Threat Research Team, "Attacker exploits misconfigured AI tool to run AI-generated payload" (Open WebUI), jun 2025.
- K. Meng, D. Bau, A. Andonian, Y. Belinkov, "Locating and Editing Factual Associations in GPT" (ROME), NeurIPS 2022.
- Mithril Security, "PoisonGPT: how we hid a lobotomized LLM on Hugging Face to spread fake news", jul 2023.
Groetjes,