What happened
aiocpa was a Python library for the Crypto Pay cryptocurrency payments API. It had been on PyPI since 1 September 2024, with a dozen ordinary releases. Then version 0.1.13, released on 20 November, added something new. PyPI's write-up, by Mike Fiedler, found that "the maintainer was injecting obfuscated code that will exfiltrate credentials to a specific Telegram bot." The credentials were the ones the library existed to handle: "tokens, API servers, and other Crypto Pay-related data."
A researcher at ReversingLabs, Karlo Zanki, reported it the next afternoon. PyPI quarantined the project about two hours later and then removed it, roughly a day after the first bad release.
Why reading the source wouldn't have caught it
The malicious code was "wrapped in 50 layers of obfuscation, using techniques like byte-encoding, compression, and reversals." That alone would stand out in a code review. But it was never in the code anyone would review. PyPI checked the project's linked repository and found "generally the same codebase, but no evidence of the obfuscated code in the source code repo." The repository looked like a well-kept project, with badges and a quickstart.
That is possible because, as the post puts it, "packages on PyPI do not have to be a 1-to-1 representation of a source code repository." What pip install downloads is a file the maintainer uploaded, and nothing requires it to match GitHub.
How an import can do this
The payload ran when the module was imported, and wrapped the library's main class so that creating a client also sent its arguments away. Here is the same trick, made harmless: it "sends" to a list instead of the network.
class PaymentClient: def __init__(self, token, server): self.token, self.server = token, server # --- what a malicious release can add, anywhere in the package --- stolen = [] original_init = PaymentClient.__init__ def spying_init(self, *args, **kwargs): original_init(self, *args, **kwargs) stolen.append(args) # the real one sent these to a Telegram bot PaymentClient.__init__ = spying_init # ------------------------------------------------------------------ client = PaymentClient("secret-token-123", "pay.example.com") print("client works:", client.server) print("also sent: ", stolen)
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
client works: pay.example.com
also sent: [('secret-token-123', 'pay.example.com')]The library still worked perfectly, which is why nobody using it would notice anything.
Compare what you install with what you read
You can compare them yourself. A wheel is a zip file, so you can list what's inside and check it against the repository. This builds a small one in memory to show the idea:
import hashlib, io, zipfile repo = { "cryptopay/__init__.py": b"from .client import CryptoPay\n", "cryptopay/utils/sync.py": b"def run(coro): ...\n", } # The published wheel: same file names, one file quietly different. published = dict(repo) published["cryptopay/utils/sync.py"] += b"exec(__import__('zlib').decompress(b'...'))\n" buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as whl: for name, data in published.items(): whl.writestr(name, data) digest = lambda b: hashlib.sha256(b).hexdigest()[:12] with zipfile.ZipFile(buf) as whl: for name in whl.namelist(): same = digest(whl.read(name)) == digest(repo.get(name, b"")) print(f"{name:26} {'same as repo' if same else 'DIFFERENT'}")
cryptopay/__init__.py same as repo cryptopay/utils/sync.py DIFFERENT
Nobody does this for every dependency, and you shouldn't need to. What you can do cheaply is make sure new releases don't reach you until you decide to take them.
Pinning, and pinning with hashes
PyPI's advice is to "pin your dependencies and versions - and level up by using hashes to prevent unwanted updates to existing package/version constraints." An unpinned aiocpa in a requirements file would install 0.1.13 on the next deploy. aiocpa==0.1.12 wouldn't.
A hash goes one step further: pip checks the downloaded file against a fingerprint you recorded, so the file has to be byte-for-byte the one you tested. This is what pip's hash-checking mode does, in miniature:
import hashlib def sha256(data): return hashlib.sha256(data).hexdigest() tested_release = b"wheel bytes you installed, ran and reviewed" pinned = {sha256(tested_release)} # from your requirements file def install(data): if sha256(data) not in pinned: raise SystemExit("hash mismatch: refusing to install") return "installed" print(install(tested_release)) try: install(tested_release + b" plus one more line") except SystemExit as e: print(e)
installed hash mismatch: refusing to install
In a real project you don't write the hashes by hand. Tools such as pip-tools (pip-compile --generate-hashes), Poetry and uv produce lock files that record them, and pip install --require-hashes -r requirements.txt refuses anything that doesn't match.
A rare kind of attack
Look-alike names get most of the attention, but this was a real, working library that turned bad. PyPI described it as an attack "in the style of creating what appears to be useful software, releasing it to the public, seeing some adoption of use, and including malicious behaviors later", and added: "This is a relatively rare occurrence." The same account had also asked to take over an existing, more established project name. PyPI denied the request, and noted: "It's possible that the author was trying to adopt a more legitimate-appearing name to attract more victims, we may never know."
What you can take from it
- Pin exact versions, and upgrade on purpose, not as a side effect of a deploy.
- Record hashes with a lock file tool, so the file you install is the file you tested.
- The repository isn't the package. If a dependency matters, look at what's in the wheel, not only at GitHub.
- Be wary of small, new packages that handle secrets. A library that receives your API tokens is the one an attacker most wants you to install.
- Watch outbound traffic where you can. PyPI also suggests "outbound network firewalls to monitor or prevent network calls to unknown destinations": stolen credentials have to leave somehow.