И этот заголовок - не кликбейт. Подвергнув реверс инжинирингу клиент российского мессенджера MAX удалось подтвердить самые худшие предположения.

В сети начали появляться сообщения о странных обращениях мессенджера MAX к Telegram и WhatsApp, из-за чего в сети начали выдвигаться предположения касательно природы и целей этих запросов. Но одно дело предполагать, другое дело знать. Мало ли это какая-то интеграция или случайный аналитический модуль. Поэтому чтобы понять самому и рассказать вам я решил посмотреть внутрь клиента и понять что и зачем он делает.

TL;DR - содержит шпионский модуль, который сделали разработчики MAX для слежки за теми кто использует VPN, они постарались сделать этот модуль неблокируемым и прикрутили удаленное управление.

Подготовка

Так как клиент MAX не содержит отладочной информации и его реверс инжиниринг затруднен, то на первом этапе я решил п��осто посмотреть какие сетевые запросы делает подопытный. Для этого нам понадобится:

  1. mitmproxy. Я использовал режим wireguard (--mode wireguard), так как он позволяет перехватывать вообще весь трафик.

  2. Эмулятор Android. Я использовал Android Emulator, который идет в комплекте с Android Studio.

  3. Загрузить корневой сертификат mitmproxy в системное хранилище эмулятора.

  4. WireGuard клиент на android. Я использовал официальный. При старте mitmproxy/mitmweb показывает qr код и конфигурацию для wg клиента.

  5. Собственно сам мессенджер MAX. В исследовании я использовал версию MAX_(RS)_v.26.4.3(6552)(8.0-15.0)(arm7a,arm64-8a,x86,x86-64), которую нашел на 4pda.

  6. JADX для анализа APK.

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

Перехват трафика

Что же, запускаем mitmweb, подключаем эмулятор и смотрим что за запросы ходят в интернет.

(Я удалил часть мусора, не имеющего отношения к делу)

Что же, мы видим в том числе интересующие нас запросы, но почему-то обмен данными с api.oneme.ru (api домен мессенджера) отображается как TCP поток, а не HTTP(S)/WebSocket.

Изначально мне казалось, что это gRPC, так как трафик был похож на бинарную мешанину с вкраплениями строк, но protoc --decode_raw ничего не показал.

Анализ протокола

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

0a|0100|01|0006|01|000087|data

Состоит из

Поле

Значение

Описание

0a

10

Версия протокола

0100

0x0100

Command (вероятно, маршрутизация на разные сервисы)

01

1

Порядковый номер запроса (SEQ)

0006

0x0006

Опкод (OPCODE)

01

1

Флаг сжатия

000087

135

Размер полезной нагрузки

data

Данные в формате MessagePack

По поводу msgpack - привет ребятам из VK, это их любимая игрушка.

Так что для анализа трафика пришлось написать аддон для mitmproxy, который распаковывает трафик на лету.

maxproto_dump.py
import lz4.block
import msgpack
import pprint
from datetime import datetime
from mitmproxy import tcp
from mitmproxy import ctx

def unpack_packet(data: bytes):
    if len(data) < 10:
        return None

    ver = int.from_bytes(data[0:1], 'big')
    cmd = int.from_bytes(data[1:3], 'big')
    seq = int.from_bytes(data[3:4], 'big')
    opcode = int.from_bytes(data[4:6], 'big')
    packed_len = int.from_bytes(data[6:10], 'big', signed=False)
    
    comp_flag = packed_len >> 24
    payload_length = packed_len & 0xFFFFFF
    
    if payload_length == 0:
        return {
            "ver": ver, "cmd": cmd, "seq": seq, "opcode": opcode,
            "payload": "[Empty Payload / System Message / ACK]"
        }

    payload_bytes = data[10:10 + payload_length]
    
    if comp_flag != 0:
        compressed_data = payload_bytes
        try:
            payload_bytes = lz4.block.decompress(compressed_data, uncompressed_size=1048576)
        except lz4.block.LZ4BlockError as e:
            return {
                "ver": ver, "cmd": cmd, "seq": seq, "opcode": opcode,
                "payload": f"[Error: LZ4 Decompression failed - {e}]"
            }

    try:
        payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
    except Exception as e:
        payload = f"[Error: MessagePack unpack failed - {e}]"
        
    return {
        "ver": ver,
        "cmd": cmd,
        "seq": seq,
        "opcode": opcode,
        "payload": payload
    }

class MaxProtoDumper:
    def tcp_message(self, flow: tcp.TCPFlow):
        host = ""
        if flow.server_conn and flow.server_conn.sni:
            host = flow.server_conn.sni
        elif flow.server_conn and flow.server_conn.address:
            host = flow.server_conn.address[0]

        if "oneme.ru" not in host and "155.212" not in host:
            return

        message = flow.messages[-1]
        raw_bytes = message.content
        
        direction = "C->S" if message.from_client else "S->C"

        parsed = unpack_packet(raw_bytes)
        if not parsed:
            return

        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]

        if isinstance(parsed["payload"], (dict, list)):
            formatted_payload = pprint.pformat(parsed["payload"], indent=2)
        else:
            formatted_payload = str(parsed["payload"])

        log_msg = (
            f"\n[{timestamp}]\n{direction}\n"
            f"VER: {parsed['ver']} | CMD: {parsed['cmd']} | SEQ: {parsed['seq']} | OPCODE: {hex(parsed['opcode'])}\n"
            f"Payload Data:\n{formatted_payload}\n"
            f"{'='*50}"
        )
        
        ctx.log.info(log_msg)
        
        with open("maxproto_decoded.txt", "a", encoding="utf-8") as f:
            f.write(log_msg + "\n")

addons = [
    MaxProtoDumper()
]

Данные дампятся в консоль и в maxproto_decoded.txt

И что же внутри?

Что же, запускаем mitmweb с нашим аддоном и смотрим что внутри

mitmweb --mode wireguard -s maxproto_dump.py

Я запустил прокси и одно из первых сообщений, которые я увидел было

C->S
VER: 10 | CMD: 0 | SEQ: 25 | OPCODE: 0x1
Payload Data:
{'interactive': False}
==================================================

C->S
VER: 10 | CMD: 0 | SEQ: 26 | OPCODE: 0x5
Payload Data:
{ 'events': [ { 'event': 'GET_HOST_REACHABILITY',
                'params': { 'connection_type': 2,
                            'hosts': { 'api.oneme.ru': 3,
                                       'calls.okcdn.ru': 3,
                                       'gosuslugi.ru': 3,
                                       'gstatic.com': 3,
                                       'main.telegram.org': 3,
                                       'mmg.whatsapp.net': 3,
                                       'mtalk.google.com': 3},
                            'ip': 'REDACTED',
                            'operator': '25001:MTS',
                            'vpn': 1},
                'sessionId': REDACTED,
                'time': 17726REDACTED,
                'type': 'HOST_REACHABILITY',
                'userId': REDACTED}]}

(очевидно я скрыл чувствительные данные)

Ничего себе. Вот что мы тут видим.

  • connection_type - Тип соединения

Код

Значение

0

Тип соединения неизвестен

1

Нет соединения

2

Wi-Fi

3

Mobile slow

4

Mobile fast

  • hosts - список хостов для проверки и статус этой проверки. Значения могут быть такие

Код

Ping (ICMP)

TCP:443

Итог

0

FAIL

FAIL

хост недоступен полностью

1

OK

FAIL

пинг есть, HTTPS недоступен

2

FAIL

OK

пинга нет, HTTPS доступен

3

OK

OK

пинг есть, HTTPS доступен

  • ip — Очевидно, IP адрес клиента. Причём в разных событиях может приходить IP, полученный из разных источников.

  • operator — строка содержит PLMN код оператора, состоящий из мобильного кода страны и кода оператора:

    • MCC: 250 (RUS)

    • MNC: 01 (в данном случае - MTS)

  • vpn — флаг, показывающий активно ли vpn подключение в системе. Этот флаг ограничен только статусом используется ли VPN ПО на самом телефоне (нативный Android API)

Так же было обнаружено, что этот модуль включается и отключается удаленно сервером. При логине/обновлении сессии возвращается конфигурация, которая содержит флаг host-reachability, что делает возможным включение этой функции таргетно для отдельных аккаунтов.

Как это работает

  1. При старте приложения берется список адресов источников ip и перемешивается:

  2. IP добывается асинхронно с таймаутом 3000ms, причем ответ 127.0.0.1 игнорируется

  3. Параллельно опрашиваются хосты назначения, используя:

    • ping (ICMP)

    • connect TCP:443 (проверка доступности по HTTPS). Таймаут такой же - 3000ms

  4. При сворачивании/разворачивании приложения данные отправляются на api.oneme.ru сообщением HOST_REACHABILITY

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

Так же от версии к версии список проверяемых хостов меняется. К примеру проверка Telegram и WhatsApp то включается, то выключается (но не удаляется, apk всегда содержит эти хосты в своем коде). Я считаю что сейчас идет тестирование и обкатка, после чего ничего не стоит переключить этот модуль в полностью удаленно управляемый режим.

Подробный реверс инжиниринг со ссылками под спойлером

Эта часть не будет содержать скриншотов, так как это все равно на первый взгляд выглядит как непонятная мешанина кода с примесями smali.

Я указал точную версию apk и укажу названия классов, в которых обрабатывается то или иное, каждый сможет найти и перепроверить.

Отправной точкой поиска будет поиск в коде HOST_REACHABILITY, который без труда находится в public final vb7. vb7 ссылается на значения из public abstract class zb7, который содержит C строки, который JADX неверно интерпретировал как массив int8. Вот так выглядит его контент на самом деле:

Переменная

Значение

f77818a

gstatic.com

f77820c

mtalk.google.com

f77822e

calls.okcdn.ru

f77824g

gosuslugi.ru

f77826i

main.telegram.org

f77828k

mmg.whatsapp.net

f77830m

https://ipv4-internet.yandex.net/api/v0/ip

f77832o

https://ipv6-internet.yandex.net/api/v0/ip

f77834q

https://ifconfig.me/ip

f77836s

https://api.ipify.org

f77838u

https://checkip.amazonaws.com

f77840w

https://ip.mail.ru/

IP добывается асинхронно через qb7 → pb7 с таймаутом 3000 мс. sources/p000/qb7.java:45

В pb7 берётся список URL-ов (IPv4/IPv6 Yandex, ifconfig.me, ipify, checkip.amazonaws.com, ip.mail.ru), перемешивается и дальше перебирается до первого валидного IP по regex (и отбрасывается 127.0.0.1).

Когда вызывается GET_HOST_REACHABILITY:

Инициализация задачи делается при старте приложения: HostReachabilityTask вызывает new xb7().m22182c() (sources/one/p010me/android/OneMeApplication.java и sources/p000/C0136c6.java)

xb7.m22182c() регистрирует listener в p3i только если включён PMS-флаг host-reachability (sources/p000/xb7.java:164 и sources/p000/j06.java:457). Сами PMS ключи лежат в sources/ru/p026ok/tamtam/android/prefs/PmsKey.java

Дальше каждый раз при переходе приложения в foreground p3i вызывает mo462j(), и для xb7 стартует корутина vb7 (если предыдущая ещё не активна). (sources/p000/p3i.java:102 и sources/p000/dk6.java:92)

В vb7 есть задержка 3000 мс перед стартом проверки/репорта.

А как именно проверяет:

  • ping выполняется через стандартный InetAddress.isReachable (ub7 → jy2(case 3))

  • TCP connect на host:443 с таймаутом 3000 мс (tb7 → xb7.m22181a → sq2(case 25))

Маппинг кодов происходит в sources/p000/rb7.java, но используйте smali, jadx тут показывает криво.

connection_type это 1 если нет соединения и enum zw3.f79639a если есть.
vpn - это проверка NetworkCapabilities.TRANSPORT_VPN через ConnectivityManager (sources/p000/vb7.java:125 и sources/p000/hw3.java:186)
operator - берётся из TelephonyManager.getNetworkOperator() + ":" + getNetworkOperatorName(), иначе "undefined"

Таргетированное удаленное управление

PMS приходят с сервера как часть config в ответ на логин. Ответ парсится в sources/p000/qea.java:900. Поэтому сервер может вернуть разные значения для разных пользователей/сессий. При logout этот конфиг чистится (sources/p000/olc.java:34)

Что все это значит?

Ну, кажется все и так очевидно, но давайте поговорим о нюансах:

  1. Это точно получилось не случайно. Они любят рассказывать о opensource модулях аналитики, но это не тот случай. Очевидно этот модуль был разработан внутри VK и наличие заблокированных и ограниченных ресурсов говорит нам о том, что они и являются целью проверки.

  2. Эти данные отправляются не на отдельный аналитический домен, а смешиваются с основным трафиком мессенджера так, чтобы заблокировать эту аналитику не заблокировав мессенджер было невозможно. Отдельным бонусом идет то, что их протокол не декодируется автоматическими инструментами.

  3. Методика проверки (ping + tcp:443) это прямая проверка успешности блокировки ресурса на ТСПУ. ТСПУ не режет пинги, но ограничивает доступ к конкретным портам/протоколам.

  4. Очевидно, что выбор источников получения IP не случаен, это 50/50 российские и зарубежные сервисы. Зачем? Чтобы ловить умников, которые настроили маршрутизацию трафика и не заворачивают в туннель местный трафик.

  5. Текущие функции удаленного управления и кажущаяся неизбежность их совершенствования превращает национальный мессенджер в государственный шпионский инструмент (spyware)

  6. Этот подход очень хорош для отлавливания и блокировки личных (приватных) ВПН серверов, у которых обычно одинаковый входной и выходной ip.

  7. Этот подход очень хорош для привязывания пользователей конкретных впн сервисов к конкретным людям (я не буду развивать тему что из этого следует).

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

  9. Отправка PLMN кода оператора будет являться неплохим маркером того, что пользователь скорее всего в РФ. При этом, в отличии от геолокации, запретить собирать информацию о мобильном операторе не получится.

Вероятно, они хотят превратить миллионы устройств в сканеры успешности своих блокировок и поиска тех, кто их обходит.

Я видел мысли вроде "А почему сбербанк так не может или, может, уже делает". Может и делает, но подумайте сколько времени средний человек проводит в приложении сбербанка, а сколько в современном мессенджере, который фактически является соцсетью?

А что делать то?

Ну кажется решение простейшее - удалите его.

Если не можете удалить по каким-либо обстоятельствам, то у вас буквально пара вариантов (кроме технически сложных, но тут кто на что горазд):

  1. Если у вас Android, можно установить приложение в отдельное, изолированное рабочее пространство. Обычно такое пространство не наследует VPN соединение основного профиля.

    • Samsung — защищённая папка Knox

    • Xiaomi / Redmi / POCO — Второе пространство

    • Huawei / Honor — PrivateSpace (Личное пространство)

    • Универсальный вариант, в том числе для Pixel / Motorola / Nothing: Shelter, Island, Insular

  2. Если у вас IOS или совсем не хотите риска, то, вероятно, стоит купить для этого отдельный самый дешевый телефон. Самый дешевый android на момент написания статьи в DNS стоит около 5 тысяч рублей.

  3. Заблокировать все перечисленные сервисы получения ip адресов. Но это ненадежно, в любой момент могут добавить новые.

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

PS - ИИ не написал ни слова в этой статье, но использовался для форматирования уже написанного текста

UPD

Ответ пресс службы мессенджера MAX

Я оставлю это без комментариев тут, предлагаю обсудить ответ в комментариях к самому ответу.

UPD 2

Первые упоминания этих странных запросов появились на хабре 20.01.26 и 09.01.26 на YouTube

Так же независимая проверка на iPhone

UDP 3

С сегодняшним обновлением 26.7.1 (RuStore) в мессенджере отключили отправку запросов к WhatsApp и Telegram. Вероятно билд подготовили после вчерашней статьи.

Но тем не менее из кода обращения к WhatsApp и Telegram не удалены, класс называется on7. Модуль в целом остался активен.