记一次 NT 架构登录协议的逆向过程

几个月前研究某国民级应用的新 NT 架构,碰到了一套设计得相当用心的登录协议。跟以往见过的"固定 RSA 换 AES key"不同,这套东西把密钥协商、盐值获取、凭证构造全做成了动态的,环环相扣。整个过程踩了不少坑,这里做个记录。

协议的大致流程

登录需要严格按顺序走三步:

  1. SsoKeyExchange — ECDH 密钥协商,建立加密通道
  2. SsoNTLoginGetSaltList — 从服务端拿加密用的盐值
  3. 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 字节。

具体做法:

  1. 全局扫描解密流里的 0x10
  2. 碰到之后按 Varint 规则读后续字节(看每个字节最高位判断是否继续)
  3. 解码出来转成 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 内存监控,一个字节一个字节对出来的:

偏移长度内容
0x002版本标识,固定 0x0004
0x024账号 MD5 的前 4 字节
0x0D / 0x10-一些固定的校验位(0x10, 0x1F, 0x41)
0x128动态盐值
0x1A4当前时间戳,uint32
0x221分隔符 0x01
0x2316密码的原始 MD5
0x3316上面算出来的 TEA_Key(相当于二次哈希)
0x471分隔符 0x01
0x4816设备 GUID
0x58 / 0x5C8尾部常量,两个 uint32 的 1
0x602账号字符串的字节长度
0x62N账号字符串本身

这块 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 字节凭证把设备、时间、账号、密码、服务端盐全绑在一起,改任何一个字节解密就对不上

整体来说是一次很有收获的逆向经历。核心教训是:碰到对抗性强的协议,越早脱离"找现成工具解"的思路越好,直接面对二进制流反而更稳定。

Last modification:July 19, 2026
如果觉得我的文章对你有用,请随意赞赏