TL;DR Внутри клиента exteraGram на базе официального клиента Telegram работает внутренняя система динамического обновления компонентов. Через неё авторы могут в любой момент загрузить на ваш телефон код прямо из Telegram-канала, обойти окно установки (can_not_skip) и выполнить его в едином адресном пространстве приложения (с его UID и полными правами). В исследованной цепочке загрузки я не обнаружил проверки цифровой подписи. При компрометации канала или инфраструктуры обновлений это создаёт возможность массовой доставки вредоносного кода пользователям.

Как всё началось Был обычный вечер, делать было откровенно нечего. Я сам сидел на ExteraGram, и вдруг стало интересно - а как оно вообще внутри устроено? (Спойлер: после того, что я там увидел, я его снес).

Чат exteraGram Forum, 10 апреля

Ну immat0x1 говорил, так и сделаю. На момент написания поста последняя версия exteraGram 12.9.0 от 31 июля 2026 года

Открываю терминал, натравливаю JADX на свежий APK, жду декомпиляции. Открываю исходники и... немного впадаю в ступор. Авторы обмазали клиент защитой по полной программе. Весь Java-код приложения завернут в жесточайший обфускатор LSParanoid. Вдобавок, Python-модули SDK скомпилированы через Cython в нативные либы .so, а потом прогнаны через OLLVM с Control Flow Flattening, чтобы совсем отбить желание туда лезть.

Потом я зашел в их чат и увидел забавную фразу от лид-разраба:

Публичный чат другого форка, 3 августа
Публичный чат другого форка, 3 августа

Тут всё встало на свои места. Когда ты генерируешь сложную архитектуру нейросетями, а потом просто заворачиваешь это в жесточайший обфускатор, решив, что это спасёт от взлома - получается то, что мы имеем.

Найдя классический бэкдор и RCE (Remote Code Execution) прямо в продакшен-сборке, я собрал детальный репорт и отправил его в Google Android Security Team. И знаете, что они мне ответили?

Ответ The Play and Android Security Team
Ответ The Play and Android Security Team

То есть, по мнению инженеров Google, скачивание обфусцированных бинарников из Telegram-постов и их запуск с доступом к JVM без песочницы — это не малварь. Что ж, раз официальные инстанции дали зеленый свет такому подходу, давайте я просто покажу, как это работает изнутри.

Что они говорят и что есть в коде
Все номера строк ниже — это вывод декомпилятора JADX на оригинальном APK версии 12.9.0 (сборка 20260731). Никакой ручной деобфускации, показываю всё как есть, прямо с тонной шифратора LSParanoid.

В чатах авторы очень любили рассказывать, какие у них крутые защиты. Разработчик bleizix писал на форуме:

чат exteraGram Forum, 8 мая

По факту, они накрутили защиту, как в коммерческих DRM:

Слой первый и второй (Cython + OLLVM): Python-модули плагинов они скомпилировали в бинарники .so, а потом прогнали через кастомный OLLVM (Control Flow Flattening). Паранойя в Java (LSParanoid): Вся Java-часть загрузчика наглухо зашифрована. Но самое смешное — они так увлеклись всей этой обфускацией, что забыли банально сделать strip нативных бинарников.

Они накрутили Control Flow Flattening и потратили ресурсы на сборку, но при этом забыли сделать базовую вещь — strip. Это как поставить на входную дверь сейфовый замок толщиной в метр, но оставить ключи под ковриком с надписью ‘Welcome’. Утилита strip выпиливает отладочную информацию и символы из нативных бинарников. Её отсутствие полностью обесценивает сложную OLLVM-обфускацию, предоставляя реверс-инженеру внутренние пути и имена функций на блюдечке.

Вся их претензия на элитарность и «запредельную защиту» сыпется от одной команды в терминале. Достаточно посмотреть строки в бинарнике metadata_parser.so (один из плагинов), который должен был быть супер-защищенным. Забытый strip выдает всё с потрохами:

Вывод из терминала
Вывод из терминала

Мало того, что мы видим точную версию Cython, так еще и палится конкретный форк OLLVM с гитхаба (luluovo1/ollvm-ndk30), который они использовали для обфускации. И там же рядом торчат пути сборки их SDK:

Вывод из терминала
Вывод из терминала

В официальном патчноуте 12.9.0 они с гордостью заявляли:

чат exteraGram Forum, 31 июля

Я полез искать этот безопасный режим. Оказалось, в коде это просто один if (safeMode) return;, который отключает загрузку. Никаких реальных песочниц на уровне ОС, никаких ограничений прав через SecurityManager. Либо плагины выключены, либо у них есть полный доступ ко всему адресному пространству процесса.

Загрузчик кода прямо из Telegram
Авторы потратили кучу времени на настройку LSParanoid со всей этой кастомной математикой, но во всей этой многослойной броне забыли прописать самую базу: проверку того, что именно они скачивают.

Открывая PythonPluginsEngine.java, мы не видим ни одной нормальной строки. Всё зашифровано:

PythonPluginsEngine.java, 2851-2895 строка
PythonPluginsEngine.java, 2851-2895 строка

LSParanoid прячет строки, заменяя их на вызовы метода getString(long seed). Если заглянуть в их классы DeobfuscatorHelper и RandomHelper, мы увидим всю их математику в открытом виде.

Во-первых, переданный длинный seed прогоняется через миксер в стиле SplitMix64 (XOR с битовыми сдвигами и умножением на гигантские константы вроде 7109453100751455733L). Во-вторых, внутренний стейт генератора дробится не на 64-битные куски, а на 16-битные short-блоки. Для генерации каждого байта происходит магия битовых сдвигов: блоки ксорятся между собой (^), после чего циклически сдвигаются влево и вправо функцией rotl на жестко заданные смещения (9, 10, 13).

Полученный из этого хаоса псевдослучайный ключ снова XOR-ится. Но с чем? Деобфускатор лезет в гигантский заранее сгенерированный массив бессмысленных строк (chunks), берет оттуда символ по индексу (используя деление и остаток от деления на 8191) и применяет XOR. Таким образом генерируется вся строка, символ за символом.

deobf.py
import os
import re
import struct

def to_int(val: int) -> int:
    val &= 0xFFFFFFFF
    return val - 0x100000000 if val >= 0x80000000 else val

def to_short(val: int) -> int:
    val &= 0xFFFF
    return val - 0x10000 if val >= 0x8000 else val

def rotl_short(val: int, shift: int) -> int:
    val_int = to_int(to_short(val))
    unsigned = val_int & 0xFFFFFFFF
    return to_short((unsigned >> (32 - shift)) | (val_int << shift))

def calc_seed(val: int) -> int:
    val &= 0xFFFFFFFFFFFFFFFF
    j2 = ((val ^ (val >> 33)) * 7109453100751455733) & 0xFFFFFFFFFFFFFFFF
    mult = ((j2 ^ (j2 >> 28)) * -3808689974395783757) & 0xFFFFFFFFFFFFFFFF
    return mult >> 32

def next_state(state: int) -> int:
    state &= 0xFFFFFFFFFFFFFFFF
    s1 = to_short(state & 0xFFFF)
    s2 = to_short((state >> 16) & 0xFFFF)
    s_sum = to_short(to_int(s1) + to_int(s2))
    s_rotl = to_short(to_int(rotl_short(s_sum, 9)) + to_int(s1))
    s_xor = to_short(to_int(s2) ^ to_int(s1))
    a_b = (rotl_short(s_xor, 10) & 0xFFFFFFFFFFFFFFFF) | ((s_rotl & 0xFFFFFFFFFFFFFFFF) << 16)
    d_val = (a_b << 16) & 0xFFFFFFFFFFFFFFFF
    e_val = to_short(to_int(rotl_short(s1, 13)) ^ to_int(s_xor))
    g_val = to_short(to_int(e_val) ^ (to_int(s_xor) << 5))
    return d_val | (g_val & 0xFFFFFFFFFFFFFFFF)

def read_char(idx: int, chunks: list[list[int]], state: int) -> tuple[int, int]:
    chunk = chunks[idx // 8191][idx % 8191]
    nxt = next_state(state)
    res = ((chunk << 32) & 0xFFFFFFFF00000000) ^ nxt
    return res, nxt

def decrypt_string(string_id: int, chunks: list[list[int]]) -> str:
    raw_id = string_id & 0xFFFFFFFFFFFFFFFF
    nxt1 = next_state(calc_seed(raw_id & 0xFFFFFFFF))
    nxt2 = next_state(nxt1)
    part1 = (raw_id >> 32) ^ ((nxt1 >> 32) & 0xFFFF)
    part2 = (nxt2 >> 16) & 0xFFFF0000
    start_idx = to_int(part1 ^ part2)
    header, current_state = read_char(start_idx, chunks, nxt2)
    length = (header >> 32) & 0xFFFF
    chars = []
    for offset in range(length):
        ch_data, current_state = read_char(start_idx + offset + 1, chunks, current_state)
        chars.append((ch_data >> 32) & 0xFFFF)
    return struct.pack(f'<{len(chars)}H', *chars).decode('utf-16-le')

def parse_java_chars(raw: str) -> list[int]:
    cleaned = re.sub(r'\\u([0-9a-fA-F]{4})', lambda m: chr(int(m.group(1), 16)), raw)
    escapes = {'\\n': '\n', '\\r': '\r', '\\t': '\t', '\\"': '"', '\\\\': '\\', "\\'": "'", '\\b': '\b', '\\f': '\f'}
    for src, dst in escapes.items():
        cleaned = cleaned.replace(src, dst)
    chars = []
    for ch in cleaned:
        cp = ord(ch)
        if cp <= 0xFFFF:
            chars.append(cp)
        else:
            cp -= 0x10000
            chars.append(0xD800 + (cp >> 10))
            chars.append(0xDC00 + (cp & 0x3FF))
    return chars

def escape_string(text: str) -> str:
    table = {'\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t'}
    res = []
    for ch in text:
        if ch in table:
            res.append(table[ch])
        elif ord(ch) < 32 or ord(ch) == 127:
            res.append(f'\\u{ord(ch):04x}')
        else:
            res.append(ch)
    return "".join(res)

def load_chunks(target_dir: str) -> dict[str, list[list[int]]]:
    chunks_db = {}
    array_re = re.compile(r'String\[\]\s+\w+\s*=\s*(?:new\s+String\[\]\s*)?\{([^}]+)\}')
    str_re = re.compile(r'"((?:[^"\\]|\\.)*)"')
    for root, _, files in os.walk(target_dir):
        for file in files:
            if not file.endswith(".java") or "Helper" in file:
                continue
            path = os.path.join(root, file)
            try:
                with open(path, encoding="utf-8", errors="ignore") as f:
                    content = f.read()
            except OSError:
                continue

            for match in array_re.finditer(content):
                raw_strings = str_re.findall(match.group(1))
                if any(len(s) > 1000 for s in raw_strings):
                    cls_name = file[:-5]
                    chunks_db[cls_name] = [parse_java_chars(s) for s in raw_strings]
                    
    return chunks_db

def deobfuscate_dir(target_dir: str = "sources"):
    chunks_db = load_chunks(target_dir)
    if not chunks_db:
        return

    call_re = re.compile(r'([A-Za-z0-9_$.]+)?getString\s*\(\s*(?:\(\s*long\s*\)\s*)?(-?\s*\d+)L?\s*\)')
    ternary_re = re.compile(r'([A-Za-z0-9_$.]+)?getString\s*\(\s*([^?]+?)\s*\?\s*(-?\s*\d+)L?\s*:\s*(-?\s*\d+)L?\s*\)')

    for root, _, files in os.walk(target_dir):
        for file in files:
            if not file.endswith(".java") or "Deobfuscator" in file:
                continue
            path = os.path.join(root, file)
            try:
                with open(path, encoding="utf-8", errors="ignore") as f:
                    content = f.read()
            except OSError:
                continue
            if "getString(" not in content:
                continue
            keys_to_try = [k for k in chunks_db if k in content] + [k for k in chunks_db if k not in content]
            
            def replace_call(m: re.Match) -> str:
                full_match, raw_id = m.group(0), m.group(2)
                try:
                    str_id = int(raw_id.replace(" ", ""))
                    if "Deobfuscator" not in full_match and abs(str_id) <= 0xFFFFFFFF:
                        return full_match
                    for key in keys_to_try:
                        try:
                            decrypted = decrypt_string(str_id, chunks_db[key])
                            return f'"{escape_string(decrypted)}"'
                        except Exception:
                            continue
                    return full_match
                except ValueError:
                    return full_match
                    
            def replace_ternary(m: re.Match) -> str:
                full_match = m.group(0)
                condition = m.group(2).strip()
                try:
                    id1 = int(m.group(3).replace(" ", ""))
                    id2 = int(m.group(4).replace(" ", ""))
                    dec1, dec2 = None, None
                    for key in keys_to_try:
                        if dec1 is None:
                            try: dec1 = decrypt_string(id1, chunks_db[key])
                            except Exception: pass
                        if dec2 is None:
                            try: dec2 = decrypt_string(id2, chunks_db[key])
                            except Exception: pass
                        if dec1 is not None and dec2 is not None:
                            break
                    if dec1 is not None and dec2 is not None:
                        return f'{condition} ? "{escape_string(dec1)}" : "{escape_string(dec2)}"'
                    return full_match
                except ValueError:
                    return full_match

            new_content = call_re.sub(replace_call, content)
            new_content = ternary_re.sub(replace_ternary, new_content)
            if new_content != content:
                with open(path, "w", encoding="utf-8") as f:
                    f.write(new_content)

if __name__ == "__main__":
    deobfuscate_dir()

Написав простенький скрипт, который копирует их же логику из RandomHelper, мы мгновенно снимаем всю эту «непробиваемую» броню. И вот что мы видим под капотом парсера обновлений

PythonPluginsEngine.java, 2851-2895 строка
PythonPluginsEngine.java, 2851-2895 строка

ИИ, который «не думает»
Отдельного упоминания заслуживает их хваленый встроенный AI. В коде com/exteragram/messenger/ai/network/Client.java они написали свой собственный парсер для вырезания тегов … из ответов нейросетей!

Вместо того, чтобы нормально настроить API-запрос к моделям вроде DeepSeek так, чтобы они не возвращали процесс рассуждения, разработчики решили просто вырезать мысли нейросети по-живому прямо из стрингов:

Client.java, 467-488 строка
Client.java, 467-488 строка

Вы только представьте эту картину: нейросеть пыжится, генерирует сложные логические цепочки, тратит токены и время пользователя. А на стороне мобильного клиента сидит while (true) с захардкоженными индексами +7 и +8 и неистово выпиливает весь этот мыслительный процесс ржавым лобзиком. Искусственный интеллект, может, и умеет рассуждать, но пацанам из ExteraGram это не нужно — их помощник должен отвечать сразу, без раздумий. Иллюзия всезнания спасена костылем из четырех строчек!

Инжектнуть код в Telegram? Легко!

Я стал ковырять загрузчик Updater. Оказалось, обновы прилетают прямо из постов в Telegram-канале:

UpdaterUtils.java, 109-135 строка
UpdaterUtils.java, 109-135 строка

Все обновления скачиваются напрямую из постов. Если произойдет компрометация инфраструктуры (например, утечка токена бота, публикующего релизы, или угон сессии администратора канала), атакующий сможет опубликовать пост со своим вредоносным SDK.

Клиент не проверяет цифровую подпись скачанного архива и не валидирует ID чата источника (chat_id) на жестко зашитый константный канал. Он просто парсит пост и слепо жрет то, что прилетело — и этот бинарник улетит напрямую в телефоны сотен тысяч пользователей.

Но это еще не всё. Самое смешное в UpdaterUtils.java - это то, как выглядит сам диалог установки этого потенциального бэкдора. Пока ваш телефон распаковывает непроверенный APK, на экране принудительно крутится стикер утки:

UpdaterUtils.java, 221-225 строка
UpdaterUtils.java, 221-225 строка

Вас хакают, а на экране танцует утенок. Гениально.

Бэкдор “для своих”
В файле BadgesController.java (который выдает бейджики Trusted, Developer, Supporter) я нашел тупо захардкоженный ID пользователя:

BadgesController.java, 90-99 строка
BadgesController.java, 90-99 строка

То есть чувак с ID 2562664432 автоматически считается “Trusted” (доверенным). Любой плагин от него система проглотит без предупреждений. Вот это безопасность уровня enterprise!

Исполнение питона прямо в памяти
Но и это не самое страшное. Я пошел глубже и добрался до плагинов — PythonPluginsEngine.java. Разрабы решили, что скачивать обновления апк-шек им мало, поэтому они встроили загрузку питоновских плагинов (SDK) напрямую из Telegram-постов.

PythonPluginsEngine.java, 2727-2746 строка
PythonPluginsEngine.java, 2727-2746 строка

Код просто ищет посты с зашифрованными тегами python_sdk_stable через MTProto. И тут я заметил две вещи, из-за которых вся их защита просто обнуляется.

Нет проверки подписи
Я просмотрел всю цепочку и не нашел вообще никакой проверки того, кто сгенерировал этот архив. Тихая установка. В парсере апдейтов есть интересный флаг:

PythonPluginsEngine.java, 2877-2881 строка
PythonPluginsEngine.java, 2877-2881 строка

Чтобы доказать, что этот флаг приводит к принудительной установке в обход настроек пользователя, проследим за его использованием при проверке обновлений:

PythonPluginsEngine.java, 2749-2788 строка
PythonPluginsEngine.java, 2749-2788 строка

Это значит, что если в посте будет стоять can_not_skip=true, клиент вообще ничего не спросит. Диалоговое окно просто скипается, и архив улетает прямиком в savePythonSdkArchive. (Справедливости ради, для 100% подтверждения “тихой установки” нужен динамический патчинг ответа в рантайме, но статика говорит сама за себя).

В мире нормальной разработки флаг force_update используется для критических исправлений нулевого дня, и даже тогда пользователь получает уведомление. В ExteraGram этот флаг просто отключает кнопку “Отмена”, потому что кто-то очень не хочет, чтобы вы увидели вопрос “Вы точно хотите установить обновление?”. Демократия? Нет, это добровольно-принудительный апдейт с утёнком на фоне.

Затем архивы тупо перезаписывают старые файлы:

PythonPluginsEngine.java, 2957-2976
PythonPluginsEngine.java, 2957-2976

Кстати, загрузка исполняемого кода в обход Google Play напрямую нарушает политику Device and Network Abuse.

Доступ ко всему

Инъекция кода через MVEL Файл ClassProxy.java (строка 631) предоставляет метод executeMvelMethod, который выполняет Java-выражения через библиотеку org.mvel2.MVEL.

ClassProxy.java, 631-656 строки
ClassProxy.java, 631-656 строки

Я проанализировал полный стек вызовов от точки входа плагина до MVEL.executeExpression(). Ни на одном уровне не передаётся экземпляр ParserContext с ограничениями. Более того, в коде явно передаются obj и obj2 в качестве переменных java и python, что прямо указывает на намерение дать полный доступ. Если бы разработчики хотели безопасности, они бы вручную создали контекст (whitelist) и передали его в compileExpression. Этого нет.

Они не просто передают ссылку на инстанс приложения. Они буквально сказали MVEL: “Вот тебе, брат, полный доступ к JVM. Работай, я доверяю тебе, как родному”. Это не архитектура, это незащищенный секс с интернетом. Песочница? Нет, это открытое поле с табличкой «Здесь не воруют, честное слово».

PoC: Как плагин угоняет базу данных

Плагинам не нужно изощряться с хаками памяти. «Песочницы» в Android-плагинах по умолчанию нет. Плагины выполняются в едином адресном пространстве процесса приложения, с его UID, получая полный доступ к файловой системе (sandbox Android).

Если вы напишете Python-плагин для их клиента, вам достаточно дернуть executeMvelMethod через стандартный мост Chaquopy/JNI. Вот как выглядит элементарный Proof-of-Concept на Python:

# Минимальный рабочий пример внутри плагина ExteraGram
from java.lang import Class
from java.lang import Object
from java.lang import String
# Получаем класс через рефлексию (плагин уже в процессе Telegram!)
ClassProxy = Class.forName("com.exteragram.messenger.plugins.utils.ClassProxy")
# Формируем MVEL-нагрузку (читаем базу юзеров Telegram)
mvel_payload = """org.telegram.messenger.MessagesStorage.getInstance(0).getDatabase().queryFinalized("SELECT uid, name FROM users LIMIT 1", new Object[0]);"""
# Ищем метод
method = ClassProxy.getMethod("executeMvelMethod", [Object, Object, String, Class.forName("[Ljava.lang.String;"), Class.forName("[Ljava.lang.Object;")])
# Выполняем. База в ваших руках.
result = method.invoke(None, [None, None, mvel_payload, None, None])
print("Stolen DB Data:", result)

Но это ещё цветочки. Поскольку плагин работает внутри процесса мессенджера, он имеет доступ ко всем файлам приложения. Вы можете не просто читать переписку из базы, а напрямую скопировать файл tgnet.dat (или ключи авторизации MTProto) и отправить его на свой сервер. Получив эти ключи, атакующий может полностью угнать вашу Telegram-сессию и клонировать аккаунт на своем устройстве. Никаких СМС, никаких 2FA-паролей - просто полный перехват аккаунта.

Криптография уровня «невидимых пробелов»

Когда я смотрел, как они хранят секреты, я наткнулся на фантастическую вещь. Я нашел класс InvisibleEncryptor.java (буквально «Невидимый Шифратор»).

Этот кусок кода даёт 100% подтверждение моему выводу из начала статьи: код им реально писала нейронка. Разработчики и сами публично хвастались, что крупные изменения у них делаются “клодиком” (Claude AI).

Конечно, технически это примитивная обфускация и стеганография, а не криптография (не надо сравнивать это с AES). Но раз уж автор пафосно назвал класс InvisibleEncryptor, давайте судить его по всей строгости. Этот алгоритм берет секретный текст и конвертирует каждый байт… в невидимые Unicode-символы (zero-width spaces). Для школьного фокуса это прикольно, но для production-кода мессенджера — это позор.

Вот кусок из их YandexMapsProvider.java:

// Там внутри кавычек идут невидимые пробелы, в которых "спрятан" ключ
private static final String YANDEX_API_KEY = InvisibleEncryptor.decode("\u2001\u2002\u206a\u200c\u2000\u206e...");

Их концепция защиты — это буквально: “превратим секретный ключ в невидимые пробелы, хакер откроет файл, увидит пустоту, решит, что у него сломался монитор, и уйдет в монастырь”. (Спойлер: если вы думаете, что \u2001 это какая-то хитрая защита — нет, это просто символ с названием “EM QUAD”. Он существует в Unicode уже лет 30. Очевидно, разработчики открыли Википедию, увидели слова “zero-width spaces” и решили, что теперь они криптографы).

Они на полном серьёзе прячут этим алгоритмом не только второстепенные API-ключи, но и бэкапы пользователей! Любому плагину с доступом к внутренним API достаточно просто дёрнуть этот же самый публичный метод InvisibleEncryptor.decode(), чтобы получить всё в открытом виде.

Самая ирония в том, что пока они прятали ключи от Яндекс.Карт в невидимые пробелы, главные API-ключи от Telegram (APP_ID и APP_HASH) и конфигурация Firebase лежат в абсолютно открытом виде в дефолтном BuildConfig.java и strings.xml. Бронированная дверь на кладовке со швабрами, а входная дверь в квартиру снята с петель.

DevServer: локальный сервер в проде

В PythonPluginsEngine.java есть метод, который запускает модуль:

PythonPluginsEngine.java, 652-674 строки
PythonPluginsEngine.java, 652-674 строки

В проде оставлен код запуска локального сервера (start_server). Разработчики могут возразить, что он закрыт флагом pluginsDevMode. Но у плагинов же есть MVEL. Любой загруженный скрипт может переопределить этот флаг прямо в памяти через рефлексию.

А если вы думаете: "Ну ок, сервер запустится, но как же порты и NAT? К телефону снаружи не подключиться!" - вы мыслите категориями защиты, а не атаки.

Вредоносному коду не нужно, чтобы вы подключались к нему снаружи. Сервер поднимается локально, на самом устройстве (обычно на 127.0.0.1 или 0.0.0.0). Плагин сам, находясь внутри защищенного периметра приложения, может инициировать обратное подключение (reverse shell) к серверу злоумышленника, обходя любые NAT и файрволы, или просто использовать этот локальный TCP-сокет для межпроцессного взаимодействия с другими малварями на устройстве.

Как защититься

Чтобы статья не была просто детективом, вот практическое руководство для разработчиков ExteraGram (и любых других форков), как исправить эту зияющую дыру:

  1. Проверка цифровой подписи (Signature Verification). Любой скачанный архив должен валидироваться публичным ключом. Если подпись не сошлась, файл удаляется до распаковки.

  2. Проверка источника (chat_id). Клиент должен проверять, что сообщение с обновлением пришло из канала с фиксированным ID, который нельзя подменить даже при компрометации аккаунта. Иначе злоумышленник может отправить сообщение от имени бота из другого чата.

  3. Изоляция плагинов. Вместо MVEL с полным доступом к JVM используйте изолированный WebView с загруженным JavaScript, где через addJavascriptInterface вы контролируете, какие именно методы доступны (например, только работа с UI). Либо, если нужен тяжеловес, используйте V8 (LiquidCore).

  4. Хранение секретов. Прекратите использовать невидимые пробелы. Ключи нужно прятать в NDK/JNI (нативный слой) или использовать Android Keystore.

  5. Очистка релизов. Выпиливайте тестовые бэкдоры (DevServer) из production-сборок на этапе компиляции (например, через ProGuard/R8 правила).

Что в сухом остатке

Я собрал всё, что нашел, в одну картинку. У нас есть механизм тихой загрузки апдейтов. У нас есть выполнение этого кода без изоляции памяти. И у нас нет проверки того, что этот код вообще оригинальный.

Чат другого форка, 20 января
Чат другого форка, 20 января

Главная проблема здесь даже не сами ошибки. Ошибки делают все. Проблема в том, что авторы просто не понимают, как работает безопасность. Зато накрутили обфускаторов.

Когда архитектура содержит подобные фундаментальные дыры в доставке исполняемого кода, просьба к пользователям «просто отключить системный антивирус» выглядит как минимум самонадеянно. Искусственный интеллект может сгенерировать архитектуру, но он не обеспечит её безопасность.

Что делать, если вы пользовались ExteraGram

Для разработчиков советы были выше, а вот руководство к действию для обычных пользователей:

  1. Немедленно удалите клиент со своего устройства.

  2. Зайдите в официальный Telegram -> Настройки -> Конфиденциальность -> Активные сеансы -> Найдите сеанс ExteraGram и нажмите «Завершить сеанс». (Просто удалить приложение недостаточно, сессия остается активной на серверах Telegram, и в случае угона ключей к ней могут получить доступ).

  3. Никогда не отключайте Google Play Protect по просьбе разработчиков сторонних клиентов.

P.S. Автор аудита - 15-летний разработчик. Этот разбор был выполнен исключительно в образовательных целях. Мораль: никакие OLLVM и LSParanoid не спасут вас, если ваша архитектура состоит из костылей, а безопасность ограничивается невидимыми пробелами. ИИ не написал ни слова в этой статье, но использовался для форматирования уже написанного текста
P.P.S. Отдельная благодарность lead-разработчику ExteraGram (@immat0x1) за искренние рассказы про разработку «клодиком», баны за технические вопросы — вы лучший мотиватор в моей жизни. Огромная благодарность за предоставленный билд: pixwet, bleizix, alexeyzavar, ireina, yzewe, hehcker, ampliation, iwill, kvuco, "i miss her".

UPD 1: Через час, автор официально ответил в чате exteraGram Forum.

Чат exteraGram Support, 11 августа

"притянутая за уши статья", сочувствую автору если он не понимает собственную логику

Чат exteraGram Support, 11 августа

То есть тим лид прямо признает что их система плагинов дырявое корыто. Вопрос к вам. Вы бы доверили ваш Telegram аккаунт проекту с таким человеком во главе?

Канал exteraGram Utilities, 18 апреля 2025

Ставлю 10 баксов на то что они в следующем обновлении попытаются исправить некоторые баги(через тот же claude, хотя я думаю у них ии не додумает).

Комментарии (7)


  1. llimonix
    11.08.2026 15:37

    Интересная статья, спасибо автору. Экстере стоит позаботиться о защите клиента и не хвастаться тем, что они используют клодик, который не имеет критического мышления и допускает кучу неточностей не видя всей картины целиком.

    Можно ли считать данный клиент очередной жертвой вайбкодинга?


  1. alex1478
    11.08.2026 15:37

    Всегда считал и продолжаю считать, что основная задача таких клиентов - скамить пользователя. Либо собирая ценную для автора информацию о пользователях, либо удовлетворяя вуайеристкие расстройства автора. Не удивлюсь, если окажется что в клиенте есть механизмы взаимодействия с командным сервером, например через секретный чат


    1. Cating65801 Автор
      11.08.2026 15:37

      Ну оно скачивает конфиги из личного телеграмм канала(@XSd6GEcz5ZXMu82UvXQc), и если есть обновление скачивает сдк, конфиг для приложения


  1. veselcraft
    11.08.2026 15:37

    Позор этому клиенту и их неповоротливым разработчикам. Общался с ними намедни, они непробиваемые, даже если ты юридически и логически прав. А раньше пользовался им и рекламил за деньги...


    1. Andrey4ik
      11.08.2026 15:37

      Добавлю что их политическая позиция вызывает ещё больше вопросов


  1. Andrey4ik
    11.08.2026 15:37

    Как всегда разрабы экстеры и аюграма обосрались

    Юзайте награм икс и жизнь будет лучше

    Автору статьи респект!


  1. yozora
    11.08.2026 15:37

    Open-Source чудо экстераграм. Вайбкод-решение с блекджеком и RCE... Так еще и потенциальный supply-chain в меру того что это чудо вайбкод. После новостей со всякими NekoGram, CherryGram, [вставить слово]Gram, которые то сливают номера, то воруют сессии, доверять стоит исключительно официальному клиенту. Официально разработчик заявлял что исходный код закрыт для того чтобы всякие гении не копировали клиент с их аналоговнетовыми функциями и не вшивали туда вирусы. И вот у них через расширения от "разных" разработчиков могут быть вирусы. Только этими самыми "разными" разработчиками могут быть и сами владельцы клиента ;)

    P.S. 28 января ничего не произошло