本文记录对某即时通讯软件 iOS 客户端(下称”目标 App”)私有长连接安全传输层的完整逆向。该传输层是厂商自研的类 TLS 协议,内部代号 mmtls。全文分两部分:

  1. 1. 传输层还原——mmtls 握手 → 密钥派生 → 记录层加解密 → 业务帧,如何逐层捕获并用纯 Python 逐字节复现;
  2. 2. 消息监听——在还原出的通道上以纯协议方式实时获取消息(自行扮演客户端收发,无需 App、无需 Frida)。

匿名化:全文不出现真实品牌 / 包名 / 类名 / 账号。主二进制记为 appcore,Bundle 记为 com.vendor.im,ObjC 类前缀记为 IM*,样本中人名 / 群名以占位符替换,可识别的品牌短标识在实录里用 <APP-ID> 遮盖。加密测试向量(纯 hex)保留原值,仅用于佐证”逐字节可复现”。

合法性边界:标的为研究者自有越狱设备 + 自有账号,属对自身数据链路的个人安全研究。本文只讨论协议结构与还原方法。

目录

  • • 协议概览:设计原理与逆向路线
  • • 0. 环境与前置
  • • 1. 连接分层:登录后链路上跑着几层
  • • 2. 捕获原始字节:为何必须冷启动
  • • 3. 握手两帧:ClientHello 与 ServerHello
  • • 4. 核心难点:一次握手里的两套密钥
  • • 5. 记录层:每帧的 GCM 加解密与活体验证
  • • 6. 登录报到与业务帧:双层信封
  • • 7. 会话密钥 m_sk2 的来源与时效
  • • 8. 消息正文的三层编码
  • • 9. 消息监听:纯协议消息流
  • • 10. 整合:不依赖 App 的离线客户端

协议概览:设计原理与逆向路线

mmtls 是目标 App 自研的类 TLS 安全传输协议,运行在它自建的长连接(向服务器 :8080 的裸 TCP)之上。它借用了 TLS 1.3 的握手骨架与密钥派生思想,但帧格式与密码学组合全部私有化,与标准 TLS 不兼容。这一点直接决定了逆向路线:TLS 中间人、SSLKEYLOG、SSL pinning 绕过一律失效,唯一可行路径是进程内 hook 其自研实现

为何自研而非复用 TLS。

  • • 长连接与弱网优化:IM 需一条常驻长连接承载消息推送。自研协议可把握手往返压到最少、支持带票据的快速重连(思路近似 TLS 1.3 的 0-RTT),移动端切网、断线重连都更快。
  • • 免 CA 信任链:服务器公钥直接内置于 App,握手首帧即用它包裹 ClientHello,不依赖操作系统证书体系,用户安装根证书也无法做中间人。
  • • 抬高逆向门槛:私有帧格式 + 私有密钥组合,天然增加抓包分析与第三方仿真的成本。

四个核心机制(后文分章展开)。

  1. 1. 握手仿 TLS 1.3,但更精简。一来一回两帧:客户端发 ClientHello(己方临时公钥 + 随机数),服务器回 ServerHello(其临时公钥 + 随机数 + 一张快速重连票据)。两端各用”己方私钥 + 对方公钥”导出同一共享秘密,再派生对称密钥。为防中间人篡改,整条 ClientHello 先用内置 RSA 公钥整体包裹再上网。
  2. 2. 一次握手并行跑两套密钥交换——这是全协议最核心、也最易混淆的设计:两套的曲线、哈希(SHA-256 vs SHA-384)、产物长度(32B vs 128B)都不同。混用必然对不上——分清两套是看懂整个协议的转折点(§4)。
    • • 数据密钥:椭圆曲线 ECDH(P-256) 导出共享秘密 → HKDF-SHA256 派生四把料(收 / 发加密密钥 + 收 / 发 nonce 底料),专用于加密每一帧业务数据
    • • 验身密钥:有限域 DH(1024 位自定义素数) 导出共享秘密 → TLS 1.2 PRF-SHA384 派生 Finished 校验码,仅用于握手互验,不加密任何数据
  3. 3. 记录层是 AES-256-GCM,一帧一 tag。GCM 兼具机密性与完整性:每帧除密文外附 16 字节认证 tag,篡改任意字节都会验证失败。每帧加密需一个当次唯一的 nonce,由”固定 IV 异或自增计数器”生成。特别之处:收、发两个方向的帧格式与 nonce 算法都不同(§5)。
  4. 4. mmtls 只提供安全管道,业务叠加其上。对照 §1 的七层:第 1–5 层(握手 + 记录层)是 mmtls 传输层本身;管道打通后再跑两层业务——业务帧用”双层信封”(路由信息用公开固定密钥、机密正文用账号绑定的会话密钥 m_sk2),消息正文再单独走 protobuf → zlib → AES-CBC。注意 m_sk2 不是握手产物,它与账号绑定、由登录换票链下发、约半天过期(§6 §7)。

与标准 TLS 对照。

维度
标准 TLS 1.2 / 1.3
本文的 mmtls
承载
任意 TCP,标准端口
自建长连接 :8080 裸 TCP
握手模型
ClientHello / ServerHello + ECDHE
仿 TLS 1.3,一来一回,首帧 RSA 包裹
身份信任
X.509 证书 + CA 链
服务器 RSA 公钥内置,无 CA
密钥交换
单套(ECDHE 或 DHE)
两套并行

:ECDH(数据)+ 有限域 DH(验身)
密钥派生
HKDF-SHA256
数据层 HKDF-SHA256 + 验身层 PRF-SHA384
记录层
AES-GCM / ChaCha20
AES-256-GCM,收 / 发帧格式与 nonce 各异
取明文
可 keylog / 中间人
均失效,只能进程内 hook

三个贯穿全程的先决要点。

  • • 两套密钥不可混用:数据加密 = ECDH + SHA-256;握手验身 = 有限域 DH + SHA-384。哈希或体系用错,全盘对不上(§4)。
  • • 抓包必须冷启动:握手在 App 启动后数百毫秒内完成,attach 必然错过首帧,只能挂起进程、装好 hook 再放行(§2)。
  • • 密钥不可从内存直接扒:设备为 arm64e,启用 PAC 指针签名,盲扒结构体偏移会崩;正解是守在”密钥被使用的那一刻”就地截取(§2 §4.2)。

0. 环境与前置

设备
越狱 iPad,iOS 13.7,arm64e(带 PAC 指针签名)
注入工具
frida-server

 17.15.3;主机端 frida 命令行(含 ObjC 桥)
目标
com.vendor.im

(占位),主模块 appcore
静态分析
从设备内存 dump 主二进制,导入 IDA
长连接端点
若干边缘 IP 的 :8080(由服务端下发)

范围:本文从扫码登录成功之后讲起。二维码请求、手机确认、二维码登录那条 HTTP 链路不展开,仅在 §7 交代它最终交给我们哪几把密钥。

1. 连接分层:登录后链路上跑着几层

登录成功后,客户端向 :8080 建立一条裸 TCP 长连接。抓包第一件事是先做一个能省一半力气的方向判断。

方法论:先确认它走不走系统 TLS。 hook 系统 SSLWrite(标准 TLS 的出口)观察是否触发。结果:业务连接上 SSLWrite零触发。结论明确——这条连接不走系统 TLS,是 App 在裸 TCP 上自建的一套加密。方向就此定死:不做 TLS 中间人、不抓 keylog,只进程内 hook 其自研实现。

自底向上共 7 层:

 
┌─────────────────────────────────────────────────────────────┐
│ 7. 消息正文层   protobuf → zlib → AES-128-CBC(m_sk2)          │  §8
├─────────────────────────────────────────────────────────────┤
│ 6. 业务帧层     [meta: 固定密钥] + [body: m_sk2 会话密钥]      │  §6
├─────────────────────────────────────────────────────────────┤
│ 5. 登录激活层   报到 → 服务器下发 token                        │  §6
├─────────────────────────────────────────────────────────────┤
│ 4. 内层成帧     ec000e | 类型 | 计数 | 长度 | 数据             │  §5
├─────────────────────────────────────────────────────────────┤
│ 3. 记录加密层   AES-256-GCM,收 / 发各一套密钥与 IV            │  §5
├─────────────────────────────────────────────────────────────┤
│ 2. 握手验身层   有限域 DH(1024b)+ PRF-SHA384 → Finished       │  §4.2
├─────────────────────────────────────────────────────────────┤
│ 1. 握手协商层   交换 EC 临时公钥(RSA 包裹)                    │  §3 §4.1
└─────────────────────────────────────────────────────────────┘
 

第 1–2 层的握手只发生一次;第 3–4 层是每帧收发时的加密与成帧;第 5–7 层是管道打通后承载的业务内容。逆向按此顺序自下而上逐层剥离。

陷阱:别被内嵌的 boringssl 带偏。 该 App 也内嵌了 boringssl,里面有 ChaCha20 / X25519 / HKDF 等标准算法。容易误以为业务数据由此加密——那套是给它的 HTTPS / CDN 下载走的标准 TLS,:8080 业务连接根本不碰。区分方法:看 hook 到的加密调用,其 socket 是否连向 :8080


2. 捕获原始字节:为何必须冷启动

私有协议没有公开规范,唯一可信的事实来源是链路上真实流动的字节。因此第一步不做任何解密与推断,只完成一件事:完整、无损地录下这条 TCP 连接进出的每一个字节

2.1 必须 spawn 冷启动门控,不能 attach

握手发生在 App 启动后的头几百毫秒——进程一起来就自动重连服务器,几个来回即完成暗号交换。

  • • attach(附加):等 App 跑起来再挂 frida,握手早已结束,只能抓到后续业务帧,永远看不到最关键的首次握手。
  • • spawn(冷启动门控):先杀掉进程,以挂起态重新拉起(摁住不放),装好 hook 之后再 resume 放行,从第 1 个字节起一个不漏。

由于账号已登录,冷启动后 App 会自动重连、无需重新扫码,因而可以反复冷启动、反复抓取干净的首帧——这是拿到完整握手的前提。

 
import frida, time
dev = frida.get_usb_device(timeout=10)
# 1) 先杀掉正在跑的实例,强制冷启动
for a in dev.enumerate_applications():
if a.identifier == BUNDLE and a.pid:
dev.kill(a.pid)
time.sleep(1.0)
# 2) 挂起态 spawn → attach → 装 hook → resume 放行(已登录,自动重连,无需扫码)
pid = dev.spawn([BUNDLE])
session = dev.attach(pid)
script = session.create_script(JS); script.on(‘message’, on_message); script.load()
dev.resume(pid)   # ← hook 装好后才放行,第一帧握手必被抓到
 

2.2 hook 点选在最底层的 send / recv

我们要的是最原始的线字节:App 加密完、交给系统发出前的最后一刻,以及刚从网络收进、尚未解密前的第一刻。系统调用 send / write / sendto(出)与 recv / read / recvfrom(入)正是这两个时刻,挂在这里抓到的就是真实上网字节。

一个实际问题:这些系统调用不只网络在用,读文件、管道也走它们。用 getpeername 反查 fd 即可只留网络流量——网络 socket 能查到对端 IP:port,文件 / 管道查不到,天然被过滤。

//以下为代码正文…
// 用 getpeername 判定"这是不是一个真网络 socket",顺便拿到对端 IP:port
var _getpeername = new NativeFunction(findG('getpeername'), 'int', ['int','pointer','pointer']);
function parsePeer(fd){
  var addr = Memory.alloc(128), lenp = Memory.alloc(4); lenp.writeU32(128);
  if (_getpeername(fd, addr, lenp) !== 0) return null;   // 查不到 → 不是网络 socket,丢弃
  if (addr.add(1).readU8() === 2) {                       // AF_INET
    var port = (addr.add(2).readU8()<<8) | addr.add(3).readU8();
    var ip = [4,5,6,7].map(function(o){return addr.add(o).readU8();}).join('.');
    return ip + ':' + port;
  }
  return null;
}
 
// mmtls 记录的"开头暗号":形如 ?? f1 03,首字节属于固定几个值
function scanMagic(u){
  for (var i=0; i<Math.min(u.length-2,64); i++){ if (u[i+1]===0xf1 && u[i+2]===0x03){ var c=u[i]; if (c===0x14||c===0x15||c===0x16||c===0x17||c===0x19) return i; } } return -1; } // 出方向在 onEnter 抓(数据还在),入方向在 onLeave 抓(此时才知道真实收了多少字节) ['send','write','sendto'].forEach(function(nm){ Interceptor.attach(findG(nm), { onEnter:function(a){ handle('tx', a[0].toInt32(), a[1], a[2].toInt32()); } }); }); ['recv','read','recvfrom'].forEach(function(nm){ Interceptor.attach(findG(nm), { onEnter:function(a){ this.fd=a[0].toInt32(); this.buf=a[1]; }, onLeave:function(r){ var n=r.toInt32(); if (n>0) handle('rx', this.fd, this.buf, n); }
  });
});
装好后冷启动放行:第一条 tx 即握手第一句(ClientHello),紧随的第一条 rx 即服务器回应(ServerHello)。 抓包实录:冷启动门控生效、hook 在进程出生瞬间装上、并抓到首个进出帧。SPAWN_GATING_ON 表示门控生效;HOOKED ... why:"initial-spawn" 表示 hook 在进程刚出生时装上;之后每条 ENC / DEC 即一帧(加密前 / 解密后),ARM 为主模块运行时基址:
某IOS端即时通讯 App 私有传输层(mmtls)逆向:从流量捕获到协议还原

 

冷启动门控 + 出生即挂钩 + 抓到首帧

 


 

3. 握手两帧:ClientHello 与 ServerHello

握手就是一来一回两帧:客户端先发 ClientHello,服务器回 ServerHello。两帧各自携带己方的临时公钥与一串随机数,为后续密钥派生备料。其原理是 Diffie-Hellman:两端各现场生成一对临时密钥(公钥示人、私钥自藏),交换公钥后各用”己方私钥 + 对方公钥”独全部逐字节立算出同一个共享秘密——这就是所有对称密钥的种子。

3.1 ClientHello(135 字节)

抓到的第一个出站帧,拆开如下(这是 RSA 包裹之前的明文结构):

 
ec00347fff | 高2字节 | 00000053 | ck | <32×00> | ts(4) | field_x(4) | payload(83B)
└─magic(5)─┘ └随机(2)┘└ 长度=83 ┘ (1) └─随机──┘ └时间戳┘ └连接序号┘ └──载荷──┘
 
字段
长度
含义
ec00347fff
5
固定 magic
高2字节
2
随机
00000053
4
后续 payload 长度 = 83
ck
1
校验字节 = payload 所有字节相加 & 0xff
全 0
32
32 个零(是 32 不是 33;写成 33 会被服务器直接挂断)
ts
4
时间戳(大端)
field_x
4
客户端连接序号(服务端算密钥时不用它,恒当作 1)
payload
83
见下

payload(83 字节)是一小段 protobuf,核心只做一件事:把己方临时公钥与随机数递过去:

 
0a21 <client_pub 33B>    # EC 临时公钥(压缩点:1 字节前缀 + 32 字节 X 坐标)
1220 <client_random 32B> # 32 字节随机数
1801                     # 固定标志位
20 <deviceid 变长>       # 设备短 id
2804                     # 固定 = 4
 

整个 135 字节明文,再用 RSA-1024 加密(每 117 字节一块、输出 128 字节一块),前置 4 字节大端总长后上网。

要点:为何先套 RSA。 此刻两端尚无任何共享密钥,ClientHello 里的临时公钥与随机数若明文发送,中间人可掉包做双向欺骗。用内置于 App 的服务器 RSA 公钥整体包裹,只有真服务器的私钥能拆开,从而挡住掉包。

 
def rsa_wrap_clienthello(plaintext):
key = load_pem_public_key(XMTLS_RSA_PUB_PEM)   # RSA-1024 公钥,从 App 内置资源里抠出来
out = b””
for i in range(0, len(plaintext), 117):
out += key.encrypt(plaintext[i:i+117], padding.PKCS1v15())   # 每块出 128B
return out
# 上网:4 字节大端总长 + RSA 密文
sock.sendall(struct.pack(“>I”, len(wrapped)) + wrapped)
 

用 Python 现造一个与 App 完全同构的 ClientHello:

 
private, public = gen_ecdh_p256()          # 现场生成 P-256 临时密钥对,public=33 字节压缩点
client_random = os.urandom(32)
payload = (b”\x0a\x21″ + public + b”\x12\x20″ + client_random
+ b”\x18\x01\x20″ + device_short + b”\x28\x04″)
client_hello = (bytes.fromhex(“ec00347fff”) + b”\x00\x00\x00\x00\x00\x53″
+ bytes([sum(payload) & 0xFF]) + b”\x00″*32          # ← 32 个零
+ struct.pack(“>I”, int(time.time())) + b”\x00″*4 + payload)
 

3.2 ServerHello(187 字节)

服务器的回应结构对称,把它的临时公钥、随机数,外加一张快速重连票据递回:

 
000000b7 ec00187fff … 0a2103 <svr_pub 33B> 1220 <svr_random 32B> 1a58 <88B 票据>
└长度=183┘└─magic──┘     └服务器公钥─────────┘ └服务器随机────────┘ └快速重连票据─┘
 

解析时不要硬数偏移,而是定位 0a21 / 1220 两个 tag,长度有微调也不受影响:

 
body = response[4:]
pub_at  = body.find(b”\x0a\x21″)
rand_at = body.find(b”\x12\x20″, pub_at + 35)
server_public = body[pub_at + 2 : pub_at + 35]   # 33B
server_random = body[rand_at + 2 : rand_at + 34] # 32B
 

至此两端的公钥与随机数齐备,接下来就是全协议最容易翻车的一步。


4. 核心难点:一次握手里的两套密钥

mmtls 握手时,客户端同时跑了两套 Diffie-Hellman,产出两套彼此独立的密钥:一套用于握手验身,一套用于数据加密。两套的曲线不同、哈希不同,这就是无数人卡住的根源——拿验身那套参数去凑数据加密密钥,或哈希用错,怎么算都对不上,因为用错了整整一套体系。分清它俩,是整个逆向的转折点。

用途
密钥交换
派生哈希
命名
加密每一帧数据 椭圆曲线

 ECDH(P-256)
SHA-256
数据密钥
握手验身 有限域

 DH(1024 位大素数)
SHA-384
验身密钥

要点:如何断定是”两套”而非”一套”。 关键线索来自运行时 hook:一个函数明确带 peer_name = "dhKeyAgreement" 标记、随后产出 128 字节结果——128 字节 = 1024 位,是有限域 DH,不是椭圆曲线(椭圆曲线共享秘密仅 32 字节)。产物长度不同,证明验身与数据加密走的是两套独立体系。

4.1 数据密钥:椭圆曲线 ECDH + HKDF-SHA256

这套算出的才是真正加解密每一帧数据的密钥。两端用”己方私钥 + 对方公钥”各自算出同一个 32 字节共享秘密 Z;Z 不能直接用,需经 HKDF 反复搓出四样料:发送加密密钥、接收加密密钥、发送 nonce 底料(IV)、接收 nonce 底料。

搓密钥的完整公式(已逐字节验证):

 
Z       = ECDH(自己的私钥, 对方的公钥)                     # 32B 共享秘密
PRK     = HKDF-Extract(盐=SHA256(“”), 输入=Z)              # 第一步:先把 Z 提纯
secret  = Expand(PRK,    “expand secret”, 上下文=握手摘要, 32B)
cli_key = Expand(secret, “cli key”, “”, 32B)              # 客户端→服务端 数据加密钥
svr_key = Expand(secret, “svr key”, “”, 32B)              # 服务端→客户端 数据加密钥
cli_iv  = Expand(secret, “cli iv”,  “”, 12B)              # 客户端方向 随机数底料
svr_iv  = Expand(secret, “svr iv”,  “”, 12B)              # 服务端方向 随机数底料握手摘要 = SHA256( 客户端公钥 ‖ 客户端随机 ‖ 连接序号(8字节小端) ‖ 服务器公钥 ‖ 服务器随机 )
 

两个关键细节:

  1. 1. 连接序号是 8 字节小端、首连恒为 1(哪怕帧里 field_x 是别的值)。
  2. 2. 这里的 HKDF-Expand 是改过的单块版本,不是标准库那个:信息 = 2字节长度 ‖ 标签 ‖ 上下文,结果 = HMAC-SHA256(PRK, 信息 ‖ 0x01) 取前 N 字节。照抄标准 HKDF 会算错。
纯 Python(与 App 逐字节一致):

 

//以下为代码正文…
SALT_SHA256_EMPTY = bytes.fromhex(
    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)  # SHA256("")
def hkdf_extract(ikm, salt=SALT_SHA256_EMPTY):
    return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, label, context, length):
    # 自研单块版
    info = length.to_bytes(2, "big") + label + context
    return hmac.new(prk, info + b"\x01", hashlib.sha256).digest()[:length]
def transcript_hash(cli_pub, cli_rand, conn_counter, svr_pub, svr_rand):
    return hashlib.sha256(
        cli_pub + cli_rand + conn_counter.to_bytes(8, "little")
        + svr_pub + svr_rand
    ).digest()
def derive_session_keys(Z, th):
    prk = hkdf_extract(Z)
    secret = hkdf_expand(prk, b"expand secret", th, 32)
    return {
        "cli_key": hkdf_expand(secret, b"cli key", b"", 32),
        "svr_key": hkdf_expand(secret, b"svr key", b"", 32),
        "cli_iv": hkdf_expand(secret, b"cli iv", b"", 12),
        "svr_iv": hkdf_expand(secret, b"svr iv", b"", 12),
    }

逐字节验证:拿运行时真实抓到的 Z 与 th 喂入,输出的四把密钥与 App 内部实际使用的一模一样——四把全部 [MATCH]:
某IOS端即时通讯 App 私有传输层(mmtls)逆向:从流量捕获到协议还原

 

会话密钥逐字节复现:cli_key / svr_key / cli_iv / svr_iv 全部逐字节 MATCH

 

4.2 验身密钥:有限域 DH + TLS1.2 PRF-SHA384

这套只用于握手时互相验身(证明”我确实持有握手应有的秘密”),不加密任何数据:两端各算出一个 128 字节的共享大数,用它搓出一段 Finished 证明码互发核对,核对通过即验身成功。

要点:为何抓 DH_compute_key 的 onLeave。 验身用的大数由底层 boringssl 的 DH_compute_key 算出。在该函数返回的那一刻(onLeave),输出缓冲区里正是 128 字节共享秘密 P;顺带用 DH_get0_pqg / DH_get0_key 即可一次性掏出大素数 p、底数 g 与双方公私钥。守在”算完那一刻”截取,远比去内存翻结构体可靠(尤其 arm64e 有 PAC)。

 

//以下为代码正文…
var base = Process.getModuleByName('appcore').base;  
// 'appcore' = 主二进制占位名
var F_DHpqg = new NativeFunction(
    R('DH_get0_pqg'),
    'void',
    ['pointer', 'pointer', 'pointer', 'pointer']
);
var F_DHkey = new NativeFunction(
    R('DH_get0_key'),
    'void',
    ['pointer', 'pointer', 'pointer']
);
// DH_compute_key(out, 对方公钥Y, dh) — onLeave 时 out 已经是 128B 共享秘密 P
Interceptor.attach(R('DH_compute_key'), {
    onEnter: function(a) { 
        this.key = a[0]; 
        this.pub = a[1]; 
        this.dh = a[2]; 
    },
    onLeave: function(r) {
        var o = { tag:'DHCK', n:r.toInt32() };
        o.P = hx(this.key, o.n);        // 128B 共享秘密
        o.server_pub = bnhex(this.pub);  // 对方公钥 Y
        var pr = dhParams(this.dh);      // 掏 p / g / 自己的公钥、私钥
        o.p=pr.p; o.g=pr.g; o.cpub=pr.cpub; o.cpriv=pr.cpriv;
        send(o);                         // 落盘后离线验证 P = pow(Y, 自己私钥, p)
    }
});
// 锚点:确认对端确实是"有限域 DH"而不是椭圆曲线
Interceptor.attach(R('EVP_PKEY_derive_set_peer'), { 
    onEnter:function(a){
        send({ 
            tag:'SETPEER', 
            peer_name: keyname(F_pid(a[1])) 
        }); 
        // -> "dhKeyAgreement"
    }
});
决定性发现:大素数 p 与底数 g 写死在 App 里。 两次全新冷启动握手,抓到的 p 一字不差、g 恒为 2。这意味着验身参数可 100% 脱离 App:自选私钥、用写死的 p / g 算公钥、收服务器公钥、算共享大数,全程无需 App 参与。
 
MMTLS_DH_P = int(
‘aef5bc2daedbb48e7d897641cfd8695e1b8a58df96f01b8d1aeb6bd796d06fba’
‘213ab227c02d26af7d29773076818ac3e8f0af336a6167d612bd0f42690dcd7b’
‘e3a7609fa1eaf4d806d162466ac783380a95f3f5639e3a5190b06248498990dd’
‘fa7705c8f8fc7648658752271568de95df1ab19502a024c325f9c31f2b7eb03b’, 16)
MMTLS_DH_G = 2   # 1024 位安全素数;开头字节与 RFC 标准组不同 = App 自定义
 

搓证明码用标准 TLS1.2 的 PRF(SHA384 版),同样逐字节验证:

 
def tls12_p_hash_sha384(secret, seed, length):
out, a = bytearray(), seed
while len(out) < length:
a = hmac.new(secret, a, hashlib.sha384).digest()
out += hmac.new(secret, a + seed, hashlib.sha384).digest()
return bytes(out[:length])def prf(secret, label, seed, length):
return tls12_p_hash_sha384(secret, label + seed, length)# P = pow(对方公钥Y, 自己私钥, p) 之后:
master        = prf(P,      b”master secret”,   client_random + server_random, 48)
client_finish = prf(master, b”client finished”, 握手摘要_SHA384, 48)     # 发给服务器核对
server_finish = prf(master, b”server finished”, 握手摘要_SHA384, 48)     # 核对服务器
 

辨误:登录时观察到的”签名 21 次”不是设备认证、也不是逐帧签名,而就是上面这条 SHA-384 搓密钥链在滚动——前一步输出当后一步的 key,滚 21 轮。看着吓人,实为同一条链。

本层残留(不影响主链路):那 128 字节验身公钥在握手帧中的确切字节位置、client finished 48 字节校验码的帧封装形态,尚未实证补齐(ClientHello 仅带了椭圆曲线那把 33 字节公钥)。但收发数据本身不需要这层,证据见下节。


5. 记录层:每帧的 GCM 加解密与活体验证

拿到 §4.1 的四把数据密钥后,记录层就是标准 AES-256-GCM,唯一的坑在于:收和发的帧格式不同、nonce 算法也不同。每帧的 nonce 由自增计数器与固定 IV 异或得到,保证同一把密钥每次加密的 nonce 都不重复。

发送帧

 
4字节(密文长+24) ‖ 4字节(0) ‖ 4字节(seq) ‖ tag(16) ‖ 密文
随机数 nonce = cli_iv XOR (0000000000000000 ‖ 4字节seq)   ← 异或!不是加法
附加校验 AAD  = 空
 

接收帧

 
4字节(长度) ‖ ts4 ‖ field4 ‖ tag(16) ‖ 密文
随机数 nonce = svr_iv XOR (00000000 ‖ ts4 ‖ field4)       ← 底料来自帧里的 ts 和 field
附加校验 AAD  = 空
 

陷阱:收方向 nonce 结构与发方向不同,是本层最易出错处。 收方向不是iv XOR seq,其 nonce 底料藏在帧头的 ts4 与 field4 里。若照发方向算法处理回帧,会全部验不过,且现象酷似”服务器在拒绝你”,极易误判成鉴权问题。

纯 Python 传输层:

 
def send(self, plaintext):
self.send_seq += 1
mask  = b”\x00″*8 + struct.pack(“>I”, self.send_seq)
nonce = bytes(a ^ b for a, b in zip(self.keys[“cli_iv”], mask))     # 异或
enc   = gcm_encrypt(self.keys[“cli_key”], nonce, plaintext)
cipher, tag = enc[:-16], enc[-16:]
self.sock.sendall(struct.pack(“>III”, len(cipher)+24, 0, self.send_seq) + tag + cipher)def receive(self, timeout=8.0):
length = struct.unpack(“>I”, self._read_exact(4))[0]
rest   = self._read_exact(length)                 # ts4 ‖ field4 ‖ tag16 ‖ 密文
nonce_material, tag, cipher = rest[:8], rest[8:24], rest[24:]
mask   = b”\x00″*4 + nonce_material
nonce  = bytes(a ^ b for a, b in zip(self.keys[“svr_iv”], mask))
return gcm_decrypt(self.keys[“svr_key”], nonce, cipher + tag)
 

解密后的内层成帧

GCM 解密出的明文是一条内层记录:

 
ec000e | 2字节类型 | 2字节计数 | 4字节内长 | 数据
 

活体验证:全新握手下 ping → ack 逐字节打通

这是最硬的证据:在一个完全自造的握手上(自己生成椭圆曲线密钥、不用任何缓存票据),客户端发一个 ping,服务器用 svr_key 加密回一个 ack,用上面算出的接收 nonce 成功解开——记录类型从 ping 的 0009 变为服务器应答的 000a,并回显了 ping 的时间戳 / token:04

某IOS端即时通讯 App 私有传输层(mmtls)逆向:从流量捕获到协议还原
全新握手 ping→ack 活体解开:GCM 记录层完全脱离 App 验证通过

这一帧证明:从”椭圆曲线算共享秘密 → HKDF 搓四把密钥 → GCM 收发帧 + 两套 nonce”整条链路逐字节全对、且完全脱离 App(本次握手由我们从零构造,App 未参与)。数据加密层至此闭环。


6. 登录报到与业务帧:双层信封

加密管道打通后,连接上跑的是业务帧,其内部用一套名为 AuthAes 的封装:即”AES-CBC 加密 + 一个防错校验和尾巴 + 前置随机 IV”。一个业务帧由两层信封嵌套:

  • • 外层信封(meta):用公开固定密钥1234567890123456 加密,只承载路由信息(请求类型、编号),不怕被看,故用公开密钥。
  • • 内层信封(body):用账号会话密钥 m_sk2 加密,承载真正的机密内容。

AuthAes 原语

 
def auth_aes_encrypt(key16, plaintext, iv16=None):
salt = os.urandom(4)
footer = salt + struct.pack(“>I”, fnv1a32(plaintext + salt))   # 尾巴:4字节盐 + 4字节校验和
body = plaintext + footer
pad  = 16 – (len(body) % 16); body += bytes([pad]) * pad        # PKCS7 补齐
iv = iv16 or os.urandom(16)
return iv + AES.new(key16, AES.MODE_CBC, iv).encrypt(body)      # 前挂 16字节 IVdef fnv1a32(data, seed=0x811C9DC5):     # 一个很轻的校验和算法
v = seed
for b in data: v = ((v ^ b) * 0x01000193) & 0xFFFFFFFF
return v
 

登录报到帧

连接建好后先发一个登录激活帧”报到”,服务器回 ec000e000a 外加一个 2 字节 token(之后所有业务帧都须携带这个”当日通行号”):

 
def build_login_frame(device_uuid, token, *, id_byte=0x30, activation=b”\x00″*16):
uuid = device_uuid.encode(“ascii”)
return (MAGIC + b”\x09″ + token + b”\x00\x00″ + struct.pack(“>H”, len(uuid)+21)
+ bytes([id_byte]) + token + activation
+ b”\x00\x06\x00\x04″ + bytes([len(uuid)]) + uuid)login_reply = conn.receive(timeout)
if login_reply[:5] == bytes.fromhex(“ec000e000a”):
self.token = login_reply[12:14]     # 领到通行号,后续业务帧路由用


业务帧:两层信封拼接

 

//以下为代码正文…
def build_business_frame(meta_cipher, body_cipher, *, counter, token, uid):
    inner = len(meta_cipher) + len(body_cipher) + 1
    frame = bytearray(
        MAGIC + b"\x0b"
        + struct.pack(">H", counter) + b"\x00\x00"
        + struct.pack(">H", inner+21) + b"\x00" + token + b"\x00\x01\x00\x0e"
        + struct.pack(">I", inner+5) + b"\x00\x06\x00\x00"
        + struct.pack(">I", uid & 0xFFFFFFF) + b"\x2f"  # 0x2f = '/'
        + struct.pack(">I", len(meta_cipher))
        + meta_cipher + body_cipher + b"\x2f"
    )
    frame[11] = sum(frame[14:]) & 0xFF  # 校验字节
    return bytes(frame)
def parse_business_frame(raw, business_key):
    if raw[:5] != MAGIC + b"\x0b":
        return None
    meta_len = int.from_bytes(raw[31:35], "big")
    meta_cipher = raw[35:35+meta_len]
    body_cipher = raw[35+meta_len:-1]
    meta = auth_aes_decrypt(META_KEY, meta_cipher)      # 外层:固定密钥
    body = auth_aes_decrypt(business_key, body_cipher)  # 内层:m_sk2
    f = pb_fields(meta)
    return {
        "op": f.get(5),
        "request_id": f.get(6),
        "body": body
    }  # op=干什么, request_id=第几号

固定外层密钥 1234567890123456 当场现形。 下面是抓包解密后的一帧内层明文(hook 在 App 的 AES 调用上,DEC = 解密结果)。把 out 按 protobuf 拆开,第 3 个字段就是那把固定密钥——这是"外层信封用公开固定密钥"的实锤:
 
{“tag”:”DEC”,”key”:”b071349b…279e2d3c|#16“,
“out”:”08 97f68d4a  10 3c  1a10 31323334353637383930313233343536  20 d6caf0f78d808004  30 a038″}
#2=60┘ └#3(len16) = ASCII “1234567890123456” ┘ └#4=uid/sid┘
 

解读:#2 = 60#3 = "1234567890123456"——这正是 §7 里识别”会话密钥投递确认”的判据。

发送流程:request_id 自增并先落盘预留(防崩溃后复用编号)→ body 先用 m_sk2 加密 → 拼 meta → 组帧 → 走 §5 的 GCM 发出。


7. 会话密钥 m_sk2 的来源与时效

body 的加密密钥 m_sk2不是握手算出来的,而是登录换票链下发的。握手产出的是”传输层密钥”(每次连接都新);m_sk2 是”业务层会话密钥”,与账号绑定、约半天更换一次,在扫码登录成功后由一串换票请求(GetSt / GetHKey 之类)投递给客户端。

服务器投递时会发两把 16 字节密钥,靠一个字段区分:

密钥
用途
识别方式
key456
中间过程用
投递确认里 #4 == 会话id(sid)
m_sk2 业务 body 主密钥
投递确认里 #4 == 账号id(uid)
 
def classify_hkey_confirm(plain, uid, sid):
f = {fld: v for fld, w, v in pb_walk(plain) if w in (0, 2)}
if f.get(2) != 60 or f.get(3) != FIXED_KEY:   # ← 就是 §6 那帧解密看到的 #2=60 && #3=固定密钥
return None
if f.get(4) == uid: return “m_sk2”
if f.get(4) == sid: return “key456”
return None
 

另一条路径 GetHKey 的响应里,#20 字段直接就是 16 字节会话密钥:

 
def parse_gethkey_response(frame_plaintext):
i = frame_plaintext.find(b”\xa2\x01\x10″)               # a2 01 10 = 字段#20,长16
return {“session_key”: frame_plaintext[i+3:i+3+16]}
# 实测:…a2011005980a98e5625381c07c00da74707fbb… → session_key=05980a98…4707fbb ✅
 

命门:m_sk2 约 11 小时过期,且过期是”业务层失效”而非”连接断开”。 传输层、GCM 都还活着,但业务类请求都会报 rpc_parse_error必须重新换票、重铸 m_sk2,靠重连无效。做长期无人值守监听绕不过这道坎,需加”心跳探测 + 自动 GetHKey 重铸”。


8. 消息正文的三层编码

业务 body 里,真正的聊天消息再套一层。发送前经三道工序:先按固定格式打包为二进制(protobuf),再压缩(zlib),最后加密(AES-128-CBC,密钥即 m_sk2);接收时倒序:解密 → 解压 → 解析。

 
def cbc_ivhead_encrypt(key16, plaintext, iv16=None):
iv16 = iv16 or os.urandom(16)
return iv16 + AES.new(key16, AES.MODE_CBC, iv16).encrypt(pkcs7_pad(plaintext))def encode_message_payload(pb_plaintext, m_sk2):        # 发:protobuf→zlib→CBC
return cbc_ivhead_encrypt(m_sk2, zlib.compress(pb_plaintext, 0))def decode_message_payload(inner_ciphertext, m_sk2):    # 收:CBC→zlib→protobuf
return zlib.decompress(cbc_ivhead_decrypt(m_sk2, inner_ciphertext))
 

发送文本的 protobuf 字段布局(已逆清核心字段):

 
#1=消息序号  #2=0单聊/1群  #3=对方id 或 #4=群id
#6=文本正文  #9=客户端消息id(去重用)  #10=平台号  #13=发送者名
 

要点:如何确认它是 protobuf 而非自造格式。 在发送那一刻做内存扫描,抓到尚未加密的明文正文(形如 0a0d080012090a07 <文本>)——这种 tag-长度-值结构就是 protobuf 的指纹。有了直接证据,才照 protobuf 解析,而非臆断一个”魔数 + TLV”的私有格式。

离线往返自测全绿:

 
[OK] 握手 KDF 复现(椭圆曲线 + HKDF-SHA256)
[OK] GetHkey 响应 #20 会话密钥解析
[OK] AuthAes 双层信封往返
[OK] 消息 protobuf→zlib→CBC 往返(含明文 “abc123”)
== 全部通过 ✅ 协议栈可离线复现 ==
 

9. 消息监听:纯协议消息流

无需 App 运行、无需 Frida。在 §5–§8 还原出的加密通道上自行扮演客户端向服务器拉取 / 接收消息——这才是真正可无人值守的监听。核心是两个命令字:

命令
作用
op1004
消息同步(主动带游标拉取);响应里每条消息都自带 f6 = 所属会话
op1014
服务器主动推送的实时消息

要点:会话是服务器”吐”给你的,不是你去枚举的。 一开始想找”拉群列表”命令来枚举所有群——根本没有这种命令。服务器唯一主动吐出会话 id 的地方就是 op1004 消息同步:每来一条消息,它自带一个 f6 告知所属会话。即客户端靠”收到消息”来发现会话;请求里只带消息游标、不带会话 id。

更关键的是,这个游标(消息序号)可以往回拨——把游标设为”当前序号 − N”,服务器会重放那段区间所有会话的消息,f6 就把一批会话 id 带出来。于是”发现历史会话”从”干等新消息”变为”可控地批量捞取”。

解开一条消息后的字段:

 
f4  = 发送者id      f6  = 会话id(大数=群,五位小数=单聊/联系人,0=系统噪声要滤掉)
f8  = 正文          f12 = 时间戳      f16 = 发送者名
 

陷阱:老版解析器对单聊消息会”静默返回空”,表现为”始终收 0 条”。 根因是解析器结尾有个硬门槛 if 会话字段#6 > 0,把所有 #6 == 0 的记录直接丢弃——而单聊消息与 op1014 实时推送恰好 #6 == 0,于是最想要的实时消息被全部丢弃且不报任何错。

正解:放行 #6 == 0(靠正文 #8 或类型字段判定),同时加”消息 id #1 > 0“的条件挡掉 ACK / 控制帧。这样回拉历史 + 实时推送双双能解出,群消息、单聊消息都能稳定抓到,纯协议自动发现会话才真正跑通。

单条消息解析器:

 
def parse_incoming_message_pb(pb):
nf = {f: v for f, w, v in pb_walk(pb) if w in (0, 2)}
# 老版硬门槛 if int(nf[6]) > 0 会丢弃 #6==0 的单聊/推送 → “收0″根因
# 正确:放行 #6==0,并要求 msgid #1>0 挡掉控制帧
if not any(k in nf for k in (4, 5, 8, 12, 16)): return None
return {“sender”:       nf.get(4),
“conversation”: nf.get(6),
“text”:         first_text(nf.get(8)),
“timestamp”:    nf.get(12),
“sender_name”:  dec(nf.get(16))}
 

9.1 成果代码:纯协议监听闭环

监听就是”往 §5 的 mmtls 通道发命令字、收帧、解出消息”:先回拉一次(游标回拨,靠 f6 建全量会话视图),再常驻收 op1014 实时推送。循环里的 body 都是 mmtls 记录层 GCM 已解密的明文,直接喂给上面的解析器:

 
def listen(client, *, backfill=20000, poll_timeout=30):
“””纯协议监听:mmtls 通道收帧 → 解出消息。回拉发现会话 + 常驻收实时推送,无 App、无 Frida。”””
seen = set()
cursor = client.msg_cursor()                        # 当前消息序号(从 op1004 状态里取)
# 回拉:游标回拨 backfill 条,服务器重放该区间,靠每条消息的 f6 发现会话
for _op, body in client.pull_messages(since=cursor – backfill):   # op1004
m = parse_incoming_message_pb(body)             # body 已由 §5 mmtls GCM 解出
if m and m[“conversation”]:
seen.add(m[“conversation”])                 # f6:大数=群,五位数=单聊/联系人
while True:                                         # 常驻收服务器主动推的实时消息
for _op, body in client._receive_pushes({1004, 1014}, timeout=poll_timeout):
m = parse_incoming_message_pb(body)
if m:                                       # None = ACK / 控制帧,已挡掉
yield m                                 # 交上层:落库 / 告警 / 回复
 

运行实录(人名 / 群名已匿名): 一次回拉即由 f6 还原出全量会话视图,op1014 再把实时消息推进来:

 
[op1004] backfill 游标 -20000 → 由 f6 发现 71 个群 + 218 个联系人会话
[op1014] push  conv=<示例群A>  from=<示例用户A>  “示例文本…”
== 纯协议监听跑通:全程无 App、无 Frida、无 UI ==
 

10. 整合:不依赖 App 的离线客户端

把 §3–§9 拼起来,即得一个不依赖 App、可离线运行的客户端。连接生命周期:

 
def __enter__(self):
self.conn.handshake()                          # §3+§4:交换公钥 → 算出四把数据密钥
login = build_login_frame(self.state[“device_uuid”], self.token, …)
self.conn.send(login)                          # §6:报到
reply = self.conn.receive(self.timeout)        # §5:收
if reply[:5] == bytes.fromhex(“ec000e000a”):
self.token = reply[12:14]                  # 领通行号
self.bootstrap()                               # 发 App 同款初始化请求序列
return self
 

其上再挂:_send(op, body)(§6 双层信封,request_id 落盘)、_receive_pushes({1004,1014})(§9 监听)、以及各类业务请求(发消息、拉群成员等)。

已逐一验证的资产:

状态
验证方式
ClientHello / ServerHello 结构
冷启动抓首帧,字段对齐
数据密钥(椭圆曲线 + HKDF-SHA256)
真实 Z + 摘要逐字节复现四把密钥(§4.1)
验身密钥(有限域 DH + PRF-SHA384)
真实共享大数复现 master / finished
DH 的 p / g 写死
两次冷启动逐字节相同
GCM 收发帧 + 两套 nonce
全新握手 ping→ack 活体解开(§5)
业务帧双层信封
往返自测 + 固定密钥现形(§6)
m_sk2 / key456 获取与区分
投递确认按 uid / sid 分类
消息 protobuf→zlib→CBC
往返含明文
纯协议消息监听
op1004 回拉发现会话(71 群 / 218 联系人)+ op1014 实时推送,群 / 单聊双双解出(§9)

总结:整条链路”扫码登录 → 握手 → 加密传输 → 业务帧 → 会话密钥 → 消息编解码 → 消息监听”已完整还原,密码学层做到 100% 脱离 App 离线复现;仅剩握手验身层的 client finished 帧封装与 128 字节验身公钥的确切字节位置待补,不影响收发数据的主链路。

 

本文由 码王吴彦祖  投稿

长期征稿

稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:

1000 元一篇,把你的逆向笔记变现

猿人学的业务:
1、猿人学爬虫逆向课2、海外代理IP(不限量、动态/静态住宅IP)3、youtube 等音视频低成本下载回流4、大模型预训练数据5、SERP (google/bing等搜索结果)6、海外电商/社媒数据若有朋友需要可以在菜单栏里加我微信,谢谢。

猿人学banner宣传图

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。

***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...