Не успели мы анонсировать долгожданную интеграцию 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-приложение состоит из нескольких типов процессов. Кратко:
Main process — Node.js, управляет окнами, системными API, доступом к ФС
Renderer process — Chromium, рендерит UI, изолирован от системы
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 происходит следующее:
Приложение вызывает
LoadLibraryWнаtdjson.dllиз нестандартной директории (resources/tdlib/)DLL создаёт SQLite-базы данных в
%AppData%\ZentrySpace\telegram\{accountId}\DLL немедленно открывает зашифрованные TCP-соединения с серверами Telegram (MTProto-протокол)
Всё это происходит за 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 анализирует всю цепочку процессов, а не только главный.
Он видит следующую картину:
ZentrySpace.exeзапускает дочерний процесс (utility process)Дочерний процесс загружает нативную DLL
Дочерний процесс открывает зашифрованные сетевые соединения
С точки зрения поведенческого анализа это даже более подозрительно — родительский процесс скрывает вредоносную активность за дочерним. Именно такую технику используют троянские загрузчики.
Что ещё точно не поможет
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, и проблема не повторится при обновлениях.
Таким образом, нащ продукт абсолютно безопасен, а вы теперь знаете, как не попасть в подобную ловушку.
Комментарии (7)

max9
05.05.2026 08:43>Подали заявку в kaspersky.com/partners/allowlist‑program.
с этого надо было и начинать.

KatNoName20 Автор
05.05.2026 08:43на Маке проблема была неочевидной, поскольку не получали таких оповещений, а потом уже, когда все свершилось, шли по пути "сначала надо потушить", потому что не было надежды, что оперативно попадем в программу, но нас включили, по итогу
zurabob
Возможно страшное и насквозь незаконное слово Телеграм не даст включить ваше приложение в программу, Судя по посту на пикабу, Касперский превратился в филиал роспозора на дому https://pikabu.ru/story/kak_platnyiy_antivirus_za_2600r_stal_lichnyim_roskomnadzorom_na_moem_makbuke_13942488
max9
там автор очень ловко отрезал вопрос и выдернул из контекста ответ про соблюдение законов РФ. и не ответил ни на один вопрос в каментах.
и ваще хватит филиал пикабы тут устраивать.
KatNoName20 Автор
нам повезло, статья вышла чуть позже, есть апдейты, за это время ситуация окончательно разрешилась, и нас включили в программу
zurabob
Здорово, что включили, зря сразу апдейт не написали. Но судя по скорости закручивания гаек, “ложечки-то нашлись, но осадок остался”
KatNoName20 Автор
статья долго была на модерации, не было возможности обновить(