一、查资料
网上没找到SDK可以分析,关于微信安卓协议的文章也比较少,比较有用的是<微信交互协议和加密模式研究>,这篇论文里介绍了微信使用RSA2048与AES-CBC-128结合的加密算法以及使用protobuf编码格式传输数据;<微信协议简单调研笔记>帖子里提到微信使用长短链接结合的网络通讯方式以及基于sync key的消息同步机制;<基于TLS1.3的微信安全通信协议mmtls介绍>详细讲解了类似于https中TLS作用的mmtls协议;<微信安卓客户端逆向分析>通过举例拦截聊天记录详细演示了安卓微信客户端分析的过程.
二、准备工作
工具除了安卓反编译常用打包解包软件以及查看java代码的软件,还可以用XSearch方便在大量smali汇编文件中快速搜索字符串,Wireshark用于抓包,Android Studio动态调试smali汇编代码,IDA静态分析so文件,WinHex用于编辑二进制文件.
微信底层使用自家开源的跨平台通讯库mars,该库包含xlog模块,详尽记录了微信几乎所有主要函数调用流程,开启log可以极大地方便动态分析,研究mars库也有助于理解微信与服务器交互流程.
由于mmtls无法中间人攻击,所以抓包无法获取短链接HTTP通讯的明文数据;考虑到兼容性微信允许在不使用mmtls的情况下通讯,所以关闭mmtls有助于协议分析.
由于长链接含有包头不利于分析,考虑到稳定性微信允许在不使用长链接的情况下通讯,所以禁用长链接有利于协议分析.
三、二次打包客户端
微信允许非官方签名的客户端运行,但是会校验签名并上报异常数据.最近因使用非官方客户端封号的风控策略越来越严格,运行二次打包客户端时要做好被封号准备,或者使用XP插件Hook代码,或者patch掉获取软件签名的代码或者禁掉上传异常数据的封包,总之这不是本文的重点.如果反编译工具直接打包失败,可以只重新编译dex文件,然后替换到原来的apk中.
从mars库的Xlog文档中可以知道setConsoleLogOpen接口负责开启控制台log,在反汇编代码中搜索这句代码的调用"->setConsoleLogOpen",找到XLogSetup.smali文件直接修改Lcom/tencent/mars/xlog/Xlog;->setConsoleLogOpen的调用参数即开启log.搜索getLogLevel最终可以在Lcom/tencent/mm/sdk/platformtools/下面找到输出Log等级的定义,从6改为0即可打印所有等级的log.logcat里可以通过"MicroMsg""mars"两个tag来分别过滤java层和native层的log.
运行开启Log的客户端,过滤tag为"mmtls"的日志,可以找到"Java_com_tencent_mars_mm_MMLogic_setMmtlsCtrlInfo"函数打印的log:
1
|
I/mars::mmext(12496): [com_tencent_mars_mm_MMLogic_Java2C.cc, Java_com_tencent_mars_mm_MMLogic_setMmtlsCtrlInfo, 299]:j_use_mmtls=1
|
从函数名可以猜出这是java调用jni控制开启mmtls的接口,在反汇编代码中搜索";->setMmtlsCtrlInfo"找到调用的代码,修改参数为0关闭mmtls.
从mars库中可以看出,长链接使用mars/stn/src/longlink.cc文件的LongLink::__RunConnect函数连接服务器,解压apk搜索字符串"longlink.cc"可知mars库编译出的文件为libwechatnetwork.so,在二进制文件中结合IDA patch掉该函数返回值可以禁用长链接;从mars文档也可以看出上层需要调用mars::stn::MakesureLonglinkConnected();启用长链接,在smali代码中搜索调用";->makesureLongLinkConnected"的代码注释掉也可关闭长链接.
四、抓包
使用安卓模拟器或用手机连接电脑开启的wifi,运行二次打包处理的客户端,使用Wireshark工具即可抓到微信短链接的HTTP协议封包了.比如登录包可以抓到使用POST方法向/cgi-bin/micromsg-bin/manualauth 发送了1183字节长度的数据,数据是被加密的:
<微信安卓客户端逆向分析>文章中提到数据是被libMMProtocalJni.so这个库加密的,使用IDA查看该库的jni接口:
可以看到"Java_com_tencent_mm_protocal_MMProtocalJni_pack"很可能是封包加密的接口,在smali反汇编代码中搜索调用该接口的代码"MMProtocalJni;->pack"如下:
根据log tag"RemoteReq"以及"reqToBuf using protobuf ok"可以猜测这里就是protobuf明文加密封包的地方.接下来可以使用AS在smali这句代码上下断点观察参数与返回值,如果配置调试环境有困难,可以在smali代码中插log将参数与返回值打印并保存下来,方便后面分析.其中第二个参数类型是PByteArray,这是自定义的类型,需要打印的字节数组为该对象的value成员变量;使用Log.d在控制台打印时默认有4000字节的长度限制,超过的部分无法输出,插入log时注意将长log拆分多行输出.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
#参数1:logtag,参数2:需要打印的bytes
.method
public
static
logX(Ljava/lang/String;[B)V
.locals 8
.prologue
if
-eqz p1, :cond_1
#如果参数p1是PByteArray,要取其成员变量value
#iget-object v1, p1, Lcom/tencent/mm/pointers/PByteArray;->value:[B
move-object v1, p1
#使用平台自带的将bytes转成string的函数,每个版本混淆函数名略有不同
invoke-
static
{v1}, Lcom/tencent/mm/sdk/platformtools/bh;->bp([B)Ljava/lang/String;
move-result-object v1
invoke-
virtual
{v1}, Ljava/lang/String;->length()I
move-result v2
const
v4, 0x0
#超过4000字节长度的
log
,分行输出
const
v3, 0xfa0
:goto_0
if
-ge v4, v2, :cond_1
move v5, v4
add-
int
/2addr v5, v3
if
-le v5, v2, :cond_0
move v5, v2
:cond_0
invoke-
virtual
{v1, v4, v5}, Ljava/lang/String;->substring(II)Ljava/lang/String;
move-result-object v6
invoke-
static
{p0, v6}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
move v4, v5
goto
:goto_0
:cond_1
return
-
void
.end method
|
准备好抓包环境并开启log,向任意好友发送一条消息,对比Wireshark抓到的发送的二进制数据与log中记录的该函数执行后第二个参数pByteArray.value的值发现一模一样,而该函数的入参可以看到有发送消息的明文数据,说明这就是微信的组包函数,实现 Java_com_tencent_mm_protocal_MMProtocalJni_pack 函数是分析微信协议的核心.
用同样的方法分析可知"Java_com_tencent_mm_protocal_MMProtocalJni_unpack"是解包函数,函数执行后第1个参数pByteArray中保存解密后的protobuf数据.
登录包由于涉及密钥协商,使用"Java_com_tencent_mm_protocal_MMProtocalJni_packHybrid"函数组包.
把抓到的完整HTTP数据保存下来,自己写代码模拟向相同地址端口POST相同登录包数据,发现每次都返回相同的数据,说明登录包可以被重放攻击.
五、协议分析
Http post的数据如下:
重复登录几次对比封包数据,发现封包数据的前几字节基本是固定的,而且含有大量的连续"00"数据。由于加密后的数据通常无规律,所以一般情况下这些"00"附近的数据都是未加密的数据,而在封包前面出现的未加密数据一般是包含封包重要信息的封包头.
首先查看这些二进制数据对应的ASCII数据,并未发现有意义的字符串;接下来只能靠经验"猜"每个字节代表的意义,然后尝试修改该字节重发封包验证.由于有详尽的log,这里可以直接根据log中的关键数据直接与二进制数据对比分析其意义,这将大大提高分析的成功率.比如,登录包组包log片段如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
D/MicroMsg.Jni( 2889): [common_function.h, CLogScope, 78]:--> Enter protocal_packHybrid
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 170]:cookie length=15
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 172]:cookie attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 179]:rsa length=174
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 181]:rsa attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 188]:aes length=1610
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 190]:aes attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 197]:keye length=6
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 199]:keye attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 206]:keyn length=512
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 208]:keyn attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 215]:pkey length=16
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 217]:pkey attached
D/TLV_LOG ( 2889): [common_function.h, CLogScope, 78]:--> Enter EncodeHybirdEncryptPack
V/TLV_LOG ( 2889): [mmpack.cpp,
bool
EncodeHybirdEncryptPack(Comm::SKBuffer&, Comm::SKBuffer&, unsigned
int
, unsigned
char
*, emMMFunc, unsigned
int
, Comm::SKBu, 178]:writing head, g_clientVer: 637864504, flag:7
D/TLV_LOG ( 2889): [mmpack.cpp,
bool
EncodeHybirdEncryptPack(Comm::SKBuffer&, Comm::SKBuffer&, unsigned
int
, unsigned
char
*, emMMFunc, unsigned
int
, Comm::SKBu, 207]:EncodeHybirdEncryptPack fgflag:1, cver:1001, calgo:2, clen:1180, cdlen:1180, ealog:7
I/TLV_LOG ( 2889): [skbuffer.cpp, EnsureExpandSize, 58]:EnsureExpandSize 34 to 1214 increase 1180
V/TLV_LOG ( 2889): [mmpack.cpp,
bool
EncodeHybirdEncryptPack(Comm::SKBuffer&, Comm::SKBuffer&, unsigned
int
, unsigned
char
*, emMMFunc, unsigned
int
, Comm::SKBu, 219]:packing done, uin=0, func=701, ret=0
D/TLV_LOG ( 2889): [common_function.h, ~CLogScope, 83]:<-- Exit EncodeHybirdEncryptPack
D/MicroMsg.Jni( 2889): [common_function.h, ~CLogScope, 83]:<-- Exit protocal_packHybrid
|
log中"g_clientVer: 637864504"这句是打印客户端版本号 ,637864504二进制为26050A38,可以看到这与二进制封包数据中第3到第6字节一致,结合该版本的发布版本号"6510"更加确定封包头部的这4字节就是代表当前客户端的大、小版本号,这也印证了封包前面几个字节的数据是未加密的猜想.
对于封包第一字节,通常固定是"bf",只有少部分不以"bf"开头(如截图中的登录包),log中也没找到该字节的含义,这种情况就只能反复发包测试验证是否必须以"bf"开头的封包才合法。目前测试下来是否包含该字节不影响通信.
封包头剩余数据无法直接猜出意义,只能静态分析libMMProtocalJni.so中组包函数代码,必要时可以使用IDA下断点调试参数及栈,如果搭建动态调试so环境有困难,可以尝试调试PC版本微信的WeChatWin.dll,最近版本该dll都没有加壳.mars是跨平台的组件,PC版和android版底层通信代码差不多.
从上面的log片段中找到关键词"writing head"定位到组包函数函数:EncodeHybirdEncryptPack
最终组包函数片段:
从组包函数可以看出有些字节的数据是由2个4bits的数据组合起来的,有些数据是转网络字节序后再经过处理的,处理的函数如图:
这是整数压缩算法.
尝试将包头中的数据使用整数压缩算法还原后,"BD 05"对应整数701,对应log中的
1
|
V/TLV_LOG ( 2889): [mmpack.cpp,
bool
EncodeHybirdEncryptPack(Comm::SKBuffer&, Comm::SKBuffer&, unsigned
int
, unsigned
char
*, emMMFunc, unsigned
int
, Comm::SKBu, 219]:packing done, uin=0, func=701, ret=0
|
"8C 09"对应整数1164,对应log中的clen(log是之前抓的,与截图中的封包不对应,实际log中应是clen: 1164 , cdlen: 1164 )
1
|
D/TLV_LOG ( 2889): [mmpack.cpp,
bool
EncodeHybirdEncryptPack(Comm::SKBuffer&, Comm::SKBuffer&, unsigned
int
, unsigned
char
*, emMMFunc, unsigned
int
, Comm::SKBu, 207]:EncodeHybirdEncryptPack fgflag:1, cver:1001, calgo:2, clen:1180, cdlen:1180, ealog:7
|
对比log可以猜出包头大部分字节的含义,第一个字节对应的代码:
前6bits代表包头长度,后2bits表示是否使用压缩算法,在log中搜索"calgo",
可以看出clen大于cdlen时calgo为1,即使用压缩算法; clen等于cdlen时calgo为2,即不使用压缩算法.
从文档中前人已经分析出微信登录使用rsa算法协商秘钥后续使用aes加密通信数据,log中也明确记录了加密算法和密钥,如图:
1
2
3
4
5
6
7
8
9
10
|
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 179]:rsa length=174
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 181]:rsa attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 188]:aes length=1610
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 190]:aes attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 197]:keye length=6
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 199]:keye attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 206]:keyn length=512
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 208]:keyn attached
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 215]:pkey length=16
V/MicroMsg.Jni( 2889): [MMProtocalJniImpl.cpp, protocal_packHybrid, 217]:pkey attached
|
rsa的公钥n,e是内置在客户端里的,分为157和158两个版本:
aes的密钥在log中只能看到一个key,没有iv.在腾讯开源项目mars中的aes_crypt.c文件中的aes_cbc_encrypt加密函数中,iv和key是相同的:
在libMMProtocalJni.so也可以找到对应的代码:
将明文protobuf数据和加密key从log中拷贝出来用aes在线加解密工具验证,加解密结果与log中一致,表明微信使用aes-cbc-128加密算法通讯.
在log中搜索aes加密key的二进制数据,寻找该key第一次出现的位置,附近很可能有密钥计算的相关信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
I/MicroMsg.AutoAuth( 2182): [, , 2182]:summerauth end type: 701, ret:[0,0][null]
D/[LogI]( 2182): summerauth end type: 701, ret:[0,0][null]
D/[LogD]( 2084): summerauth decodeAndRetriveAccInfo type:701
D/[LogI]( 2084): summerauth decodeAndRetriveAccInfo type:701, hashcode:196488030, ret:0, stack[[sdk.platformtools.v:i(262)][s.ap:b(661)][w.s:BQ(186)][network.q$a:onTransact(156)]]
D/[LogI]( 2084): decodeAndRetriveAccInfo authResultFlag:4 UpdateFlag:1
D/[LogD]( 2084): summerauth svr ecdh key len:57, nid:713 sessionKey len:32, sessionKey: 9d d4 f8 c6 65 fe 03 ab 1b f5 03 ca 06 4d eb 0a a9 b0 05 38 3f 0f d4 79 c7 1a 2e 63 72 65 77 1e
D/[LogD]( 2084): summerauth svrPubKey len:57 value: 04 6d 02 6d e2 94 22 48 00 fb c3 ec 1c 63 45 bb 84 c8 97 3f 8e 35 4b ed ce a3 d7 68 71 51 99 35 aa 2b 03 9d 1b c4 e2 f0 24 f5 fc 99 39 0e 8f ff a9 f9 6a 34 10 98 8d bb 11 prikey len:328, values: 30 82 01 44 02 01 01 04 1c 2e b1 e1 70 b0 c0 9f 3a a8 f3 8d 15 1e 50 30 80 b1 ed 93 be 03 e9 50 c9 f8 8a 80 7d a0 81 e2 30 81 df 02 01 01 30 28 06 07 2a 86 48 ce 3d 01 01 02 1d 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 00 00 00 01 30 53 04 1c ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff fe ff ff ff ff ff ff ff ff ff ff ff fe 04 1c b4 05 0a 85 0c 04 b3 ab f5 41 32 56 50 44 b0 b7 d7 bf d8 ba 27 0b 39 43 23 55 ff b4 03 15 00 bd 71 34 47 99 d5 c7 fc dc 45 b5 9f a3 b9 ab 8f 6a 94 8b c5 04 39 04 b7 0e 0c bd 6b b4 bf 7f 32 13 90 b9 4a 03 c1 d3 56 c2 11 22 34 32 80 d6 11 5c 1d 21 bd 37 63 88 b5 f7 23 fb 4c 22 df e6 cd 43 75 a0 5a 07 47 64 44 d5 81 99 85 00 7e 34 02 1d 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff 16 a2 e0 b8 f0 3e 13 dd 29 45 5c 5c 2a 3d 02 01 01 a1 3c 03 3a 00 04 be b6 80 3e 74 d5 02 35 32 1b e4 ec 9e fc 71 ad 96 ff 2f 00 cd 21 e1 45 84 70 da 6e 9d 98 35 ad 2d 57 5a 49 08 70 8c 5f c0 01 f2 95 67 7a 07 a0 c2 fe 16 42 2d e3 d0 7c
W/mars::comm( 2182): [platform_comm.cc, getCurSIMInfo, 303]:getCurSIMInfo error
return
null
D/[LogD]( 2182): receive kv logid:11108, isImportant:
false
,isReportNow:
false
D/[LogD]( 2182): receive group id size:3, isImportant:
false
D/[LogD]( 2182): receive kv logid:11110, isImportant:
false
,isReportNow:
false
D/[LogI]( 2084): summerauth ComputerKeyWithAllStr ret:0, agreedECDHKey len: 16, values: 8c 1c 1a d8 a2 0b c4 41 0f a1 27 74 97 2c c9 b9
D/[LogD]( 2084): summerauth aesDecrypt sessionKey len:32, value: 9d d4 f8 c6 65 fe 03 ab 1b f5 03 ca 06 4d eb 0a a9 b0 05 38 3f 0f d4 79 c7 1a 2e 63 72 65 77 1e, session len:16, value: 55 6b 7a 52 79 24 55 57 79 56 4b 4c 4f 6d 45 6e
D/[LogD]( 2084): summerauth decode session key succ session: 55 6b 7a 52 79 24 55 57 79 56 4b 4c 4f 6d 45 6e
|
最后一行log中的 session就是除登录包外的aes解密key.
根据关键词"summerauth decode session key succ session"定位代码片段:
从java代码中可以看出,最终打印出的session是用MMProtocalJni.aesDecrypt做aes解密获得的,解密key是MMProtocalJni.computerKeyWithAllStr返回的,结合log可以看出 computerKeyWithAllStr的参数有svrPubKey,prikey.从函数返回值agreedECDHKey的命名可以看出这是ECDH密钥交换算法,在log中搜索 svrPubKey和 prikey二进制数据第一次出现的位置, svrPubKey在登陆封包返回的解密数据中, prikey出现在:
1
|
D/[chao-LogD]( 2084): summerecdh nid:713 ret:0, pub len: 57, pri len:328, pub: 04 be b6 80 3e 74 d5 02 35 32 1b e4 ec 9e fc 71 ad 96 ff 2f 00 cd 21 e1 45 84 70 da 6e 9d 98 35 ad 2d 57 5a 49 08 70 8c 5f c0 01 f2 95 67 7a 07 a0 c2 fe 16 42 2d e3 d0 7c, pri: 30 82 01 44 02 01 01 04 1c 2e b1 e1 70 b0 c0 9f 3a a8 f3 8d 15 1e 50 30 80 b1 ed 93 be 03 e9 50 c9 f8 8a 80 7d a0 81 e2 30 81 df 02 01 01 30 28 06 07 2a 86 48 ce 3d 01 01 02 1d 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 00 00 00 01 30 53 04 1c ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff fe ff ff ff ff ff ff ff ff ff ff ff fe 04 1c b4 05 0a 85 0c 04 b3 ab f5 41 32 56 50 44 b0 b7 d7 bf d8 ba 27 0b 39 43 23 55 ff b4 03 15 00 bd 71 34 47 99 d5 c7 fc dc 45 b5 9f a3 b9 ab 8f 6a 94 8b c5 04 39 04 b7 0e 0c bd 6b b4 bf 7f 32 13 90 b9 4a 03 c1 d3 56 c2 11 22 34 32 80 d6 11 5c 1d 21 bd 37 63 88 b5 f7 23 fb 4c 22 df e6 cd 43 75 a0 5a 07 47 64 44 d5 81 99 85 00 7e 34 02 1d 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff 16 a2 e0 b8 f0 3e 13 dd 29 45 5c 5c 2a 3d 02 01 01 a1 3c 03 3a 00 04 be b6 80 3e 74 d5 02 35 32 1b e4 ec 9e fc 71 ad 96 ff 2f 00 cd 21 e1 45 84 70 da 6e 9d 98 35 ad 2d 57 5a 49 08 70 8c 5f c0 01 f2 95 67 7a 07 a0 c2 fe 16 42 2d e3 d0 7c
|
根据log tag "summerecdh"定位代码:
从java代码中可以看出 prikey是 libMMProtocalJni.so 库的jni函数generateECKey返回的.
学习mars库时恰巧发现ECDH相关的这几个jni函数都是开源的:
生成ECC密钥对的函数在ecdh_util.cpp中:
计算共享密钥的函数在ecdh_crypt.c中:
ECC参数nid在上面log中可以看到为713(即secp224r1).
上面得到的session只能用于后续通信做aes加解密时的key,由于登录包是第一个封包,此时还未得到session,因此解密登录包的密钥需要单独计算.
在log中搜索解密登录包的key二进制数据第一次出现的位置,发现在packHybrid的 protobuf 参数中,说明解密登录包的key是发包前本地生成发到服务器的.
整个登录流程客户端共有2个随机参数:解密登录包的key和ecc密钥对,其余参数账号密码、硬件信息等是固定不变的。因此若每次登陆生成相同的解密key和ecc密钥对,则理论上登录请求数据可以完全相同(假设RSA加密后也生成相同的数据),将此登录包的二进制数据保存并记录与之对应的key和ecc prikey,直接发送即可 免去组包 完成登录;若在代码中动态替换掉进程的key和ecc prikey以及guid,则有可能实现在非授权设备上登录账号.
通信协议草图如下:
(未完待续......)
( demo)