开源的微信Mars案例MicroChat参考学习小结[第一篇]
昨天无意看到群里讨论一个名为 MicroChat 基于Mars的微信Android通讯协议客户端代码!!
Github地址:https://github.com/InfiniteTsukuyomi/MicroChat/
声明:
本文仅提供Mars学习交流参考,请勿用于非法用途,否则后果自负!!!
本人才疏学浅,文章参考了部分资料,欢迎各位指正,共同交流学习!
感谢原作者:原作2位大神的神作
编译准备:
开发环境:Visual Studio 2015 及以上版本,部分测试VS13编译通过
抓包工具 Wireshark
分析工具 tcpdump
依赖:Mars / Duilib / SQLite3
主要项目 MicroChatSDK.dll
UI层 MicroChat (DUILIB)
使用前的准备配置
/MicroChatSDK/Business/define.h
//测试请手动修改登录设备信息 //登录设备硬件信息 #define DEVICE_INFO_GUID "A31d2152a33d83e7" //GUID #define DEVICE_INFO_CLIENT_SEQID "A31cc712ad2d83e6_1512965043210" //GUID_LOCATION地址 #define DEVICE_INFO_CLIENT_SEQID_SIGN "e89b238e77cf988ebd09eb65f5378e99" //MD5 #define DEVICE_INFO_IMEI "865167123366678" //手机IMEI #define DEVICE_INFO_ANDROID_ID "eabe1f220561a49f" //设备ID #define DEVICE_INFO_ANDROID_VER "android-26" //安卓版本 #define DEVICE_INFO_MANUFACTURER CStringA2Utf8("iPhone") //设备名称 随便填 #define DEVICE_INFO_MODELNAME CStringA2Utf8("X") //型号名称 随便填 #define DEVICE_INFO_MOBILE_WIFI_MAC_ADDRESS "01:67:33:56:78:11" //WIFI MAC地址 #define DEVICE_INFO_AP_BSSID "41:25:99:22:3f:14" //手机信号基站 MAC地址 #define DEVICE_INFO_LANGUAGE "zh_CN" //语言 //下面2个是设备 com.tencent.mm 包信息 及 设备信息(使用上面宏) #define DEVICE_INFO_SOFTINFO "<softtype><lctmoc>0</lctmoc><level>1</level><k1>ARMv7 processor rev 1 (v7l) </k1><k2></k2><k3>5.1.1</k3><k4>%s</k4><k5>460007337766541</k5><k6>89860012221746527381</k6><k7>%s</k7><k8>unknown</k8><k9>%s</k9><k10>2</k10><k11>placeholder</k11><k12>0001</k12><k13>0000000000000001</k13><k14>%s</k14><k15></k15><k16>neon vfp swp half thumb fastmult edsp vfpv3 idiva idivt</k16><k18>%s</k18><k21>\"wireless\"</k21><k22></k22><k24>%s</k24><k26>0</k26><k30>\"wireless\"</k30><k33>com.tencent.mm</k33><k34>Android-x86/android_x86/x86:5.1.1/LMY48Z/denglibo08021647:userdebug/test-keys</k34><k35>vivo v3</k35><k36>unknown</k36><k37>%s</k37><k38>x86</k38><k39>android_x86</k39><k40>%s</k40><k41>1</k41><k42>%s</k42><k43>null</k43><k44>0</k44><k45></k45><k46></k46><k47>wifi</k47><k48>%s</k48><k49>/data/data/com.tencent.mm/</k49><k52>0</k52><k53>0</k53><k57>1080</k57><k58></k58><k59>0</k59></softtype>" #define DEVICE_INFO_DEVICEINFO "<deviceinfo><MANUFACTURER name=\"%s\"><MODEL name=\%s\"><VERSION_RELEASE name=\"5.1.1\"><VERSION_INCREMENTAL name=\"eng.denglibo.20171224.164708\"><DISPLAY name=\"android_x86-userdebug 5.1.1 LMY48Z eng.denglibo.20171224.164708 test-keys\"></DISPLAY></VERSION_INCREMENTAL></VERSION_RELEASE></MODEL></MANUFACTURER></deviceinfo>"
LOGIN_RSA_VER //秘钥版本
LOGIN_RSA_VER158_KEY_E //秘钥加密
LOGIN_RSA_VER158_KEY_N //混淆后??
微信功能请求
#define CGI_NEWSYNC "/cgi-bin/micromsg-bin/newsync" //同步服务端最新消息 #define CGI_MANUALAUTH "/cgi-bin/micromsg-bin/manualauth" //登录 #define CGI_NEWSENDMSG "/cgi-bin/micromsg-bin/newsendmsg" //发送文字消息 #define CGI_NEWINIT "/cgi-bin/micromsg-bin/newinit" //首次登录,初始化数据库 #define CGI_GETPROFILE "/cgi-bin/micromsg-bin/getprofile" //获取个人信息 #define CGI_SEARCHCONTACT "/cgi-bin/micromsg-bin/searchcontact" //搜索新朋友 #define CGI_GETCONTACT "/cgi-bin/micromsg-bin/getcontact" //查找新朋友 #define CGI_VERIFYUSER "/cgi-bin/micromsg-bin/verifyuser" //添加好友 #define CGI_BIND "/cgi-bin/micromsg-bin/bindopmobileforreg" //首次登录短信授权
比如可以添加
findnearby , getmoment , 等~
/MicroChatSDK/Business/AuthInfo.h
#pragma once #include <string> #include "db/db.h" class CAuthInfo { public: CAuthInfo() { InitializeCriticalSection(&m_cs_syncKey); } string m_UserName; //昵称 string m_WxId; //wxid 或 老微信号 DWORD m_uin = 0; //uin 唯一标识 string m_Alias; //微信号 string m_Session; //SessionKey DWORD m_ClientVersion; //客户端版本 string m_guid_15; //guid 15位 string m_guid; //guid string m_androidVer; //安卓版本 string m_launguage; //lang string m_cookie; //置入浏览器的Cookie string GetSyncKey(); void SetSyncKey(string strSyncKey); static CAuthInfo *GetInstance(); //获取短信验证码凭据 string m_mobilecode_authticket; //接受短信号码(当前默认使用登录账号) string m_mobileNum; private: static CAuthInfo * m_Instance; CRITICAL_SECTION m_cs_syncKey; }; #define pAuthInfo (CAuthInfo::GetInstance())
编译常见错误:
BOOST错误 重新引用 mars/boost
MARS 的引用错误,重新引用 mars mars/comm mars/boost 等文件夹
DUILIB编译错误 参考 github.com/duilib
SQLITE3编译 需要修改一次 编译为 DLL & LIB
LIB缺失 手动引用..
VS13及以下版本会有部分语法错误!!请使用VS15及以上版本!!!
一.微信协议概览
OK,我们言归正传。先看下对微信通讯的研究
远程端口有: 80 443 8080 5222 5223 5228 这几个
特定域名:
support.weixin.qq.com 80/8080
short.weixin.qq.com 443/8080
long.weixin.qq.com 80/443
wx.qlogo.cn 80
等
微信网络行为
- 程序启动后,优先尝试DNS解析特定域名(support.weixin.qq.com,short.weixin.qq.com,long.weixin.qq.com,wx.qlogo.cn);
- 如果DNS查询不可用,程序转为使用hardcode的ip链接服务;
- 如果dns可用,返回的ip为根据ISP智能解析的结果,程序使用返回的ip链接服务;
- 程序仅在注册阶段使用https链接,内容不详;
- 程序使用tcp 80/8080链接服务器,其中80为http协议,8080为未知协议;
- 80/8080两个端口同时或任何单独一个,均可提供服务;
- 80端口为短链接,8080为长链接, 程序会优先使用8080端口;
- 没有使用udp传输数据;
- 当1-2次发送失败时,客户端会弹出提示“当前网络状况不好,是否提交反馈数据”,确认后客户端试图通过web提交反馈数据;
dns查询 dns.weixin.qq.com 返回一组IP地址long.weixin.qq.com 返回一组IP地址,本次通信中,微信使用了最后一个IP作为TCP长连接的连接地址。 http://dns.weixin.qq.com/cgi-bin/micromsg-bin/newgetdns?uin=0&clientversion=620888113&scene=0&net=1 用于请求服务器获得最优IP路径。服务器通过结算返回一个xml定义了域名:IP对应列表。 仔细阅读,可看到微信已经开始了国际化的步伐:香港、加拿大、韩国等。 具体文本,请参考:https://gist.github.com/yongboy/9341884 获取到long.weixin.qq.com最优IP,然后建立到101.227.131.105的TCP长连接 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/getprofile HTTP/1.1 (application/octet-stream) 返回一个名为“micromsgresp.dat”的附件,估计是未阅读的离线消息 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/whatsnews HTTP/1.1 (application/octet-stream) 大概是资讯、订阅更新等 GET http://wx.qlogo.cn/mmhead/Q3auHgzwzM7NR4TYFcoNjbxZpfO9aiaE7RU5lXGUw13SMicL6iacWIf2A/96 图片等一些静态资源都会被分配到wx.qlogo.cn域名下面 下载缓存 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/downloadpackage HTTP/1.1 (application/octet-stream) 输出为micromsgresp.dat文件 GET http://support.weixin.qq.com/cgi-bin/mmsupport-bin/reportdevice?channel=34&deviceid=A952001f7a840c2a&clientversion=620888113&platform=0&lang=zh_CN&installtype=0 HTTP/1.1 返回chunked分块数据 POST http://short.weixin.qq.com/cgi-bin/micromsg-bin/reportstrategy HTTP/1.1 (application/octet-stream) 1. 心跳频率约为5分钟 上次使用Wireshark分析有误(得出18分钟结论),再次重新分析,心跳频率在5分钟左右。 2. 登陆之后,会建立一个长连接,端口号为8080 简单目测为HTTP,初始以为是双通道HTTP,难道是自定义的用于双通道通信的HTTP协议吗,网络上可见资料都是模棱两可、语焉不详。 具体查看长连接初始数据通信,没有发现任何包含"HTTP"字样的数据,以为是微信自定义的TCP/HTTP通信格式。据分析,用于可能用于获取数据、心跳交换消息等用途吧。这个后面会详谈微信是如何做到的。 2.0 初始消息传输 个人资料、离线未阅读消息部分等通过 POST HTTP短连接单独获取。 2.1 二进制简单分析 抽取微信某次HTTP协议方式通信数据,16进制表示,每两个靠近的数字为一个byte字节:
aaa
二.微信协议分析
微信协议可能如下: 一个消息包 = 消息头 + 消息体 消息头固定16字节长度,消息包长度定义在消息头前4个字节中。 单纯摘取第0000行为例,共16个字节的头部: 00 00 00 10 00 10 00 01 00 00 00 06 00 00 00 0f 16进制表示,每两个紧挨着数字代表一个byte字节。 微信消息包格式: 1. 前4字节表示数据包长度,可变 值为16时,意味着一个仅仅包含头部的完整的数据包(可能表示着预先定义好的业务意义),后面可能还有会别的消息包 2. 2个字节表示头部长度,固定值,0x10 = 16 3. 2个字节表示谢意版本,固定值,0x01 = 1 4. 4个字节操作说明数字,可变 5. 序列号,可变 6. 头部后面紧跟着消息体,非明文,加密形式 7. 一个消息包,最小16 byte字节 通过上图(以及其它数据多次采样)分析: 0000 - 0040为单独的数据包 0050行为下一个数据包的头部,前四个字节值为0xca = 202,表示包含了从0050-0110共202个字节数据 一次数据发送,可能包含若干子数据包 换行符\n,16进制表示为0x0a,在00f0行,包含了两个换行符号 一个数据体换行符号用于更细粒度的业务数据分割 是否蒙对,需要问问做微信协议的同学 所有被标记为HTTP协议通信所发送数据都包含换行符号 2.2 动手试试猜想,模拟微信TCP长连接 开始很不解为什么会出现如此怪异的HTTP双通道长连接请求,难道基于TCP通信,然后做了一些手脚?很常规的TCP长连接,传输数据时(不是所有数据传输),被wireshark误认为HTTP长连接。这个需要做一个实验证实一下自己想法,设想如下: 写一个Ping-Pong客户端、服务器端程序,然后使用Wireshark看一下结果,是否符合判断。
服务端:https://gist.githubusercontent.com/yongboy/9341037/raw/pong_server.c
/** * nieyong@youku.com * how to compile it: * gcc pong_server.c -o pong_server /usr/local/lib/libev.a -lm */ #include <arpa/inet.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <fcntl.h> #include <errno.h> #include <err.h> #include <unistd.h> #include "../include/ev.h" static int server_port = 8080; struct ev_loop *loop; typedef struct { int fd; ev_io ev_read; } client_t; ev_io ev_accept; static void free_res(struct ev_loop *loop, ev_io *ws); int setnonblock(int fd) { int flags = fcntl(fd, F_GETFL); if (flags < 0) return flags; flags |= O_NONBLOCK; if (fcntl(fd, F_SETFL, flags) < 0) return -1; return 0; } static void read_cb(struct ev_loop *loop, ev_io *w, int revents) { client_t *client = w->data; int r = 0; char rbuff[1024]; if (revents & EV_READ) { r = read(client->fd, &rbuff, 1024); } if (EV_ERROR & revents) { fprintf(stderr, "error event in read\n"); free_res(loop, w); return ; } if (r < 0) { fprintf(stderr, "read error\n"); ev_io_stop(EV_A_ w); free_res(loop, w); return; } if (r == 0) { fprintf(stderr, "client disconnected.\n"); ev_io_stop(EV_A_ w); free_res(loop, w); return; } send(client->fd, rbuff, r, 0); } static void accept_cb(struct ev_loop *loop, ev_io *w, int revents) { struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int client_fd = accept(w->fd, (struct sockaddr *) &client_addr, &client_len); if (client_fd == -1) { fprintf(stderr, "the client_fd is NULL !\n"); return; } client_t *client = malloc(sizeof(client_t)); client->fd = client_fd; if (setnonblock(client->fd) < 0) err(1, "failed to set client socket to non-blocking"); client->ev_read.data = client; ev_io_init(&client->ev_read, read_cb, client->fd, EV_READ); ev_io_start(loop, &client->ev_read); } int main(int argc, char const *argv[]) { int ch; while ((ch = getopt(argc, argv, "p:")) != -1) { switch (ch) { case 'p': server_port = atoi(optarg); break; } } loop = ev_default_loop(0); struct sockaddr_in listen_addr; int reuseaddr_on = 1; int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) err(1, "listen failed"); if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuseaddr_on, sizeof(reuseaddr_on)) == -1) err(1, "setsockopt failed"); memset(&listen_addr, 0, sizeof(listen_addr)); listen_addr.sin_family = AF_INET; listen_addr.sin_addr.s_addr = INADDR_ANY; listen_addr.sin_port = htons(server_port); if (bind(listen_fd, (struct sockaddr *) &listen_addr, sizeof(listen_addr)) < 0) err(1, "bind failed"); if (listen(listen_fd, 5) < 0) err(1, "listen failed"); if (setnonblock(listen_fd) < 0) err(1, "failed to set server socket to non-blocking"); ev_io_init(&ev_accept, accept_cb, listen_fd, EV_READ); ev_io_start(loop, &ev_accept); ev_loop(loop, 0); return 0; } static void free_res(struct ev_loop *loop, ev_io *w) { client_t *client = w->data; if (client == NULL) { fprintf(stderr, "the client is NULL !!!!!!"); return; } ev_io_stop(loop, &client->ev_read); close(client->fd); free(client); }
客户端代码 : https://gist.githubusercontent.com/yongboy/9319660/raw/PingClient.java
/** * Ping Client * @author nieyong */ package com.learn; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.buffer.PooledByteBufAllocator; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import java.util.concurrent.TimeUnit; class PingClientHandler extends ChannelInboundHandlerAdapter { private final ByteBuf firstMessage; public PingClientHandler() { firstMessage = PooledByteBufAllocator.DEFAULT.buffer(22); // weixin 16 byte's header firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(16); firstMessage.writeByte(0); firstMessage.writeByte(16); firstMessage.writeByte(0); firstMessage.writeByte(1); firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(6); firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(0); firstMessage.writeByte(1); // just for /n firstMessage.writeByte('\n'); // 1 byte // footer 16 byte String welcome = "hello"; // 5 byte firstMessage.writeBytes(welcome.getBytes()); } @Override public void channelActive(ChannelHandlerContext ctx) { ctx.writeAndFlush(firstMessage); } @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception { ctx.executor().schedule(new Runnable() { @Override public void run() { ctx.channel().writeAndFlush(msg); } }, 1, TimeUnit.SECONDS); } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { ctx.flush(); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { System.err.println("Unexpected exception from downstream :" + cause.getMessage()); ctx.close(); } } public class PingClient { private final String host; private final int port; public PingClient(String host, int port) { this.host = host; this.port = port; } public void run() throws Exception { EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(group).channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new PingClientHandler()); } }); ChannelFuture f = b.connect(host, port).sync(); f.channel().closeFuture().sync(); } finally { // Shut down the event loop to terminate all threads. group.shutdownGracefully(); } } public static void main(String[] args) throws Exception { String host = "127.0.0.1"; int port = 8080; if (args.length == 3) { host = args[0]; port = Integer.parseInt(args[1]); } new PingClient(host, port).run(); } }
结论是什么呢?
若使用原始TCP进行双向通信,则需要满足以下条件,可以被类似于Wireshark协议拦截器误认为是HTTP长连接:
- 使用80/8080端口(81/3128/8000经测试无效) 也许8080一般被作为WEB代理服务端口,微信才会享用这个红利吧。
- 输出的内容中,一定要包含换行字符"\n"
因此,可以定性为微信使用了基于8080端口TCP长连接,一旦数据包中含有换行"\n"符号,就会被Wireshark误认为HTTP协议。可能微信是无心为之吧。
3. 新消息获取方式
- TCP长连接接收到服务器通知有新消息需要获取
- APP发起一个HTTP POST请求获取新状态消息,会带上当前SyncKey 地址为:http://short.weixin.qq.com/cgi-bin/micromsg-bin/reportstrategy HTTP/1.1,看不到明文
- APP获取到新的消息,会再次发起一次HTTP POST请求,告诉服务器已确认收到,同时获取最新SyncKey 地址为:http://short.weixin.qq.com/cgi-bin/micromsg-bin/kvreport,看不到明文
- 接受一个消息,TCP长连接至少交互两次,客户端发起两次HTTP POST请求 具体每次交互内容是什么,有些模糊
- 服务器需要支持:状态消息获取标记,状态消息确认收取标记。只有被确认收到,此状态消息才算是被正确消费掉
- 多个不同设备同一账号同时使用微信,同一个状态消息会会被同时分发到多个设备上
此时消息请求截图如下:
4. 发送消息方式
发送消息走已经建立的TCP长连接通道,发送消息到服务器,然后接受确认信息等,产生一次交互。
小伙伴接收到信息阅读也都会收到服务器端通知,产生一次交互等。
可以确定,微信发送消息走TCP长连接方式,因为不对自身状态数据产生影响,应该不交换SyncKey。
- 在低速网络下,大概会看到消息发送中的提示,属于消息重发机制
- 网络不好有时客户端会出现发送失败的红色感叹号
- 已发送到服务器但未收到确认的消息,客户端显示红色感叹号,再次重发,服务器作为重复消息处理,反馈确认
- 上传图片,会根据图片大小,分割成若干部分(大概1.5K被划分为一部分),同一时间点,客户端会发起若干次POST请求,各自上传成功之后,服务器大概会合并成一个完整图片,返回一个缩略图,显示在APP聊天窗口内。APP作为常规的文字消息发送到服务器端
- 上传音频,则单独走TCP通道,一个两秒的录制音频,客户端录制完毕,分为两块传输,一块最大1.5K左右,服务端响应一条数据通知确认收到。共三次数据传输。音频和纯文字信息一致,都是走TCP长连接,客户端发送,服务器端确认。
四.微信协议小结
- 发布的消息对应一个ID(只要单个方向唯一即可,服务器端可能会根ID判断重复接收),消息重传机制确保有限次的重试,重试失败给予用户提示,发送成功会反馈确认,客户端只有收到确认信息才知道发送成功。发送消息可能不会产生新SyncKey。
- 基于版本号(SynKey)的状态消息同步机制,增量、有序传输需求水到渠成。长连接通知/短连接获取、确认等,交互方式简单,确保了消息可靠谱、准确无误到达。
- 客户端/服务器端都会存储消息ID处理记录,避免被重复消费客户端获取最新消息,但未确认,服务器端不会认为该消息被消费掉。下次客户端会重新获取,会查询当前消息是否被处理过。根据一些现象猜测。
- 总体上看,微信协议跨平台(TCP或HTPP都可呈现,处理方式可统一),通过“握手”同步,很可靠,无论哪一个平台都可以支持的很好
- 微信协议最小成本为16字节,大部分时间若干个消息包和在一起,批量传输。微信协议说不上最简洁,也不是最节省流量,但是非常成功的。
-
若服务器检测到一些不确定因素,可能会导致微启用安全套接层SSL协议进行常规的TCP长连接传输。短连接都没有发生变化
以上,根据有限资料和数据拦截观察总结得出,啰啰嗦嗦,勉强凑成一篇,会存在一些不正确之处,欢迎给予纠正。在多次
五.附录
Microsoft Exchange Active Sync协议,简称EAS,分为folderrsync(同步文件夹目录,即邮箱内有哪几个文件夹)和sync(每个文件夹内有哪些文档)两部分。
某网友总结的协议一次回话大致示范:
Client: synckey=0 //第一次key为0
Server: newsynckey=1235434 //第一次返回新key
Client: synckey=1235434 //使用新key查询
Server: newsynckey=1647645,data=*****//第一次查询,得到新key和数据
Client: synckey=1647645
Server: newsynckey=5637535,data=null //第二次查询,无新消息
Client: synckey=5637535
Server: newsynckey=8654542, data=****//第三次查询,增量同步
- 上页中的相邻请求都是隔固定时间的,如两分钟
- 客户端每次使用旧key标记自己的状态,服务端每次将新key和增量数据一起返回。
- key是递增的,但不要求连续
- 请求的某个参数决定服务器是否立即返回
本人才疏学浅,参考了部分网上的资料,欢迎各位补充共同交流!