微信机器人 DIY 从 0 到 1

这标题多少有点儿不恰当——本文重点其实是介绍微信机器人的一个传输通道,机器人部分不会涉及太多。不过话说回来,哪怕只能机械应答,其实也是可以称为机器人的。

本文打算从动机、基本原理、关键技术、具体实现以及应用示例五方面来展开介绍。

代码已经开源:WeChatFerry,动手达人们,搞起来了(支持 Python 哦!)。
微信安装包也建了个仓库,可以去下载对应版本的微信安装包:WeChatSetup。

ℹ️ WeChatFerry 是基于 PC 微信的一个工具,如果你没有 Windows 电脑,可以通过虚拟机来玩儿。

动机

最初的时候,一位大佬有个需求,希望通过微信群发通知。我当时找到了 ichat,一个基于微信 Web 版的框架。可是没用几天,我的微信网页版就被封了。

可能是因为微信 Web 协议被破解得差不多了,后来基本微信 Web 版都上不去了。没办法了,只好再找轮子。

借助伟大的互联网,我终于又找到了一个基于 PC 微信的轮子。但这轮子支持的版本有点儿老,等我把项目调试好,发布到服务器上的时候出故障了——服务器是个新系统,旧版本的微信不能用。

这时候,我有两个选择:一个是把开发环境打个镜像,装到服务器上,继续使用旧版本的微信;一个是造轮子,自己适配最版本的微信。

出于对技术的热爱,我选择了造轮子。这个轮子现在已经实现的功能包括:

  • 获取登录状态
  • 发送文本消息(群聊可 @)
  • 发送图片消息
  • 接收所有类型消息
  • 获取联系人(基于内存,也可以通过 SQL)
  • 通过好友验证
  • 查询数据库,获取库、表
  • 执行 SQL

已经支持的客户端包括:

  • CPP
  • Java
  • Python

基本原理

本质上,就是写了个工具,“劫持”了微信:

  • 当微信收到消息时,抢在微信处理(显示到页面)前,先让工具处理,处理完之后再交还给原来的处理模块;
  • 需要发送消息时,模拟微信发送消息,组装好消息体,调用微信发送消息的模块;
  • 获取联系人,则是遍历一块特定的内存空间;
  • 通过好友验证,则是组装好验证信息,调用微信的验证模块;
  • 数据库相关功能,则是通过获取到数据库句柄,参考 sqlite3 的接口,来执行。
    微信机器人 DIY 从 0 到 1_第1张图片

更形象一点,我们派一个间谍(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++ 为例,程序从产生到运行大体需要经历:

  1. 编码
  2. 预编译、编译、汇编、链接
  3. 创建程序进程,加载程序代码、数据,创建、映射虚拟地址空间
  4. 创建主线程,运行程序

在编译阶段,编译器便把代码里的指令安放到代码段。当程序被加载到虚拟地址空间的时候,代码段便被映射过去。于是,我们程序里的函数,便可以用一个地址(是不是想起了指针?)代替。

当微信接收到一条新消息,需要展示给用户的时候,可以想象,肯定会调用某个函数,把消息展示出来。如果我们把这个函数换成咱们的函数,就可以拦截微信的消息了。前面提到,在程序运行的时候,所谓函数不过是个地址指向,所以我们只要把这个地址指向咱们自己的函数,便实现了拦截。

下面举个例子:

# 经研究发现,当微信收到消息的时候,会调用下面的函数
# 地址       机器码            反汇编
0F7F0F4C    E8 FF535400     call WeChatWi.0FD36350

我们只要把 0F7F0F4C 里的 call WeChatWi.0FD36350,替换成 call 咱们自己的函数,便可以对消息进行拦截了。同时,为了不影响原有的功能,我们还需要在 咱们自己的函数 的最后,调用 WeChatWi.0FD36350

我们把 0F7F0F4C 叫做 Hook 地址,把 WeChatWi.0FD36350 叫做 Call 地址。这里 0F7F0F4C0FD36350 都是“相对”地址——相对 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)即可。

RPC

前面我们成功打入微信内部,并且也可以拦截消息并“假传圣旨”,那么,我们怎么把消息传出去或者传进来呢?

微信和我们的应用,在不同的进程。如果我们的应用需要和微信通信,则涉及到进程间通信(Inter Process Communication)。

Windows 支持的 IPC 方式包括:

  • 剪贴板
  • COM
  • 数据复制
  • DDE
  • 文件映射
  • Mailslots
  • 管道
  • RPC
  • Windows 套接字

RPC 指远程过程调用(Remote Procedure Call)。这里的远程指的是不在同一个进程,可以是一台电脑上的不同进程;也可以是不个电脑上的不同进程。使用 RPC,可以创建高性能紧密耦合的分布式应用程序。

本项目选择了 RPC,结果惹了一身麻烦。但是通过 RPC,进程间通信就变得很简单。RPC 工具使用户看起来就像客户端直接调用位于远程服务器程序中的过程一样。 客户端和服务器各自有自己的地址空间;也就是说,每个资源都有自己的内存资源分配给过程使用的数据。 下图说明了 RPC 体系结构:
微信机器人 DIY 从 0 到 1_第2张图片

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/
spy

这部分就是前面举例的拦截消息和“假传圣旨”的实现部分,目前实现了:

  • 接受好友申请(accept_new_friend
  • 执行 SQL(exec_sql
  • 获取联系人(get_contacts
  • 接收消息(receive_msg
  • 发送消息(send_msg
sdk

完成 spy.DLL(间谍)的注入和初始化。

proto

RPC 服务、接口、消息的定义文件。

cpp

C++ 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。

python

Python 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。

java

Java 应用,可以是个对话机器人,可以是个定时消息发送器,还可以是个群发机器。这里提供了 spy 的功能示例。

应用

最初,就是因为有需求而造的轮子,不料轮子造出来需求却没了,现在只好做些无用的东西。欢迎进群体验。

天气播报机器人

每天 7 点准时在群里发布天气预报:

天气播报机器人

智障机器人

只能回答天气问询:
微信机器人 DIY 从 0 到 1_第3张图片
为了做天气播报机器人,写了个天气爬虫,把天气数据抓回来了。代码也开源了:WeatherScrapy。
这个应用使用了 RASA,自己造了些语料简单训练了一下。

成语解释、接龙

团建作弊可用:
微信机器人 DIY 从 0 到 1_第4张图片

To Be Continued…

  • 统计好友分布情况
  • 计算好友之间的亲密度(点赞占比、共同群聊数量、聊天频度……)
  • 羊毛群机器人
  • 清除群僵尸
  • ……

欢迎 Star、PR:WeChatFerry,后续再介绍一个基于 Python 的机器人框架,就可以更愉快地玩耍了。

你可能感兴趣的:(微信机器人,微信,机器人,自然语言处理)