记一次 NT 架构登录协议的逆向过程
几个月前研究某国民级应用的新 NT 架构,碰到了一套设计得相当用心的登录协议。跟以往见过的"固定 RSA 换 AES key"不同,这套东西把密钥协商、盐值获取、凭证构造全做成了动态的,环环相扣。整个过程踩了不少坑,这里做个记录。
协议的大致流程
登录需要严格按顺序走三步:
- SsoKeyExchange — ECDH 密钥协商,建立加密通道
- SsoNTLoginGetSaltList — 从服务端拿加密用的盐值
- SsoNTLoginOptimusLogin — 提交凭证,换取 Token
整条链路外面套了一层自定义的 Type 12 结构,用 TEA + 本地缓存的 d2key 加密。剥掉这层之后里面是 Protobuf,但业务数据还要再用协商出来的 ExchangeKey 做一次 AES-GCM 解密。所以不把 ECDH 这步搞定,抓到的包全是乱码,没法往下走。
ECDH 协商:比想象中多一层
第一步 SsoKeyExchange 的底层逻辑,是通过 Hook BoringSSL 的密码学函数看到的。
客户端本地随机生成 prime256v1 的 ECDH 密钥对,然后从代码段里读一个硬编码的服务端公钥(65 字节非压缩格式,04 开头),算出 ShareKey。到这里都是标准操作。
但它发送客户端公钥的时候不是直接发,而是包了两层:
- 先把设备 GUID、UID 之类的东西组成 Protobuf 明文,用 ShareKey 做 AES-GCM 加密,得到
gcmCalc1 - 然后把客户端公钥 + gcmCalc1 + 时间戳拼起来算 SHA-256,再用一个内置的 32 字节静态密钥(
e2733bf4...)对这个哈希值做第二次 AES-GCM,得到gcmCalc2
两层 GCM 一起发上去。服务端验证通过后,会下发新的 ExchangeKey(后续加密的真正密钥)、KeySig 和会话有效期。
第二层 GCM 的作用我理解是把设备特征和公钥绑死——你不知道那个静态密钥就没法伪造公钥,光有 ECDH 还不够。
盐值提取:放弃 Protobuf 解析,直接扫二进制流
拿到加密通道之后要调 GetSaltList 拿 8 字节的盐。这步卡了我挺久。
问题在于服务端返回的 Protobuf 结构经常变,字段 Tag 和嵌套层级都不稳定。用标准的 Pb 库反序列化,隔几天就解析失败,提取不到盐值,后面密码加密就全错。
后来换了个思路:既然 Pb 结构不可靠,那我就不依赖它了,直接在解密后的二进制流里做特征匹配。
观察规律是这样的:8 字节的盐在底层流里通常跟在 0x10(Protobuf 的 Tag 2, WireType 0)后面,以 Varint 方式编码,解码后占 8 到 10 字节。
具体做法:
- 全局扫描解密流里的
0x10 - 碰到之后按 Varint 规则读后续字节(看每个字节最高位判断是否继续)
- 解码出来转成 8 字节 Buffer,过滤掉明显是设备信息 ASCII 的假盐(比如
0x38 0x39 0x31这种)
这个方法跑了很长时间没出过问题,完全不受 Pb 结构变动影响。
128 字节 ClientA1 凭证的构造
拿到盐之后就是最后一步:组装 128 字节的登录凭证 ClientA1。
先说加密密钥怎么来的:
TEA_Key = MD5( password_md5_16bytes + salt_8bytes )就是密码的 MD5 拼上动态盐,再整体取一次 MD5,得到 TEA 加密用的 key。
然后是明文 Buffer 的内存布局,基准长度 98 字节,末尾追加账号字符串。通过 IDA 分析 + Frida 内存监控,一个字节一个字节对出来的:
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0x00 | 2 | 版本标识,固定 0x0004 |
| 0x02 | 4 | 账号 MD5 的前 4 字节 |
| 0x0D / 0x10 | - | 一些固定的校验位(0x10, 0x1F, 0x41) |
| 0x12 | 8 | 动态盐值 |
| 0x1A | 4 | 当前时间戳,uint32 |
| 0x22 | 1 | 分隔符 0x01 |
| 0x23 | 16 | 密码的原始 MD5 |
| 0x33 | 16 | 上面算出来的 TEA_Key(相当于二次哈希) |
| 0x47 | 1 | 分隔符 0x01 |
| 0x48 | 16 | 设备 GUID |
| 0x58 / 0x5C | 8 | 尾部常量,两个 uint32 的 1 |
| 0x60 | 2 | 账号字符串的字节长度 |
| 0x62 | N | 账号字符串本身 |
这块 Buffer 组装完(98 + N 字节),用 TEA_Key 做 TEA 加密。TEA 有 padding 机制,最终输出刚好是 128 字节。
最终提交和风控
把 128 字节的 ClientA1 加上系统标识、App ID、设备特征打包,调 SsoNTLoginOptimusLogin 提交。
- 返回 Code 0 就是成功了,服务端会下发
tgt、d2、d2key这些 Token - 返回
140022008是触发风控,需要过滑块验证码,拿到 Ticket 后回去重新走 GetSaltList 再登录
风控阈值是服务端动态控的,具体触发条件不太确定。
一些感受
这套协议设计上确实有几个点比较有意思:
- ECDH 做到了前向安全,每次会话密钥都不一样,抓到的历史流量没法解密
- Protobuf 结构故意做得不稳定(或者说频繁迭代),逼着你不能依赖结构化解析
- 128 字节凭证把设备、时间、账号、密码、服务端盐全绑在一起,改任何一个字节解密就对不上
整体来说是一次很有收获的逆向经历。核心教训是:碰到对抗性强的协议,越早脱离"找现成工具解"的思路越好,直接面对二进制流反而更稳定。