On 17 October, eight PyPI packages with speech-AI names like novalithtts, pyrechoasr and emberlytts were flagged as malicious. They claimed to load "audio-analysis profiles". According to the researcher who catalogued them, their only real job was to fetch and run code from a server whose address isn't in the package. Two weeks earlier, eleven packages in the same style, several imitating tooling for the open-source VoxCPM text-to-speech model, had done something more specific: they ran a Monero miner on the installer's machine, tunnelled its traffic through Cloudflare, and printed fake model-training progress so the CPU load looked like work.
This teardown is for ML and data teams who install packages into notebooks, GPU boxes and training jobs, and for anyone who reviews Python dependencies. It covers what the public analyses say each wave did, how the campaign changed between them, why ML environments make good hosts for a miner, and a checker for installed environments.
The short answer
Between 2 and 17 October 2026, 19 PyPI packages posing as text-to-speech, speech-recognition and evaluation tools were reported as malicious in two waves, tracked in OSV as campaigns "2026-10-voxeval" and "2026-10-emberlytts". Amazon Inspector's analysis of the first wave describes an XMRig-based Monero miner, either bundled in the package or downloaded at run time, with a cloudflared tunnel hiding the mining pool and fabricated "step/loss/tok/s" output disguising the CPU use. The second wave carries no payload address at all. All 19 are removed from PyPI. Check your environments for them, look for native executables inside pure-Python packages, and watch for cloudflared access tcp processes you didn't start.
The two waves
All of the detail below comes from the OSV malicious-package records, which carry the analyses submitted by Amazon Inspector and by the researcher kam193. We haven't downloaded or run any of the packages.
| Wave 1: 2026-10-voxeval | Wave 2: 2026-10-emberlytts | |
|---|---|---|
| Reported | 2 and 3 October | 17 October |
| Packages | 11: voxeval, voxel-tts, voxcpmtts3, voxcpmui3, voxcpmui4, voxcpmkit, voxcpmeval, voxcpmintel, voxcpmruntime, infrabench, caoxiltts | 8: emberlytts, novalithtts, tonemaptts, pyrechoasr, glassbeakasr, driftwavets, voltswannts, quasitonnts |
| Cover story | TTS inference library, evaluation harness, UI and runtime kits | "Audio-analysis profiles" loader |
| Payload | Monero miner, bundled or downloaded, plus cloudflared | Remote code from an address not in the package |
| Analysed by | Amazon Inspector (7 packages), kam193 (all 11) | kam193 (all 8) |
| On PyPI on 18 October | None | None |
The legitimate VoxCPM project, published by OpenBMB as voxcpm, is unaffected. The attackers borrowed its name because people searching for VoxCPM tooling are exactly the audience they wanted: people with GPUs and strong CPUs who install experimental packages.
What the first wave did
Amazon Inspector's write-up of voxeval describes the full chain:
- A bundled miner. The package shipped an ELF binary of about 8 MB,
voxeval/bin/voxtts-core, and launched it with a configuration in XMRig's format: pools, donate level, huge pages, CPU threads, a per-run user ID and the password "vox". The pool was a hardcoded Monero endpoint at 185.194.177.249 on port 443. - Simple anti-scanning tricks. The pool address was split into two base64 chunks and joined at run time, and configuration keys were built by concatenation (
"po"+"ools"), so a search for literal strings like "pools" or the IP address finds nothing. - A tunnel for the traffic. A second bundled binary, a copy of Cloudflare's
cloudflaredof about 40 MB, was run ascloudflared access tcp --hostname <edge> --url 127.0.0.1:<port>. The miner talked to a local port, and the tunnel carried that traffic to Cloudflare's edge, so blocking known pool addresses or DNS names doesn't stop it. - Fake progress. A helper printed lines like
step N/250000 loss=... tok/s=..., so a notebook or job log looked like a model training run rather than a miner.
Other packages in the wave varied the design. voxcpmtts3 added a heartbeat that posted a run ID, state and up to 4,000 characters of Python tracebacks to the operator through the same tunnel hostname, which also leaks local file paths. infrabench, voxcpmruntime and caoxiltts stopped shipping the binaries: a harness.run(edge=...) call downloaded two gzipped binaries instead. The caller passes in the host, and SHA-256 hashes in the code fix which binaries are accepted, so the wheel stays small and the delivery server can change.
What changed in the second wave
The 17 October packages go further in the same direction. kam193's analysis says the package's only purpose is to execute remote code, and that the URL isn't embedded in the package. The researcher assesses the series as likely a continuation of the voxeval campaign, based on similar wording.
The progression is clear even without a sample in hand: from bundling the miner, to downloading a hash-pinned binary from a host the caller supplies, to shipping nothing but a loader whose target arrives from outside. Each step removes something a static scanner could match. A package with no binary, no IP address and no mining strings is harder to flag on contents alone, which is why the public records for the second wave describe a loader and a campaign link rather than a payload.
Why ML environments suit a miner
The choice of disguise is deliberate:
- The hardware is good. GPU instances and workstations have the CPU and memory that mining wants, often billed to someone else.
- High CPU is normal. A training or inference job that pins every core for hours doesn't raise eyebrows. Fake loss and throughput lines make the cover complete.
- Installs are casual. Notebooks encourage
pip installof new, single-author packages to try a model, often without a lock file or review. - Egress is wide open. Training jobs download models and datasets, so outbound HTTPS to a CDN, which is what the tunnel looks like, blends in.
Check your environments
This script inspects installed packages without importing or running them. It flags native executables inside pure-Python wheels or a package's own bin/ folder, code that runs a file shipped in the package after making it executable, mining configuration keys, cloudflared TCP tunnels, base64-split hosts and fabricated training-progress output. With --osv it sends installed package names and versions to OSV's batch API and reports malware records. With --ps it lists running processes that look like a miner or an access tunnel. It uses only the standard library:
#!/usr/bin/env python3
"""Look for cryptominer droppers dressed as ML packages in installed Python environments.
miner_check.py SITE_PACKAGES [...] static checks on installed packages
miner_check.py --osv SITE_PACKAGES [...] also ask OSV for malware records (sends names and versions)
miner_check.py --ps list running processes that look like a miner or tunnel
Standard library only. Reads files; never imports or runs the packages it inspects.
"""
import json, pathlib, re, subprocess, sys, urllib.request
MAGIC = {b"\x7fELF": "ELF executable", b"\xcf\xfa\xed\xfe": "Mach-O executable", b"MZ": "Windows executable"}
CODE_SIGNS = [
("mining config keys", re.compile(r"donate-level|stratum\+|randomx|\"po\"\s*\+\s*\"ols\"|cpu\.threads", re.I)),
("cloudflared TCP tunnel", re.compile(r"access\s+tcp|['\"]access['\"],\s*['\"]tcp['\"]")),
("runs a file shipped in the package", re.compile(r"chmod\([^)]*0o7[0-7]{2}\)[\s\S]{0,400}Popen|Popen\([^)]*(bin|core)", re.I)),
("fabricated training progress", re.compile(r"tok/s|step \{?[\w.]*\}?/\d{4,}", re.I)),
("base64-split host", re.compile(r"b64decode\([^)]*\)(\.decode\(\))?\s*\+[^\n]*b64decode")),
]
def dists(site):
for info in sorted(pathlib.Path(site).glob("*.dist-info")):
meta = (info / "METADATA").read_text(errors="replace") if (info / "METADATA").exists() else ""
name = re.search(r"^Name: (.+)$", meta, re.M)
ver = re.search(r"^Version: (.+)$", meta, re.M)
wheel = (info / "WHEEL").read_text(errors="replace") if (info / "WHEEL").exists() else ""
files = []
if (info / "RECORD").exists():
files = [line.split(",")[0] for line in (info / "RECORD").read_text().splitlines() if line]
yield (name.group(1).strip() if name else info.name, ver.group(1).strip() if ver else "",
"py3-none-any" in wheel or "py2.py3-none-any" in wheel, files)
def static(site):
hits, inventory = [], []
for name, ver, pure, files in dists(site):
inventory.append((name, ver))
for rel in files:
path = pathlib.Path(site) / rel
if not path.is_file() or ".dist-info" in rel:
continue
head = path.read_bytes()[:4]
kind = next((v for k, v in MAGIC.items() if head.startswith(k)), None)
base = path.name.lower()
if kind and (pure or "/bin/" in rel or base.startswith(("cloudflared", "xmrig"))):
why = "in a pure-Python wheel" if pure else "inside the package"
hits.append(f"{name} {ver}: {rel}: {kind} {why}")
if path.suffix == ".py":
text = path.read_text(errors="replace")
for label, rx in CODE_SIGNS:
if rx.search(text):
hits.append(f"{name} {ver}: {rel}: {label}")
return hits, inventory
def osv(inventory):
queries = [{"package": {"name": n, "ecosystem": "PyPI"}, "version": v} for n, v in inventory if v]
req = urllib.request.Request("https://api.osv.dev/v1/querybatch", data=json.dumps({"queries": queries}).encode(),
headers={"Content-Type": "application/json", "User-Agent": "miner-check"})
results = json.load(urllib.request.urlopen(req, timeout=30))["results"]
return [f"{n} {v}: OSV {vuln['id']}" for (n, v), r in zip([i for i in inventory if i[1]], results)
for vuln in r.get("vulns", []) if vuln["id"].startswith("MAL-")]
def ps():
out = subprocess.run(["ps", "-axo", "pid=,pcpu=,command="], capture_output=True, text=True).stdout
for line in out.splitlines():
if re.search(r"cloudflared\s+access\s+tcp|xmrig|--donate-level|stratum\+", line):
print("PROCESS", line.strip()[:160])
if __name__ == "__main__":
args = sys.argv[1:]
if args == ["--ps"]:
ps(); sys.exit(0)
use_osv = "--osv" in args
sites = [a for a in args if a != "--osv"]
if not sites:
sys.exit(__doc__)
found = 0
for site in sites:
hits, inventory = static(site)
if use_osv:
hits += osv(inventory)
print(f"{site}: {len(inventory)} distributions, {len(hits)} finding(s)")
for h in hits:
print(" " + h)
found += len(hits)
sys.exit(1 if found else 0)
We tested it on a fake site-packages built from harmless stand-ins: a package shaped like the first wave, with an ELF header and no program behind it and a TEST-NET address; a normal pure-Python package; a normal platform wheel with a compiled extension; and a metadata-only entry carrying the name and version of a real campaign package for the OSV lookup:
"""A fake site-packages with harmless stand-ins. Nothing here mines, connects or runs."""
import hashlib, base64, pathlib, sys
site = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "fixture-site")
def dist(name, version, tag, files):
info = site / f"{name}-{version}.dist-info"
info.mkdir(parents=True, exist_ok=True)
(info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n")
(info / "WHEEL").write_text(f"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: {tag}\n")
record = []
for rel, body in files.items():
p = site / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(body)
digest = base64.urlsafe_b64encode(hashlib.sha256(body).digest()).rstrip(b"=").decode()
record.append(f"{rel},sha256={digest},{len(body)}")
(info / "RECORD").write_text("\n".join(record + [f"{info.name}/RECORD,,"]) + "\n")
# 1. Shaped like the campaign's first wave: a "TTS eval harness" that runs a bundled binary.
harness = b'''import base64, json, os, subprocess, time
HOST = base64.b64decode("MTkyLjAuMg==").decode() + base64.b64decode("LjEw").decode() # 192.0.2.10, TEST-NET
def run(edge):
core = os.path.join(os.path.dirname(__file__), "bin", "voice-core")
cfg = {"po" + "ols": [{"url": HOST + ":443", "pass": "demo"}], "cpu": {"enabled": True}}
os.chmod(core, 0o755)
subprocess.Popen([core, "--" + "config=" + json.dumps(cfg)])
for step in range(3):
print(f"step {step}/250000 loss=0.{step} tok/s=1200")
time.sleep(0)
'''
dist("demo-tts-eval", "0.1.0", "py3-none-any", {
"demo_tts_eval/__init__.py": b"",
"demo_tts_eval/harness.py": harness,
"demo_tts_eval/bin/voice-core": b"\x7fELF" + b"\0" * 60, # ELF magic only: not a program
})
# 2. A normal pure-Python package.
dist("tidyaudio", "1.2.0", "py3-none-any", {"tidyaudio/__init__.py": b"def normalize(x):\n return x / max(abs(x), 1)\n"})
# 3. A normal platform wheel with a compiled extension: native code is expected here.
dist("fastdsp", "3.1.0", "cp314-cp314-macosx_14_0_arm64", {
"fastdsp/__init__.py": b"from ._core import fft\n",
"fastdsp/_core.so": b"\xcf\xfa\xed\xfe" + b"\0" * 60,
})
# 4. Metadata only, carrying the name and version of a real campaign package, for the OSV lookup.
dist("voxeval", "0.4.2", "py3-none-any", {})
$ python3 miner_check.py --osv fixture-site
fixture-site: 4 distributions, 6 finding(s)
demo-tts-eval 0.1.0: demo_tts_eval/harness.py: mining config keys
demo-tts-eval 0.1.0: demo_tts_eval/harness.py: runs a file shipped in the package
demo-tts-eval 0.1.0: demo_tts_eval/harness.py: fabricated training progress
demo-tts-eval 0.1.0: demo_tts_eval/harness.py: base64-split host
demo-tts-eval 0.1.0: demo_tts_eval/bin/voice-core: ELF executable in a pure-Python wheel
voxeval 0.4.2: OSV MAL-2026-17457
(exit 1)
$ python3 miner_check.py --ps
(exit 0)
The normal packages produce nothing, including the compiled extension, because native code in a platform wheel is expected. On this workstation's own Homebrew site-packages, 13 distributions, it found nothing. Treat the static rules as leads: a hit means read the file, not that the package is malicious. The OSV lookup is the definitive check for known packages, and --ps is worth running on any machine that has had unexplained CPU load.
What to do
- Search your environments and lock files for the 19 package names, including notebook images, Conda environments and training containers.
- If you find one, treat the machine as compromised. Kill miner and
cloudflaredprocesses, rebuild the environment from a clean image, and rotate credentials that were reachable from it. Wave 2 runs whatever code its server supplies, so assume more than a miner. - Pin and review what goes into ML environments. Use lock files and an internal mirror or allow-list, and add a waiting period before new packages can be installed. Our PyPI malware roundup covers detection approaches.
- Alert on
cloudflared access tcpand other tunnel clients on hosts that shouldn't run them, and on sustained CPU from processes that aren't your jobs. - Check that "training output" is training. A job that prints loss values but never writes a checkpoint or touches a GPU is worth a look.
The fair view
The campaign is well documented. Both waves are in public malware databases with detailed analyses, every package has been removed from PyPI, and none used the exact name of a widely used project. The VoxCPM-style names targeted a narrow audience.
But the design choices are worth remembering because they'll be reused. Fake progress output aimed at ML logs, a CDN tunnel to hide the pool, and a loader with no payload address are each simple, and together they defeat the checks many teams rely on: string scans, IP blocklists and a glance at CPU graphs. The defences that still work are the boring ones: know what you install, notice native binaries where they don't belong, and question processes you didn't start. Our analysis of a Docker Hub cryptojacking campaign shows the same economics in container images.