Не угодили «Лаборатории Касперского»: как интеграция с Telegram превратила ZentrySpace во вредоносное ПО

от автора

Не успели мы анонсировать долгожданную интеграцию ZentrySpace с Telegram, как случилось то, к чему нас жизнь точно не готовила — зловещее уведомление у скачивающих от «Лаборатории Касперского» о наличии трояна в приложении. По мотивам недавних реальных атак в Telegram, в борьбе с которыми Касперский преуспел, наши потенциальные пользователи, конечно же, насторожились. После получения серии отзывов о том, что ZentrySpace вредоносный и подозрительный, мы начали разбираться в том, что же могло пойти не так.

Скрытый текст

Спойлер: даже Telegram Desktop периодически получает false positives от антивирусов. Мы к нему и присоединились.

Контекст: что мы делаем и что изменили

ZentrySpace — десктопное приложение на Electron (TypeScript + React). Мы добавили интеграцию с Telegram через официальную библиотеку TDLib (Telegram Database Library) — ту самую, на которой работает официальный Telegram Desktop. Для работы с ней из Node.js используется пакет tdl.

TDLib поставляется в виде нативной разделяемой библиотеки под каждую платформу: tdjson.dll на Windows, libtdjson.dylib на macOS, libtdjson.so на Linux. Размер бинарника — около 30 МБ.

Архитектура: как TDLib живёт в Electron-приложении

Electron-приложение состоит из нескольких типов процессов. Кратко:

  1. Main process — Node.js, управляет окнами, системными API, доступом к ФС

  2. Renderer process — Chromium, рендерит UI, изолирован от системы

  3. Utility process — изолированный Node.js-процесс для тяжёлых/нативных задач

Изначально мы запускали TDLib прямо в main process. Это самый простой путь.

Первоначальная реализация: TDLib в main process

Вся инициализация происходила при старте приложения. Сначала резолвим путь к нативной библиотеке в зависимости от платформы и окружения:

// main.tsfunction getTdjsonFileName(): string {  switch (process.platform) {    case 'darwin': return 'libtdjson.dylib';    case 'win32':  return 'tdjson.dll';    case 'linux':  return 'libtdjson.so';    default:       return 'libtdjson.so';  }}function resolveTdjsonPath(): string | null {  if (app.isPackaged) {    // Production: берём из ресурсов приложения    return path.join(process.resourcesPath, 'tdlib', getTdjsonFileName());  }  // Development: берём из vendor/  const platform = process.platform;  const arch = process.arch;  let subDir: string | null = null;  if (platform === 'darwin') {    subDir = arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';  } else if (platform === 'win32') {    subDir = arch === 'ia32' ? 'win32-ia32' : 'win32-x64';  } else if (platform === 'linux') {    subDir = arch === 'arm64' ? 'linux-arm64' : 'linux-x64';  }  if (!subDir) return null;  return path.join(app.getAppPath(), 'vendor', 'tdlib', subDir, getTdjsonFileName());}

Далее создаём сервис и инициализируем его в методе initializeExternalInstances():

private initializeExternalInstances() {  const userDataPath = path.join(app.getPath('userData'), 'telegram');  const tdjsonPath = resolveTdjsonPath();  this.telegramService = new ElectronTdLibClientService({    dbBaseDir: userDataPath,    tdjsonPath: tdjsonPath ?? undefined  });}

Сам ElectronTdLibClientService при первом вызове init(accountId) конфигурировал tdl и создавал TDLib-клиент напрямую в main process:

// Старая версия ElectronTdLibClientServiceimport * as tdl from 'tdl'private async configureTdLibOnce(): Promise<void> {  if (this.configured) return;  tdl.configure({ tdjson: this.options.tdjsonPath, verbosityLevel: 1 });  this.configured = true;}async init(accountId: string): Promise<void> {  await this.configureTdLibOnce();  const dbDir = path.join(this.dbBaseDir, accountId);  const client = tdl.createClient({    apiId: Number(TG_API_ID),    apiHash: TG_API_HASH,    databaseDirectory: dbDir,    filesDirectory: path.join(dbDir, 'files'),    tdlibParameters: {      use_message_database: true,      use_chat_info_database: true,      system_language_code: 'en',      device_model: 'Zentry Desktop',      application_version: '1.0.0',    },  });  client.on('update', (update) => {    this.emit('update', { type: 'raw-tdlib-update', payload: update });  });  this.clients.set(accountId, client);}

Handlers обращались к клиенту через TelegramClientContext, который изолировал их от прямой зависимости на tdl:

// handlers/TelegramClientContext.tsinterface TelegramClientContext {  getClient(): Promise<TdClientLike>;  readonly service: ElectronTdLibClientService;  readonly accountId: string;}// пример использования в TelegramAuthHandler.tsasync getAuthState(ctx: TelegramClientContext) {  const client = await ctx.getClient();  return client.invoke({ _: 'getAuthorizationState' });}

Всего таких вызовов client.invoke() — более 30 штук: получение чатов, отправка сообщений, загрузка медиа, авторизация и т.д. Все они исполнялись в main process.

Что именно Касперский заблокировал и почему

Детекция называется PDM:Trojan.Win32.Generic — это срабатывание модуля PDM (Proactive Defense Module). Это принципиально важно: PDM — поведенческий анализ. Он смотрит не на сигнатуры и не на сертификаты, а на то, что процесс делает в runtime.

При инициализации TDLib происходит следующее:

  1. Приложение вызывает LoadLibraryW на tdjson.dll из нестандартной директории (resources/tdlib/)

  2. DLL создаёт SQLite-базы данных в %AppData%\ZentrySpace\telegram\{accountId}\

  3. DLL немедленно открывает зашифрованные TCP-соединения с серверами Telegram (MTProto-протокол)

  4. Всё это происходит за 1–2 секунды после запуска

Именно такой паттерн характерен для банковских Троянов: подгрузить DLL → записать данные в AppData → установить зашифрованный канал с C2-сервером. PDM видит этот паттерн и блокирует — не потому что наш файл плохой, а потому что поведение неотличимо от реального Трояна.

Попытка исправить архитектурой: TDLib в Utility Process

Мы предположили, что проблема в том, что main process — «сердце» приложения — напрямую загружает нативную DLL и открывает сеть. Electron предоставляет специальный механизм для таких случаев — Utility Process: изолированный дочерний Node.js-процесс без доступа к UI-API.

Идея: вынести TDLib в отдельный worker, а в main process оставить только IPC-прокси.

Создали tdlib-worker.ts — полноценный изолированный процесс:

// telegram/tdlib-worker.tslet tdl: typeof import('tdl') | null = null;// Ленивый импорт — tdl не загружается пока не придёт сообщение 'configure'async function loadTdl(): Promise<typeof import('tdl')> {  if (!tdl) tdl = await import('tdl');  return tdl;}const clients = new Map<string, any>();let configured = false;const parentPort = process.parentPort!;// Конфигурируем TDLib и загружаем нативную библиотекуasync function handleConfigure(msg: { tdjsonPath: string; verbosity: number }) {  if (configured) { send({ type: 'configured' }); return; }  const lib = await loadTdl();  lib.configure({ tdjson: msg.tdjsonPath, verbosityLevel: msg.verbosity });  configured = true;  send({ type: 'configured' });}// Создаём TDLib-клиент для аккаунтаasync function handleCreateClient(msg: { accountId: string; apiId: number; apiHash: string;  databaseDirectory: string; filesDirectory: string; useTestDc: boolean; tdlibParameters: any }) {  const lib = await loadTdl();  const client = lib.createClient({    apiId: msg.apiId,    apiHash: msg.apiHash,    databaseDirectory: msg.databaseDirectory,    filesDirectory: msg.filesDirectory,    useTestDc: msg.useTestDc,    tdlibParameters: msg.tdlibParameters,  });  // Все обновления пробрасываем в main process через IPC  client.on('update', (update: any) => {    send({ type: 'update', accountId: msg.accountId, payload: update });  });  clients.set(msg.accountId, client);  send({ type: 'client-created', accountId: msg.accountId });}// Пробрасываем invoke()-вызовы к TDLib APIasync function handleInvoke(msg: { id: string; accountId: string; params: any }) {  const client = clients.get(msg.accountId);  try {    const result = await client.invoke(msg.params);    send({ type: 'invoke-result', id: msg.id, result });  } catch (err) {    send({ type: 'invoke-error', id: msg.id, error: String(err) });  }}

В main process ElectronTdLibClientService превратился в менеджер воркера с proxy-клиентом:

// ElectronTdLibClientService.ts (новая версия)import { utilityProcess, type UtilityProcess } from 'electron/main';export class ElectronTdLibClientService extends EventEmitter {  private worker: UtilityProcess | null = null;  private ensureWorker(): void {    if (this.worker) return;    const workerPath = path.join(__dirname, 'tdlib-worker.js');    // Запускаем TDLib в изолированном utility process    this.worker = utilityProcess.fork(workerPath);    this.worker.on('message', (msg) => this.handleWorkerMessage(msg));    this.worker.on('exit', (code) => {      console.error('[TDLib Worker] exited with code', code);      this.worker = null;      // Отклоняем все pending вызовы      for (const [id, { reject }] of this.pendingInvokes) {        reject(new Error('TDLib worker process exited'));      }    });  }  async init(accountId: string): Promise<void> {    this.ensureWorker();    // Конфигурируем воркер при первом вызове    if (!this.workerConfigured) {      this.sendToWorker({ type: 'configure',        tdjsonPath: this.options.tdjsonPath, verbosity: 1 });      await this.configuredPromise; // ждём подтверждения    }    // Просим воркер создать клиент    this.sendToWorker({ type: 'create-client', accountId, apiId: ..., apiHash: ...,      databaseDirectory: dbDir, filesDirectory: filesDir, ... });    await createdPromise;    // Создаём proxy-объект — все invoke() уйдут в воркер через IPC    const proxyClient = new TdLibProxyClient(accountId,      (msg) => this.sendToWorker(msg), this.pendingInvokes, this.invokeIdCounter);    this.clients.set(accountId, proxyClient);  }}

TdLibProxyClient реализует тот же интерфейс TdClientLike, что и настоящий tdl-клиент — все 30+ хендлеров не потребовали изменений:

class TdLibProxyClient implements TdClientLike {  async invoke(params: Record<string, any>): Promise<unknown> {    const id = String(++this.idCounter.value);    return new Promise((resolve, reject) => {      this.pendingInvokes.set(id, { resolve, reject });      // Отправляем в utility process, ждём invoke-result/invoke-error      this.sendToWorker({ type: 'invoke', id, accountId: this.accountId, params });    });  }}

Почему даже это не помогло

После рефакторинга Касперский продолжил блокировку. Причина проста: PDM анализирует всю цепочку процессов, а не только главный.

Он видит следующую картину:

  1. ZentrySpace.exe запускает дочерний процесс (utility process)

  2. Дочерний процесс загружает нативную DLL

  3. Дочерний процесс открывает зашифрованные сетевые соединения

С точки зрения поведенческого анализа это даже более подозрительно — родительский процесс скрывает вредоносную активность за дочерним. Именно такую технику используют троянские загрузчики.

Что ещё точно не поможет

  • EV-сертификат (проверено на себе).

  • Регистрация приложения в реестре Windows — антивирусники не проверяют это.

  • Обфускация (преднамеренное усложнение кода для затруднения его аналитики)  — будет только хуже.

С этой же PDM:Trojan.Win 32.Generic сталкивались Rocket.Chat, Jitsi Meet, OpenCode — у всех действующие EV‑сертификаты, у всех одна и та же проблема.

У официального Telegram Desktop этой проблемы нет по двум причинам: TDLib там статически слинкован в главный исполняемый файл (нет отдельной DLL, нет LoadLibraryW), и у Telegram годами накопленная репутация в облачной базе Касперского (KSN). У нас ни того, ни другого.

Как решили проблему мы

Шаг 1: Отправка файла через Virusdesk

Через форму virusdesk.kaspersky.com отправили исполняемый файл с описанием: что это за приложение, почему ему нужна TDLib, почему такое поведение легитимно. Ответ пришёл в течение нескольких дней, false positive подтверждён.

Шаг 2: Технический отчёт с TRACE‑файлами

Для PDM‑детекций Касперский отдельно просит собирать и присылать trace‑файлы: gsf.trace и avp.trace. Именно они позволяют аналитикам понять конкретный поведенческий паттерн и добавить точечное исключение в базу. Инструкция: support.kaspersky.com/common/diagnostics/15898

Шаг 3: Программа Allowlist

Подали заявку в kaspersky.com/partners/allowlist‑program. После включения приложения в программу оно автоматически получает кредит доверия во всех продуктах Kaspersky, и проблема не повторится при обновлениях.

Таким образом, нащ продукт абсолютно безопасен, а вы теперь знаете, как не попасть в подобную ловушку.

ссылка на оригинал статьи https://habr.com/ru/articles/1031516/