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

в 7:15, , рубрики: saas-сервис, TDLib, telegram, интеграция, касперский, корпоративные приложения, корпоративный мессенджер

Не успели мы анонсировать долгожданную интеграцию 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.ts
function 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:

// Старая версия ElectronTdLibClientService
import * 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.ts
interface TelegramClientContext {
  getClient(): Promise<TdClientLike>;
  readonly service: ElectronTdLibClientService;
  readonly accountId: string;
}
// пример использования в TelegramAuthHandler.ts
async 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%ZentrySpacetelegram{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.ts
let 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 API
async 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, и проблема не повторится при обновлениях.

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

Автор: KatNoName20

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js