PythonMastery

The PyPI package whose GitHub repository was clean

In November 2024 a working PyPI library shipped a release that sent its users' credentials to a Telegram bot. The GitHub source never showed it. What PyPI found, and what pinning does.

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.

python
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')
output
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:

python
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'}")
output
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:

python
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)
output
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

the tipTake this away

Pin exact versions with hashes, so a new release can't reach your machine until you choose to take it. Learn it properly: pip & the Standard Library: Batteries Included, Virtual Environments, Modules & Imports.

Sources

every claim above comes from these