Messenger MAX 会追踪 VPN 用户吗?逆向工程表明答案是肯定的

来源: https://habr.com/ru/articles/1006666/

而且,这个标题并非标题党。通过对俄罗斯即时通讯软件MAX进行逆向工程,我们证实了最糟糕的假设。

最近网上开始出现关于 MAX Messenger 向 Telegram 和 WhatsApp 发送奇怪请求的报道,引发了人们对这些请求的性质和目的的猜测。但猜测是一回事,了解真相又是另一回事。这可能是某种集成功能,也可能是一个随机的分析模块。为了弄清真相并与大家分享,我决定深入研究一下客户端,了解它的功能和原因。

简而言之——它包含一个间谍模块,由MAX开发人员创建,用于监控VPN用户。他们试图使该模块无法被屏蔽,并添加了远程控制功能。

准备

由于 MAX 客户端不包含调试信息且难以逆向工程,我决定首先查看测试对象发出的网络请求。为此,我们需要:

  1. 我使用了mitmproxy,并启用了 WireGuard 模式(--mode wireguard ),因为它允许拦截所有流量。
  2. 我使用的是Android Studio自带的Android模拟器。
  3. 将 mitmproxy 根证书上传到模拟器的系统存储中。
  4. 适用于安卓系统的 WireGuard 客户端。我使用的是官方版本。启动后,mitmproxy/mitmweb 会显示一个二维码和 WG 客户端的配置信息。
  5. MAX_(RS)_v.26.4.3(6552)(8.0-15.0)(arm7a,arm64-8a,x86,x86-64) MAX Messenger 本身。在我的研究中,我使用了在 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 命令(可能路由到不同的服务)
01 1 请求序列号 (SEQ)
0006 0x0006 操作码(OPCODE)
01 1 压缩标志
000087 135 有效载荷尺寸
data MessagePack格式的数据

因此,为了分析流量,我不得不为 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 无线上网
3 手机慢
4 移动快
  • hosts - 要检查的主机列表及其检查状态。值可以是:
代码 Ping(ICMP) TCP:443 结果
0 失败 失败 主机完全不可用
1 好的 失败 ping 通了,但是 HTTPS 不可用。
2 失败 好的 无 ping 操作,HTTPS 可用
3 好的 好的 ping 通了,HTTPS 可用
  • ip — 显然,这是客户端的 IP 地址。此外,不同的事件可能会收到来自不同来源的 IP 地址。
  • operator — 该行包含运营商的PLMN代码,由移动国家代码和运营商代码组成:
    • MCC:(250 俄语)
    • 跨国公司:(01 在本例中为 MTS)
  • vpn — 指示系统中 VPN 连接是否处于活动状态的标志。此标志仅受限于手机本身是否正在使用 VPN 软件(原生 Android API)。

还发现,该模块可通过服务器远程启用和禁用 host-reachability 。登录/会话刷新时,会返回一个包含标志的配置,从而可以针对特定帐户启用此功能

这是如何运作的

  1. 应用程序启动时,会获取并打乱源 IP 地址列表:
  1. IP 地址以异步方式获取,超时时间为 3000 毫秒,并且127.0.0.1 忽略响应。
  2. 同时,使用以下命令查询目标主机:
  • ping(ICMP)
  • 连接 TCP:443 (通过 HTTPS 检查可用性)。超时时间相同,为 3000 毫秒。
  1. 当您最小化/展开应用程序时,数据会发送到api.oneme.ru 消息中心。HOST_REACHABILITY

值得注意的是,这不是一个被放弃的模块;它正在不断发展,并且有迹象表明他们计划添加一个功能齐全的模块,用于从服务器执行命令,将其变成Roskomnadzor 的袖珍审计员

不同版本中,被检查的主机列表也会有所不同。例如,有时会启用对 Telegram 和 WhatsApp 的检查,有时则会禁用(但并未移除;APK 代码中始终包含这些主机)。我相信目前正在进行测试和改进,之后应该可以轻松地将此模块切换到完全远程控制模式。

详细的逆向工程图,链接在剧透下方。

本节不包含屏幕截图,因为乍一看它仍然像是一堆难以理解的、掺杂着小代码的杂乱无章的代码。

我已经指定了 apk 的确切版本,并且会指出处理此内容或彼内容的类的名称,以便任何人都可以查找和仔细检查。

搜索的起点是代码HOST_REACHABILITY ,它很容易在public final vb7 .vb7 文件中找到。它引用了来自 的值public abstract class zb7 ,而 包含 C 字符串,JADX 错误地将其解释为 int8 数组。它的实际内容如下所示:

IP 地址以异步方式获取qb7 → pb7 ,超时时间为 3000 毫秒。sources/p000/qb7.java:45

pb7 获取 URL 列表(IPv4/IPv6 Yandex、ifconfig.me、ipify、checkip.amazonaws.comip.mail.ru),打乱顺序,然后使用正则表达式搜索,直到找到第一个有效的 IP 地址(并​​将其丢弃)127.0.0.1

调用 GET_HOST_REACHABILITY 时:

该任务在应用程序启动时初始化:HostReachabilityTask 调用 new xb7().m22182c() (sources/one/p010me/android/OneMeApplication.javasources/p000/C0136c6.java )

xb7.m22182c() 仅当 PMS 标志host-reachabilitysources/p000/xb7.java:164sources/p000/j06.java:457 )启用时,才会在 p3i 中注册监听器。PMS 密钥本身位于sources/ru/p026ok/tamtam/android/prefs/PmsKey.java

然后,每次应用程序切换到前台时,p3i 都会调用 vb7 mo462j() ,对于 xb7 来说,会启动 vb7 协程(如果之前的协程尚未激活)。(sources/p000/p3i.java:102sources/p000/dk6.java:92

在 vb7 中,开始检查/报告之前会有 3000 毫秒的延迟。

它具体是如何检查的?

  • ping 操作通过标准InetAddress.isReachableub7 → jy2(case 3) )执行。
  • TCP 连接到主机:443,超时时间为 3000 毫秒 ( tb7 → xb7.m22181a → sq2(case 25) )

代码映射发生在sources/p000/rb7.java ,但使用 smali,jadx 在这里显示得歪歪扭扭。

如果不存在连接,connection_type 为 1;zw3.f79639a 如果存在连接,则为枚举值。vpn是通过 ConnectivityManager 的 ( and )
运算符检查 NetworkCapabilities.TRANSPORT_VPN 的值,否则为空。sources/p000/vb7.java:125 sources/p000/hw3.java:186
TelephonyManager.getNetworkOperator() + ":" + getNetworkOperatorName() "undefined"

定向遥控

PMS 信息作为配置的一部分,在用户登录时 从服务器返回。该响应会被解析成不同sources/p000/qea.java:900 的值。因此,服务器可能会针对不同的用户/会话返回不同的值。此配置会在用户注销时被清除sources/p000/olc.java:34

这一切意味着什么?

这一切似乎显而易见,但我们来谈谈其中的细微差别:

  1. 这绝非巧合。他们喜欢谈论开源分析模块,但事实并非如此。这个模块显然是VK内部开发的,而且资源被封锁和限制的情况表明,他们正是此次审计的目标。
  2. 这些数据并非发送到单独的分析域,而是与即时通讯软件的主流量混合在一起,因此,如果不屏蔽该即时通讯软件,就无法屏蔽此类分析数据。此外,其协议也无法被自动化工具解码。
  3. 验证方法(ping + tcp:443)直接测试资源是否在 TSPU 上被成功阻止。TSPU 不会阻止 ping 请求,但会限制对特定端口和协议的访问。
  4. 显然,IP地址的选择并非随机,而是俄罗斯服务和外国服务各占一半。为什么?为了抓捕那些设置流量路由却不进行本地流量隧道传输的“聪明人”。
  5. 当前的远程控制功能及其似乎不可避免的改进,将国家信使变成了国家间谍软件工具。
  6. 这种方法非常适合捕获和阻止个人(私人)VPN服务器,这些服务器通常具有相同的输入和输出IP。
  7. 这种方法非常适合将特定 VPN 服务的用户与特定人员联系起来(我不会展开讨论接下来的内容)。
  8. 能够针对个人或群体启用此功能令人非常担忧。
  9. 发送运营商的PLMN代码可以很好地表明用户可能位于俄罗斯。然而,与地理位置信息不同,目前无法禁止收集移动运营商信息。

他们可能想把数百万台设备变成扫描仪,以检验封锁措施的效果,并找出那些绕过封锁的人。

我看到有人这样想:“为什么俄罗斯联邦储蓄银行做不到这一点,或者它已经做到了?”也许它已经做到了,但想想普通人在俄罗斯联邦储蓄银行应用程序上花费的时间,与现代即时通讯软件(本质上就是一个社交网络)相比,又有多少时间呢?

那我该怎么办?

看来最简单的解决办法就是删除它。

如果由于某种原因无法删除,那么你实际上只有几种选择(除了技术上比较复杂的选择,但这取决于个人喜好):

  1. 如果您使用的是安卓系统 ,可以将应用安装在单独的、隔离的工作区中。通常情况下,这样的工作区不会继承主配置文件中的 VPN 连接。
  • 三星 ——Knox安全文件夹
  • 小米/红米/POCO — 第二空间
  • 华为/荣耀 — 私人空间
  • 通用选项,包括Pixel / Motorola / Nothing :庇护所、岛屿、孤立
  1. 如果你用的是iOS系统或者不想冒险,那么为此目的购买一部单独的、价格低廉的手机或许更划算。截至撰写本文时,最便宜的安卓手机售价约为5000卢布。
  2. 屏蔽所有列出的获取 IP 地址的服务。但这并不可靠;随时可能新增获取 IP 地址的服务。

当然,也要告诉你的朋友们。记住,即使你没有什么可隐瞒的,这也可能会剥夺你基本的日常生活便利和访问大部分互联网的权利。

附注:本文中人工智能没有撰写一个字,而是用于格式化已撰写的文本。

更新

来自 MAX 信使新闻服务的回复

我在此不做任何评论,但建议在答案本身的评论区进行讨论。

更新 2

这些奇怪的查询最早出现在 Habr 网站 2026 年 1 月 20 日的报道中,随后又出现在 YouTube 上 2026 年 1 月 9 日的报道中。

此外,还有一项针对 iPhone 的独立检查。

UDP 3

在今天发布的 26.7.1 版本(RuStore 版)更新中,该即时通讯软件已禁用向 WhatsApp 和 Telegram 发送请求的功能 。此次更新很可能是在昨天的文章发布后进行的。

然而,调用 WhatsApp 和 Telegram 的代码(该类名为 WhatsAppTelegram并未被移除 on7 。整个模块仍然处于活动状态。

个人点评:
公民是拿来监控的,公民中绝不能有对普金怀有二心之人