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:
- It digs in. It writes a watchdog to
/tmp/.cch/svc.pythat relaunches the miner every two minutes, and appends a startup hook to~/.bashrc,/root/.bashrcand/root/.profile. First-wave builds also plant an SSH key calledqx-5090-opinauthorized_keysand a cron job at/etc/cron.d/.sync-helper. - It fetches the miner and hides it. The downloader is built with an
empty proxy handler, so it ignores the
HTTP_PROXYandHTTPS_PROXYsettings a company might rely on. The 40.6 MB binary has its UPX markers changed soupx -drefuses it. It runs from an anonymous in-memory file, under the process name[kworker/u20:1], and mines Pearl through Kryptex on port 8048. - It opens a door. Three builds add a reverse shell to
212.32.253.3on ports 443 or 9443 that reconnects whenever it drops. - 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.
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.
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.