本文记录对某即时通讯软件 iOS 客户端(下称”目标 App”)私有长连接安全传输层的完整逆向。该传输层是厂商自研的类 TLS 协议,内部代号 mmtls。全文分两部分:
1. 传输层还原——mmtls 握手 → 密钥派生 → 记录层加解密 → 业务帧,如何逐层捕获并用纯 Python 逐字节复现; 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. 握手仿 TLS 1.3,但更精简。一来一回两帧:客户端发 ClientHello(己方临时公钥 + 随机数),服务器回ServerHello(其临时公钥 + 随机数 + 一张快速重连票据)。两端各用”己方私钥 + 对方公钥”导出同一共享秘密,再派生对称密钥。为防中间人篡改,整条 ClientHello 先用内置 RSA 公钥整体包裹再上网。 -
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. 记录层是 AES-256-GCM,一帧一 tag。GCM 兼具机密性与完整性:每帧除密文外附 16 字节认证 tag,篡改任意字节都会验证失败。每帧加密需一个当次唯一的 nonce,由”固定 IV 异或自增计数器”生成。特别之处:收、发两个方向的帧格式与 nonce 算法都不同(§5)。 -
4. mmtls 只提供安全管道,业务叠加其上。对照 §1 的七层:第 1–5 层(握手 + 记录层)是 mmtls 传输层本身;管道打通后再跑两层业务——业务帧用”双层信封”(路由信息用公开固定密钥、机密正文用账号绑定的会话密钥 m_sk2),消息正文再单独走protobuf → zlib → AES-CBC。注意m_sk2不是握手产物,它与账号绑定、由登录换票链下发、约半天过期(§6 §7)。
与标准 TLS 对照。
|
|
|
|
|---|---|---|
|
|
|
:8080 裸 TCP |
|
|
|
|
|
|
|
|
|
|
|
两套并行
|
|
|
|
|
|
|
|
|
|
|
|
|
三个贯穿全程的先决要点。
-
• 两套密钥不可混用:数据加密 = ECDH + SHA-256;握手验身 = 有限域 DH + SHA-384。哈希或体系用错,全盘对不上(§4)。 -
• 抓包必须冷启动:握手在 App 启动后数百毫秒内完成,attach 必然错过首帧,只能挂起进程、装好 hook 再放行(§2)。 -
• 密钥不可从内存直接扒:设备为 arm64e,启用 PAC 指针签名,盲扒结构体偏移会崩;正解是守在”密钥被使用的那一刻”就地截取(§2 §4.2)。
0. 环境与前置
|
|
|
|---|---|
|
|
|
|
|
frida-server
frida 命令行(含 ObjC 桥) |
|
|
com.vendor.im
appcore |
|
|
|
|
|
: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 会自动重连、无需重新扫码,因而可以反复冷启动、反复抓取干净的首帧——这是拿到完整握手的前提。
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 为主模块运行时基址:

3. 握手两帧:ClientHello 与 ServerHello
握手就是一来一回两帧:客户端先发 ClientHello,服务器回 ServerHello。两帧各自携带己方的临时公钥与一串随机数,为后续密钥派生备料。其原理是 Diffie-Hellman:两端各现场生成一对临时密钥(公钥示人、私钥自藏),交换公钥后各用”己方私钥 + 对方公钥”独全部逐字节立算出同一个共享秘密——这就是所有对称密钥的种子。
3.1 ClientHello(135 字节)
抓到的第一个出站帧,拆开如下(这是 RSA 包裹之前的明文结构):
└─magic(5)─┘ └随机(2)┘└ 长度=83 ┘ (1) └─随机──┘ └时间戳┘ └连接序号┘ └──载荷──┘
|
|
|
|
|---|---|---|
ec00347fff |
|
|
|
|
|
|
00000053 |
|
|
|
|
|
payload 所有字节相加 & 0xff |
|
|
32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
payload(83 字节)是一小段 protobuf,核心只做一件事:把己方临时公钥与随机数递过去:
1220 <client_random 32B> # 32 字节随机数
1801 # 固定标志位
20 <deviceid 变长> # 设备短 id
2804 # 固定 = 4
整个 135 字节明文,再用 RSA-1024 加密(每 117 字节一块、输出 128 字节一块),前置 4 字节大端总长后上网。
要点:为何先套 RSA。 此刻两端尚无任何共享密钥,ClientHello 里的临时公钥与随机数若明文发送,中间人可掉包做双向欺骗。用内置于 App 的服务器 RSA 公钥整体包裹,只有真服务器的私钥能拆开,从而挡住掉包。
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:
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 字节)
服务器的回应结构对称,把它的临时公钥、随机数,外加一张快速重连票据递回:
└长度=183┘└─magic──┘ └服务器公钥─────────┘ └服务器随机────────┘ └快速重连票据─┘
解析时不要硬数偏移,而是定位 0a21 / 1220 两个 tag,长度有微调也不受影响:
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,产出两套彼此独立的密钥:一套用于握手验身,一套用于数据加密。两套的曲线不同、哈希不同,这就是无数人卡住的根源——拿验身那套参数去凑数据加密密钥,或哈希用错,怎么算都对不上,因为用错了整整一套体系。分清它俩,是整个逆向的转折点。
|
|
|
|
|
|---|---|---|---|
| 加密每一帧数据 | 椭圆曲线
|
SHA-256 |
|
| 握手验身 | 有限域
|
SHA-384 |
|
要点:如何断定是”两套”而非”一套”。 关键线索来自运行时 hook:一个函数明确带
peer_name = "dhKeyAgreement"标记、随后产出 128 字节结果——128 字节 = 1024 位,是有限域 DH,不是椭圆曲线(椭圆曲线共享秘密仅 32 字节)。产物长度不同,证明验身与数据加密走的是两套独立体系。
4.1 数据密钥:椭圆曲线 ECDH + HKDF-SHA256
这套算出的才是真正加解密每一帧数据的密钥。两端用”己方私钥 + 对方公钥”各自算出同一个 32 字节共享秘密 Z;Z 不能直接用,需经 HKDF 反复搓出四样料:发送加密密钥、接收加密密钥、发送 nonce 底料(IV)、接收 nonce 底料。
搓密钥的完整公式(已逐字节验证):
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. 连接序号是 8 字节小端、首连恒为 1(哪怕帧里 field_x 是别的值)。 -
2. 这里的 HKDF-Expand 是改过的单块版本,不是标准库那个: 信息 = 2字节长度 ‖ 标签 ‖ 上下文,结果 = HMAC-SHA256(PRK, 信息 ‖ 0x01)取前 N 字节。照抄标准 HKDF 会算错。
//以下为代码正文…
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]:

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 参与。
‘aef5bc2daedbb48e7d897641cfd8695e1b8a58df96f01b8d1aeb6bd796d06fba’
‘213ab227c02d26af7d29773076818ac3e8f0af336a6167d612bd0f42690dcd7b’
‘e3a7609fa1eaf4d806d162466ac783380a95f3f5639e3a5190b06248498990dd’
‘fa7705c8f8fc7648658752271568de95df1ab19502a024c325f9c31f2b7eb03b’, 16)
MMTLS_DH_G = 2 # 1024 位安全素数;开头字节与 RFC 标准组不同 = App 自定义
搓证明码用标准 TLS1.2 的 PRF(SHA384 版),同样逐字节验证:
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 都不重复。
发送帧
随机数 nonce = cli_iv XOR (0000000000000000 ‖ 4字节seq) ← 异或!不是加法
附加校验 AAD = 空
接收帧
随机数 nonce = svr_iv XOR (00000000 ‖ ts4 ‖ field4) ← 底料来自帧里的 ts 和 field
附加校验 AAD = 空
陷阱:收方向 nonce 结构与发方向不同,是本层最易出错处。 收方向不是
iv XOR seq,其 nonce 底料藏在帧头的 ts4 与 field4 里。若照发方向算法处理回帧,会全部验不过,且现象酷似”服务器在拒绝你”,极易误判成鉴权问题。
纯 Python 传输层:
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 解密出的明文是一条内层记录:
活体验证:全新握手下 ping → ack 逐字节打通
这是最硬的证据:在一个完全自造的握手上(自己生成椭圆曲线密钥、不用任何缓存票据),客户端发一个 ping,服务器用 svr_key 加密回一个 ack,用上面算出的接收 nonce 成功解开——记录类型从 ping 的 0009 变为服务器应答的 000a,并回显了 ping 的时间戳 / token:04

这一帧证明:从”椭圆曲线算共享秘密 → HKDF 搓四把密钥 → GCM 收发帧 + 两套 nonce”整条链路逐字节全对、且完全脱离 App(本次握手由我们从零构造,App 未参与)。数据加密层至此闭环。
6. 登录报到与业务帧:双层信封
加密管道打通后,连接上跑的是业务帧,其内部用一套名为 AuthAes 的封装:即”AES-CBC 加密 + 一个防错校验和尾巴 + 前置随机 IV”。一个业务帧由两层信封嵌套:
-
• 外层信封(meta):用公开固定密钥 1234567890123456加密,只承载路由信息(请求类型、编号),不怕被看,故用公开密钥。 -
• 内层信封(body):用账号会话密钥 m_sk2加密,承载真正的机密内容。
AuthAes 原语
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(之后所有业务帧都须携带这个”当日通行号”):
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 个字段就是那把固定密钥——这是"外层信封用公开固定密钥"的实锤:
“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) |
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 字节会话密钥:
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);接收时倒序:解密 → 解压 → 解析。
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 字段布局(已逆清核心字段):
# #6=文本正文 #9=客户端消息id(去重用) #10=平台号 #13=发送者名
要点:如何确认它是 protobuf 而非自造格式。 在发送那一刻做内存扫描,抓到尚未加密的明文正文(形如
0a0d080012090a07 <文本>)——这种 tag-长度-值结构就是 protobuf 的指纹。有了直接证据,才照 protobuf 解析,而非臆断一个”魔数 + TLV”的私有格式。
离线往返自测全绿:
[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 带出来。于是”发现历史会话”从”干等新消息”变为”可控地批量捞取”。
解开一条消息后的字段:
f8 = 正文 f12 = 时间戳 f16 = 发送者名
陷阱:老版解析器对单聊消息会”静默返回空”,表现为”始终收 0 条”。 根因是解析器结尾有个硬门槛
if 会话字段#6 > 0,把所有#6 == 0的记录直接丢弃——而单聊消息与 op1014 实时推送恰好#6 == 0,于是最想要的实时消息被全部丢弃且不报任何错。正解:放行
#6 == 0(靠正文#8或类型字段判定),同时加”消息 id#1 > 0“的条件挡掉 ACK / 控制帧。这样回拉历史 + 实时推送双双能解出,群消息、单聊消息都能稳定抓到,纯协议自动发现会话才真正跑通。
单条消息解析器:
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 已解密的明文,直接喂给上面的解析器:
“””纯协议监听: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 再把实时消息推进来:
[op1014] push conv=<示例群A> from=<示例用户A> “示例文本…”
== 纯协议监听跑通:全程无 App、无 Frida、无 UI ==
10. 整合:不依赖 App 的离线客户端
把 §3–§9 拼起来,即得一个不依赖 App、可离线运行的客户端。连接生命周期:
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 监听)、以及各类业务请求(发消息、拉群成员等)。
已逐一验证的资产:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
总结:整条链路”扫码登录 → 握手 → 加密传输 → 业务帧 → 会话密钥 → 消息编解码 → 消息监听”已完整还原,密码学层做到 100% 脱离 App 离线复现;仅剩握手验身层的
client finished帧封装与 128 字节验身公钥的确切字节位置待补,不影响收发数据的主链路。
长期征稿
稿费 1000-2000 元 / 篇,长期有效 。征集爬虫逆向、数据行业、爬虫副业、合规避坑类原创首发文章。详情见:

我的公众号:猿人学 Python 上会分享更多心得体会,敬请关注。
***版权申明:若没有特殊说明,文章皆是猿人学 yuanrenxue.con 原创,没有猿人学授权,请勿以任何形式转载。***

说点什么吧...