这标题多少有点儿不恰当——本文重点其实是介绍微信机器人的一个传输通道,机器人部分不会涉及太多。不过话说回来,哪怕只能机械应答,其实也是可以称为机器人的。
本文打算从动机、基本原理、关键技术、具体实现以及应用示例五方面来展开介绍。
代码已经开源:WeChatFerry,动手达人们,搞起来了(支持 Python 哦!)。
微信安装包也建了个仓库,可以去下载对应版本的微信安装包:WeChatSetup。
ℹ️ WeChatFerry 是基于 PC 微信的一个工具,如果你没有 Windows 电脑,可以通过虚拟机来玩儿。
最初的时候,一位大佬有个需求,希望通过微信群发通知。我当时找到了 ichat,一个基于微信 Web 版的框架。可是没用几天,我的微信网页版就被封了。
可能是因为微信 Web 协议被破解得差不多了,后来基本微信 Web 版都上不去了。没办法了,只好再找轮子。
借助伟大的互联网,我终于又找到了一个基于 PC 微信的轮子。但这轮子支持的版本有点儿老,等我把项目调试好,发布到服务器上的时候出故障了——服务器是个新系统,旧版本的微信不能用。
这时候,我有两个选择:一个是把开发环境打个镜像,装到服务器上,继续使用旧版本的微信;一个是造轮子,自己适配最版本的微信。
出于对技术的热爱,我选择了造轮子。这个轮子现在已经实现的功能包括:
已经支持的客户端包括:
本质上,就是写了个工具,“劫持”了微信:
更形象一点,我们派一个间谍(Spy.DLL
)打入微信内部,通过电报(RPC
)和外部特工(C++ 应用
、 Java 应用
、 Python 应用
)进行消息交换:
Spy.DLL
把消息通过 RPC
传给 C++ 应用
或者 Python 应用
;C++ 应用
或者 Python 应用
需要发送消息时,通过 RPC
传递给 Spy.DLL
“假传圣旨”发送出去。到现在为止,还有一个问题:间谍是怎么混进去的?这就需要借助注入技术。下面介绍一下本项目涉及到的几个关键技术点。
根据前面的介绍:
Spy.DLL
负责拦截、伪装,这就需要拦截技术(Hook);RPC
负责传送消息,涉及到跨进程间通信,本项目使用的是远程过程调用(Remote Procedure Call);SDK.DLL
负责把 Spy.DLL
(间谍)打入微信内部,涉及到注入技术。C++ 应用
、 Java 应用
、 Python 应用
等客户端用于实现应用功能。这也是我乐于其中的原因,因为项目虽小,但涉及到的技术点还挺多,非常有趣。
首先介绍一下注入(Inject)技术。
注入技术通常都跟恶意软件有关,一般是为了在目标进程中执行自定义代码。注入技术有很多,本项目选取了最经典的一种:将 Spy.DLL
的路径写入微信进程的虚拟地址空间,然后通过在微信进程中创建一个远程线程来加载 Spy.DLL
:
参考实现如下:
HANDLE hThread;
SIZE_T cszDLL = (wcslen(dllPath) + 1) * sizeof(WCHAR);
// 1. 打开目标进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (hProcess == NULL) {
MessageBox(NULL, L"打开进程失败", L"InjectDll", 0);
return NULL;
}
// 2. 在目标进程的内存里开辟空间
LPVOID pRemoteAddress = VirtualAllocEx(hProcess, NULL, cszDLL, MEM_COMMIT, PAGE_READWRITE);
if (pRemoteAddress == NULL) {
MessageBox(NULL, L"DLL 路径写入失败", L"InjectDll", 0);
return NULL;
}
// 3. 把 dll 的路径写入到目标进程的内存空间中
WriteProcessMemory(hProcess, pRemoteAddress, dllPath, cszDLL, NULL);
// 3. 创建一个远程线程,让目标进程调用 LoadLibrary
hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibrary, pRemoteAddress, 0, NULL);
if (hThread == NULL) {
MessageBox(NULL, L"LoadLibrary 调用失败", L"InjectDll", 0);
return NULL;
}
WaitForSingleObject(hThread, -1);
GetExitCodeThread(hThread, (LPDWORD)injectedBase);
CloseHandle(hThread);
VirtualFreeEx(hProcess, pRemoteAddress, 0, MEM_RELEASE);
CloseHandle(hProcess);
通过注入技术,成功将 Spy.DLL
(间谍)打入了微信内部,下一步要做的事情便是让 Spy.DLL
(间谍)能“劫持”微信消息和“假传圣旨”,这需要使用拦截、伪装技术。
拦截技术通常被称为 Hook
。
为了介绍拦截技术,需要先说一说程序从编码到运行的流程。以 C/C++ 为例,程序从产生到运行大体需要经历:
在编译阶段,编译器便把代码里的指令安放到代码段。当程序被加载到虚拟地址空间的时候,代码段便被映射过去。于是,我们程序里的函数,便可以用一个地址(是不是想起了指针?)代替。
当微信接收到一条新消息,需要展示给用户的时候,可以想象,肯定会调用某个函数,把消息展示出来。如果我们把这个函数换成咱们的函数,就可以拦截微信的消息了。前面提到,在程序运行的时候,所谓函数不过是个地址指向,所以我们只要把这个地址指向咱们自己的函数,便实现了拦截。
下面举个例子:
# 经研究发现,当微信收到消息的时候,会调用下面的函数
# 地址 机器码 反汇编
0F7F0F4C E8 FF535400 call WeChatWi.0FD36350
我们只要把 0F7F0F4C
里的 call WeChatWi.0FD36350
,替换成 call 咱们自己的函数
,便可以对消息进行拦截了。同时,为了不影响原有的功能,我们还需要在 咱们自己的函数
的最后,调用 WeChatWi.0FD36350
。
我们把 0F7F0F4C
叫做 Hook
地址,把 WeChatWi.0FD36350
叫做 Call
地址。这里 0F7F0F4C
和 0FD36350
都是“相对”地址——相对 WeChatWin.dll
的地址;而 WeChatWin.dll
的地址称为 基址
(Base
)。
本例中,WeChatWin.dll
的基址为:0F2A0000
,所以:
Hook = 0x0F7F0F4C - 0x0F2A0000 = 0x550F4C
Call = 0x0FD36350 - 0x0F2A0000 = 0xA96350
假设我们写了一个函数 RecieveMsgHook
来处理拦截的消息,下面的代码可以实现消息拦截:
// 把 Hook 和 Call 的地址算出来
DWORD hookAddress = g_WeChatWinDllAddr + g_WxCalls.recvMsg.hook;
recvMsgCallAddr = g_WeChatWinDllAddr + g_WxCalls.recvMsg.call;
recvMsgJumpBackAddr = hookAddress + 5;
// 组装机器码
BYTE jmpCode[5] = { 0 };
jmpCode[0] = 0xE9; // 原来函数调用,机器码 E8,现在要改成跳转 E9
// 把原来的 WeChatWi.0FD36350 替换成 RecieveMsgHook 的地址
*(DWORD *)&jmpCode[1] = (DWORD)RecieveMsgHook - hookAddress - 5;
// 0F7F0F4C E8 FF535400 call WeChatWi.0FD36350
WriteProcessMemory(GetCurrentProcess(), (LPVOID)hookAddress, jmpCode, 5, 0);
当我们需要在微信接上发送一条新消息的时候,可以想象,微信肯定会调用某个函数,把消息发送出去。如果我们找到这个函数,组装好发送内容,调用它,就可以发送微信的消息了。
下面举个例子:
0F44FBF3 8D46 38 lea eax,dword ptr ds:[esi+0x38]
0F44FBF6 6A 01 push 0x1
0F44FBF8 50 push eax ; At members
0F44FBF9 57 push edi ; Message
0F44FBFA 8D55 90 lea edx,dword ptr ss:[ebp-0x70] ; Receiver wxid
0F44FBFD 8D8D 50FCFFFF lea ecx,dword ptr ss:[ebp-0x3B0] ; Buffer
# 经研究发现,当微信发送消息的时候,使用下面的函数
0F44FC03 E8 28213700 call WeChatWi.0F7C1D30 ; Send Msg
0F44FC08 83C4 0C add esp,0xC
0F44FC0B C645 FC 05 mov byte ptr ss:[ebp-0x4],0x5
0F44FC0F 8B85 70FCFFFF mov eax,dword ptr ss:[ebp-0x390]
0F44FC15 0B85 74FCFFFF or eax,dword ptr ss:[ebp-0x38C]
0F44FC1B 75 10 jnz short WeChatWi.0F44FC2D
于是,当我们需要发送消息的时候,只要调用 0x521D30
(0x0F7C1D30 - 0x0F2A0000)即可。
前面我们成功打入微信内部,并且也可以拦截消息并“假传圣旨”,那么,我们怎么把消息传出去或者传进来呢?
微信和我们的应用,在不同的进程。如果我们的应用需要和微信通信,则涉及到进程间通信(Inter Process Communication)。
Windows 支持的 IPC 方式包括:
RPC 指远程过程调用(Remote Procedure Call)。这里的远程指的是不在同一个进程,可以是一台电脑上的不同进程;也可以是不个电脑上的不同进程。使用 RPC,可以创建高性能紧密耦合的分布式应用程序。
本项目选择了 RPC,结果惹了一身麻烦。但是通过 RPC,进程间通信就变得很简单。RPC 工具使用户看起来就像客户端直接调用位于远程服务器程序中的过程一样。 客户端和服务器各自有自己的地址空间;也就是说,每个资源都有自己的内存资源分配给过程使用的数据。 下图说明了 RPC 体系结构:
Yet Another Demo for Windows RPC 是对 Windows 下 RPC 使用的一些总结。项目现在已经转到了gRPC,可以参考 A gRPC Demo。
最初的版本,使用了 Windows 原生的 RPC 导致可以拦截消息也可以“假传圣旨”——只限于自己人(C++)。那怎么能让 Python 也可以拦截消息、“假传圣旨”呢?这就涉及到了混合编程,具体而言是 Python 调用 C++ 的 SDK。
下面是微软文档上介绍的实现方式:
Approach | Vintage | Representative users |
---|---|---|
C/C++ extension modules for CPython | 1991 | Standard Library |
PyBind11 (recommended for C++) | 2015 | |
Cython (recommended for C) | 2007 | gevent, kivy |
HPy | 2019 | |
mypyc | 2017 | |
ctypes | 2003 | oscrypto |
cffi | 2013 | cryptography, pypy |
SWIG | 1996 | crfsuite |
Boost.Python | 2002 | |
cppyy | 2017 |
本项目最开始的时候选择了 ctypes,无它,唯简单而。但后来随着功能变得复杂,ctypes 不太好搞,于是便转向了 PyBind11。
现在换成了 gRPC,可以不需要混合了!
前面已经把关键技术介绍完了,具体实现就比较好理解了。
WeChatFerry
├── LICENSE
├── README.MD
├── TEQuant.jpeg
├── WeChatFerry.sln
├── cpp/
├── java/
├── proto/
├── python/
├── sdk/
└── spy/
这部分就是前面举例的拦截消息和“假传圣旨”的实现部分,目前实现了:
accept_new_friend
)exec_sql
)get_contacts
)receive_msg
)send_msg
)完成 spy.DLL
(间谍)的注入和初始化。
RPC 服务、接口、消息的定义文件。
C++ 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。
Python 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。
Java 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。
最初,就是因为有需求而造的轮子,不料轮子造出来需求却没了,现在只好做些无用的东西。欢迎进群体验。
每天 7 点准时在群里发布天气预报:
天气播报机器人
只能回答天气问询:
为了做天气播报机器人,写了个天气爬虫,把天气数据抓回来了。代码也开源了:WeatherScrapy。
这个应用使用了 RASA,自己造了些语料简单训练了一下。
欢迎 Star、PR:WeChatFerry,后续再介绍一个基于 Python 的机器人框架,就可以更愉快地玩耍了。