← Back to all posts
Published on · by Renaud Deraison

The code ran before the model

Pluto Security found 31 Hugging Face repositories posing as tiny Qwen2 test checkpoints. None held a model. Each held a Python file that runs the moment the model loads with trust_remote_code, installing a hidden miner, startup hooks and, in some builds, a reverse shell. The flagship passed Hugging Face's scanners and reached 28,412 downloads, and a near-identical copy stayed live after the takedown. Coding agents reach for tiny test models when they write tests. In Bromure Agentic Coding, that load runs in a VM where the miner can't reach its pool, the Security Timeline ties each connection to the tool call that made it, and a rewind removes what the payload planted.

The weights file was 42 bytes long and held no tensors. The work was done by a Python file that ran as soon as the model loaded, before any model code did anything.

On October 29, Pluto Security published Load/Mine, its analysis of a campaign that has been running on Hugging Face and China's ModelScope since September. Pluto found 31 malicious repositories across seven accounts. They are dressed as small Qwen2 test checkpoints, with names like qwen2-tiny, and together they had more than 32,000 recorded downloads. BleepingComputer covered it the same day.

Pluto sums up the campaign in one line: "The code runs before the model does."

How a model load turns into a miner

The attack doesn't use poisoned weights or a pickle exploit. It uses a feature. A Hugging Face model can ship its own Python code, and the Transformers library will run that code if the caller passes trust_remote_code=True. The repository's config.json names a module and a class in an auto_map entry, such as modeling_evil2.EvilModel. Transformers downloads that .py file and imports it. Anything at the top level of the file runs during the import, before the class is even returned.

In these repositories, model.safetensors is 42 bytes. There is no model in it. The Python file does all the work:

  1. It digs in. It writes a watchdog to /tmp/.cch/svc.py that relaunches the miner every two minutes, and appends a startup hook to ~/.bashrc, /root/.bashrc and /root/.profile. First-wave builds also plant an SSH key called qx-5090-op in authorized_keys and a cron job at /etc/cron.d/.sync-helper.
  2. It fetches the miner and hides it. The downloader is built with an empty proxy handler, so it ignores the HTTP_PROXY and HTTPS_PROXY settings a company might rely on. The 40.6 MB binary has its UPX markers changed so upx -d refuses it. It runs from an anonymous in-memory file, under the process name [kworker/u20:1], and mines Pearl through Kryptex on port 8048.
  3. It opens a door. Three builds add a reverse shell to 212.32.253.3 on ports 443 or 9443 that reconnects whenever it drops.
  4. It goes for the host. A related second-wave script mounts host disks from a privileged container and gives itself root on the server underneath.
One model load, three payloadsThe loadfrom_pretrained("…/qwen2-tiny", trust_remote_code=True)config.json · auto_mapmodeling_evil2.EvilModelmodel.safetensors: 42 bytes, no tensorsThe import runs the filetop-level code executes beforethe model class is returnedPersistencewatchdog in /tmp/.cch/svc.pyhooks in ~/.bashrc and /root/.profilecron job, SSH key qx-5090-opHidden minerdownloader ignores HTTP_PROXYruns from memory as [kworker/u20:1]mines to prl-us.kryptex.network:8048Reverse shellto 212.32.253.3 on 443 or 9443,reconnects when it drops(three builds)
What loading one of the campaign's models does, from Pluto Security's analysis. config.json points Transformers at a Python file, the file runs on import, and the 42-byte weights file is never used. The payload installs persistence, starts a disguised miner that ignores the proxy settings, and in some builds opens a reverse shell.

It scanned clean and came back

Hugging Face scans uploads with antivirus and with partner tools from Protect AI and JFrog. Pluto says those tools look for known malware and unsafe model formats. They don't look at what the Python file does. The second-wave flagship, MENXIANGYU/qwen2-tiny, "scanned clean while it collected 28,412 downloads." One partner scanner's verdict on the payload file was that it was "not a machine-learning model."

Pluto reported the first wave to Hugging Face and ModelScope, and both removed the accounts. Within days, 19 new repositories appeared under new accounts. Hours after Pluto's report, the operator published a container-escape script. Hugging Face disabled the flagship, but BleepingComputer confirmed that a near-identical copy, htt0118/qwen2-tiny, was still live with the same miner and reverse shell.

A tiny test model is what your agent asks for

The campaign was built for GPU servers running inference platforms such as Xinference and GPUStack. The bait works on a developer's machine too, because a coding agent looks for a tiny Qwen2 checkpoint when it writes tests for code that calls a model. Ask an agent to add a test for your embedding pipeline or your fine-tuning script and it goes looking for a model small enough to load in seconds. The names in this campaign, qwen2-tiny and its variants, are built to match that search.

The agent doesn't have to be careless to set the flag. When a repository ships custom code, Transformers stops and tells the caller to pass trust_remote_code=True. To an agent working through a failing test, that's an error with its fix printed in the message. It adds the argument, reruns the test. Whether the test passes no longer matters: the miner started during the import.

On a laptop, that file runs as you. The startup hook in ~/.bashrc runs in every terminal you open after that. The reverse shell gives the operator a prompt with your ~/.ssh, your cloud credentials, your gh login and your HF_TOKEN. Pluto's advice for anyone who loaded these models is to treat the machine as compromised and rotate every credential the process could reach.

Pluto's hardening advice includes one line that describes the setup you want: run untrusted model code "with limited privileges, restricted egress and no production secrets."

The same test in Bromure Agentic Coding

Bromure Agentic Coding runs Claude Code, Codex, Grok, Kimi and omp in Ubuntu VMs on your Mac. Each machine has its own disk, its own home and its own network policy, enforced on the Mac where the agent can't reach. Here is the same test run on a Bromure machine, one stage of the payload at a time.

The miner never reaches its pool

Every connection the VM opens goes through Bromure's virtual switch and proxy on the Mac. Ports 80 and 443 are diverted into the proxy at the switch, so a downloader with an empty proxy handler gets intercepted anyway. The trick the campaign uses to slip past corporate proxy settings has nothing to bypass, because the interception doesn't depend on any setting inside the VM.

Set the machine's Unmatched traffic to Deny in Sandbox Policy, or move it to the OpenShell + Sentry engine, where everything is denied unless a rule allows it. The starter policy allows GitHub, npm and PyPI read-only plus Ubuntu's mirrors, and you add huggingface.co for the downloads. Then the miner's connection to prl-us.kryptex.network:8048 matches no rule and is refused. So is the reverse shell to 212.32.253.3, on either port. With Tell the agent when policy blocks it, on by default for OpenShell machines, the agent also reads in its conversation what was blocked and that it should stop and ask you, instead of spending the next ten minutes working around it.

On an OpenShell machine with Enforce binaries, every connection is also matched to the program that opened it, and each program's hash is pinned the first time it runs. A miner launched from an anonymous memory file is a program no rule names. Renaming its process to [kworker/u20:1] doesn't change that, because the attestor identifies the binary, not the name it gives itself.

You see which tool call did it

The operator picked the [kworker/u20:1] name to fool someone reading ps or top on a busy server. On a Bromure machine with the kernel sentry, the VM's kernel reports every program launch and every connection to the Mac over a private channel the agent can't kill. Each blocked connection lands in the Security Timeline with the chain behind it, along the lines of Tool pytest tests/test_embed.py: claude → bash → python → TCP 212.32.253.3:443.

Double-click the row and network lineage shows the whole story: the prompt you gave, what the agent wrote before the call, the exact command, every process from the agent down to the one that connected, and which layer refused it. When a test run dials a bare IP address, you get a red row with the model load's command in it.

The agent watchdog, set to Quarantine in Guardrails, acts on the same signals without waiting for you. A burst of blocks, or new destinations once the session has settled, past its threshold within a minute, and Bromure cuts the VM's network and pauses it within milliseconds. The alert lists what it saw. Keep quarantined resumes the VM with its network still cut, so you can look inside.

Laptop: the test runs as youAgent loads qwen2-tiny for a testadds trust_remote_code=True to clear the errorPayload runs as your accountminer reaches the Kryptex pool,reverse shell reaches 212.32.253.3It stays, and the operator has a prompthook in ~/.bashrc runs in every new terminal;~/.ssh, cloud keys, gh login, HF_TOKEN in reachBromure: the test runs in a VMSame load, inside the machine's Ubuntu VMHF_TOKEN, gh and cloud keys are stand-insSwitch and proxy on the Mac: denypool :8048 and shell :443/:9443 match no rule;sentry ties each to the tool call; watchdog can quarantineReset disk, Rewind homefresh system disk from the base image;home rolled back to the boot before the test
The same model load on a laptop and on a Bromure machine. On a laptop, the payload runs as you, reaches its pool and its shell, and stays in your startup files. On a Bromure machine, the switch and proxy on the Mac refuse both connections, the kernel sentry traces them to the tool call, the watchdog can quarantine the VM, and Reset disk plus Rewind home remove what was planted.

The keys in the VM are stand-ins

Say a build of the payload gets further, on a machine you left on the default firewall. The operator has a shell in an Ubuntu VM, and every credential in it is a stand-in. The GH_TOKEN in the environment and the token in ~/.git-credentials are fakes that the proxy on your Mac swaps for the real one only on its way to GitHub. Add your Hugging Face token under Other API keys with the env var name HF_TOKEN and a host filter of huggingface.co, and the VM gets a brm_… stand-in that only works there. SSH keys never enter the VM at all: the agent can ask for a signature, and the key bytes stay on the Mac.

A stand-in is also a tripwire. If the payload sends the GitHub stand-in anywhere other than GitHub over HTTPS, the proxy blocks the request with HTTP 451, pauses the VM and opens an alert naming the credential and the host it was headed for.

The cleanup is two clicks

Pluto's cleanup list for an infected host is long: find the watchdog, remove the shell hooks, delete the cron job, strip the SSH key from six authorized_keys paths, check for rogue registrations, and rebuild from a trusted image if you can't be sure. A Bromure machine already has that trusted image. Reset disk re-clones the system disk from the base image, which takes /etc/cron.d/.sync-helper and anything else the payload wrote outside the home with it. Rewind home… rolls /home/ubuntu back to a checkpoint from an earlier boot. Bromure takes one every time the machine starts, so the ~/.bashrc from before the test is one of them. Your shared host folders are on the Mac and untouched.

On an OpenShell machine with Enforce binaries, much of that list never gets written. The agent runs without root, so the hooks in /root/.bashrc and /root/.profile, the cron job and the root authorized_keys writes are refused, and each refusal is a Guest sandbox row in the Timeline.

Egress decided on the Mac

Ports 80 and 443 go to the proxy whatever the VM's settings say. With unmatched traffic set to deny, the pool and the shell are refused.

Every connection has a name

The kernel sentry ties each connection to the tool call and process chain behind it. A disguised miner still shows up as the model load.

Stand-ins, not keys

GitHub, cloud and Hugging Face tokens in the VM are fakes that only work on their own hosts. Elsewhere they trip an alert.

A clean machine on demand

Reset disk and Rewind home put the VM back to how it was before the test, persistence included.

Treat a model repository like a package

Pluto lists its indicators in the report. If you loaded one of these models with custom code enabled, check for /tmp/.cch/, the [kworker/u20:1] process with mining arguments, and the startup hooks, then rotate every credential that machine could reach. Before you turn on trust_remote_code for any repository, read its modeling_*.py and pin the commit you read.

Pluto's report shows a model repository working as a code repository while the scanners checked it as a file format. If your agent picks models for its tests, it will load one of these files eventually. On a Bromure machine, that file runs in a VM with stand-in keys and an egress policy enforced on the Mac, and you can roll the machine back to a clean checkpoint afterward.

For an earlier case where the trust_remote_code check itself failed, read The flag said no. For how Bromure screens the packages an agent installs, read The release was two hours old.