Не успели мы анонсировать долгожданную интеграцию 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%ZentrySpacetelegram{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, и проблема не повторится при обновлениях.
Таким образом, нащ продукт абсолютно безопасен, а вы теперь знаете, как не попасть в подобную ловушку.
Автор: KatNoName20
