|  | 
 
| 本帖最后由 bacy001 于 2016-6-24 15:33 编辑 
 关键字:网游辅助(外挂) VS2010 MFC 多线程 Windows消息
 
 本教程的前几节已经向读者介绍了 WinPcap 编程接口、封包分析方法以及游戏控制等等内容,本节将综合以上内容,向读者展示如何将以上技术有机的整合到一个程序中,从而实现具有监控采集工具耐久度并自动更换采集工具的网游辅助(外挂)程序!
 
 模块设计
 1)主窗口模块,使用 Windows MFC 对话框程序构建网游辅助(外挂)程序的主窗口,同时负责封包捕获模块和动作执行模块之间的协调控制;
 2)封包捕获模块,使用 Windows MFC 创建一个工作者线程,由这个工作者线程完成封包的捕获以及网游消息的识别工作,当发现目标消息(工具耐久值...)后使用自定义消息通知主窗口模块针对不同消息进行处理;
 3)动作执行模块,由主窗口模块调用,用于向游戏发送鼠标和键盘控制消息,实现工具更换、背包整理等动作的执行。
 
 下面本文将分步骤向读者展示构建网游辅助(外挂)程序的全过程
 1、使用 VC2010 创建一个 MFC 应用程序,项目名称:“GameRobot”
 相关设置项如下:基于对话框、不使用 Unicode、采用共享 Dll 的方式使用 MFC,其他默认。
 并添加两个按钮控件、一个 ComboBox 控件和一个 编辑框控件,如下图:
 
 ![]() “Find” 按钮用于搜索游戏窗口,并将窗口句柄保存起来,同时将窗口信息列表显示在 ComboBox 控件中。
 “Start” 按钮用于启动工作者线程以及指定需要监控的封包类型。
 下方编辑框控件用于输出一些必要的信息提示,便于我们了解 GameRobot 的运行情况。
 
 2、源码文件组织
 VC2010 创建的 MFC 对话框应用程序通常会自动生成以下四个文件:GameRobot.h、GameRobot.cpp、GameRobotDlg.h 和 GameRobotDlg.cpp,为了让程序结构更加清晰,建议将使用 WinPcap 捕获封包的代码拿出来单独放到一组文件中,比如:gpcap.h 和 gpcap.cpp。然后与游戏控制相关以及游戏封包内容解析的代码都放在 GameRobotDlg.h 和 GameRobotDlg.cpp 中。
 
 3、代码实现
 1)搜索游戏窗口
 具体代码参见上一节。要点有二,一是要将游戏窗体的窗口句柄保存好,主要用于发送鼠标键盘操作消息;二是得到窗口句柄后,可以通过 Windows API 函数 GetWindowThreadProcessId 进一步获取游戏客户端的进程号,获得进程号以后可以有多种方法通过进程号获取该进程正在使用的网络协议端口号(比如:TCP 或者 UDP 端口号),获取协议端口号主要是为了方便设置封包的过滤规则。本例所采用的用于获取网络协议端口的方法,来源于网络,具体代码请读者自行搜索!如果不考虑 XP 的兼容性推荐使用 Win7 以及以上系统支持的 GetTcpTable2 函数来获取进程所占用的网络协议端口号。
 
 2)启动封包捕获
 这部分代码主要完成两个部分的工作:一是启动用于封包捕获的工作者线程,二是设定需要监控的游戏消息类型(工具耐久、背包负重...)。
 
 在本教程的第二节 “ WinPcap 编程” 中,我们介绍了获取网络驱动接口,打开,设定过滤规则,并开始捕获的方法和代码,这里不在赘述,本节将着重介绍用于封包处理的回调函数 packet_handler 以及如何以工作者线程开启封包捕获功能。
 
 先来看一个数据结构:
 这个数据结构包含三个数据字段,hwnd 用于保存游戏窗口句柄,iPort 用于保存游戏进程所占用的端口,adhandle 则是网络驱动接口句柄。复制代码typedef struct pcap_thread_data
{
        HWND hwnd;
        UINT iPort;
        pcap_t *adhandle;
}pcap_thread_data;
上面的代码列出了两个函数,第一个是左键单击“Start”按钮后,执行的代码,代码很简单,创建一个 pcap_thread_data 结构(请读者自行初始化结构中的变量),然后使用 Windows MFC API 函数 AfxBeginThread 创建工作者线程。Windows 系统会自行创建一个新的线程,并开始执行函数 WorkThread 中的代码,也就是说 WorkThread 函数就是线程的执行体!而 pcap_thread_data 则是传递给工作者线程的参数。复制代码void CGameRobotDlg::OnBnClickedStart()
{
        pcap_thread_data *pThreadData = new pcap_thread_data;
        pThread = AfxBeginThread(WorkThread, pThreadData);
}
UINT WorkThread(LPVOID pParam)
{
        pcap_thread_data *pThreadData = (pcap_thread_data*)pParam;
        while(go)
        {
                pcap_dispatch(pThreadData->adhandle, 1, packet_handler, (u_char*)pParam);
        }
        return 0;
}
 WorkThread 函数很简单,只有一个 While 循环体。变量 go 是一个全局变量,可以理解成捕获封包的总开关。当 go 为真时,工作者线程将不断调用 pcap_dispatch 用于封包捕获;当 go 为假时,While 循环被终止,WorkThread 执行完毕返回后,Windows 系统将自动销毁该线程。因此,我们可以添加一个“Stop”按钮,并通过修改变量 go 的值来关闭封包捕获。
 
 下面让我们看看用于封包处理的回调函数 packet_handler 都干了些什么!之前有介绍过,packet_handler 函数是由 pcap_dispatch 调用的,并且 pcap_dispatch 捕获的原始封包数据就保存在参数 pkt_data 中!因此 packet_handler 函数最重要的工作就是从原始封包数据中,把游戏数据(应用层数据)提取出来。参考代码如下:
 这里先介绍这段代码用到的三个数据结构:复制代码void packet_handler(u_char *param, const struct pcap_pkthdr *header, const u_char *pkt_data)
{
        HWND hwnd = ((pcap_thread_data*)param)->hwnd;
        PMEMBUFFER pTcpData = new MEMBUFFER;
        ip_header *ih;
        tcp_header *th;
        UINT iplen,ihlen,thlen,len;  // IP 包总长度、IP 包头长度、TCP 包长度、数据长度
        ihlen = (pkt_data[14] & 0xf) * 4;  // 从第14字节就是IP头的首字节
        if(ihlen == 20)  
        {
                thlen = (pkt_data[46] & 0xf0) >> 2;
                if(thlen == 20)
                {
                        iplen = pkt_data[16] * 256 + pkt_data[17];
                        len = iplen - ihlen - thlen;
                }
                else
                {
                        ih = (ip_header*)(pkt_data + 14);
                        ihlen = (ih->ver_ihl & 0xf) * 4;
                        iplen = *(u_char*)(&ih->tlen) * 0x10000 + *((u_char*)(&ih->tlen)+1);
                        th = (tcp_header*)((u_char*)ih + ihlen);
                        thlen = (th->hlen_flags & 0xf0) >> 2;
                        len = iplen - ihlen - thlen;
                }
        }
        else
        {
                ih = (ip_header*)(pkt_data + 14);
                ihlen = (ih->ver_ihl & 0xf) * 4;
                iplen = *(u_char*)(&ih->tlen) * 0x10000 + *((u_char*)(&ih->tlen)+1);
                th = (tcp_header*)((u_char*)ih + ihlen);
                thlen = (th->hlen_flags & 0xf0) >> 2;
                len = iplen - ihlen - thlen;
        }
        if(len) // 如果 len 为 0,则表明该封包没有附带任何数据
        {
                pTcpData->len = len;
                pTcpData->head = (u_char*)(pkt_data + 14 + ihlen + thlen);
        }
        else
        {
                pTcpData->len = 0;
                pTcpData->head = NULL;
        }
        if((!len)&&(OrderList.GetCount() > 0))
        {
                Decode(hwnd, pTcpData); // 调用应用层解析函数,用于分离应用层消息
        }
        delete pTcpData;
}
ip_header 和 tcp_header 分别是 IP 包头和 TCP 包头的数据结构,具体字段的含义,请百度;MEMBUFFER 用于保存提取出来的数据,包含两个字段,len 用于保存数据段的长度,head 用于保存数据段的首字节内存地址。复制代码/* IPv4 header */
typedef struct ip_header{
    u_char  ver_ihl;        // Version (4 bits) + Internet header length (4 bits)
    u_char  tos;            // Type of service 
    u_short tlen;           // Total length 
    u_short identification; // Identification
    u_short flags_fo;       // Flags (3 bits) + Fragment offset (13 bits)
    u_char  ttl;            // Time to live
    u_char  proto;          // Protocol
    u_short crc;            // Header checksum
    UINT  saddr;      // Source address
    UINT  daddr;      // Destination address
    u_int   op_pad;         // Option + Padding
}ip_header;
/* TCP header*/
typedef struct tcp_header{
        u_short sport;
        u_short dport;
        UINT seq;
        UINT ack;
        u_short hlen_flags;
        u_short winsize;
        u_short crc;
        u_short end;
}tcp_header;
typedef struct MEMBUFFER
{
        UINT len;
        u_char *head;
}MEMBUFFER,*PMEMBUFFER;
 packet_handler 函数的核心内容就是通过计算 IP 包头与 TCP 包头的长度来确定数据首字节在整个封包数据中的偏移量,正常情况下,以太网数据帧头部 14 字节,IP 包头与 TCP 包头的长度都是 20 字节,因此我们需要的应用层数据的首字节在原始封包数据中的偏移量是 54。而如果检测到 IP 包头与 TCP 包头的长度不是标准的 20 字节,那就就需要先确定 IP 包头的长度,然后由此得到 TCP 包头的起始偏移量,再通过确定 TCP 包头的长度,从而最终确认数据的偏移量。
 
 最后 packet_handler 将调用函数 Decode 来处理 len 大于 0 的封包数据!那么 OrderList 是什么呢?如果我们的程序只准备处理一种类型的游戏消息(工具耐久),那么 OrderList 的用处就不大,但是如果需要同时监控多种消息,就需要使用 OrderList 了。请看如下代码与数据结构:
 OrderList 本质是一个链表类,其元素是 Order 结构,Order 在这里表示“指令”的意思,用于描述我们感兴趣的游戏消息种类。复制代码CPtrList OrderList;
typedef struct ORDER
{
        POSITION pos;
        UINT msgid;
        UINT status;
        UINT flag;
        UINT off;
        UINT dna;
        UINT msglen;
        u_char *msghead;
}ORDER, *PORDER;
其中 pos 字段用于存储元素在链表中的位置,具体请参见 MSDN 关于 CPtrList 的描述。
 
 msgid 用于描述游戏消息种类,可以使用如下代码定义:
 第一个消息用于表示工具耐久的消息,第二个用于表示人物背包负重的消息;复制代码#define WM_ORDER_GETTOOL 0x30010000
#define WM_ORDER_CHARFULL 0x30030000
 status 用于描述该指令目前的执行状态,例如:
 ORDER_STATUS_LISTENING 表示正在侦听,ORDER_STATUS_PAUSE 表示暂停执行;复制代码#define ORDER_STATUS_LISTENING 0x50020000
#define ORDER_STATUS_PAUSE 0x50030000
 flag 则是一个标志位,用于区分不同类型的消息,比如用于描述工具耐久度的消息是 “0x21” 开头的;off 表示偏移量,dna 表示可以用于唯一标识某一种消息的特征码,flag + off + dna 就能唯一的确定一种类型的消息。
 
 例如:游戏里通知装备耐久的消息都是 “0x21” 开头的,然后在偏移量为 2 的地方,如果字串(dna)是“0x0104”则表示工具或者武器,“0x0204”则表示帽子,而“0x0304”则表示衣服。显然当我们只关注工具的耐久时,可以使用如下代码告诉 Decode 函数应该如何具体的处理捕获到的数据。
 这里引入了一种新的变量:临界区类 CCriticalSection ,在多线程编程时,线程同步是一个很重要的问题!当一个变量需要被多个线程访问时(特别是都希望更新变量,即写操作的时候),就必须实施线程同步策略,否则将会出现很多意想不到的问题。而最简单的线程同步方法就是采用“临界区”。复制代码CCriticalSection cs;
PORDER pOrder = new ORDER;
pOrder->msgid = WM_ORDER_GETTOOL;
pOrder->flag = 0x21;
pOrder->status = ORDER_STATUS_LISTENING;
pOrder->off = 2;
pOrder->dna = 0x00000104;
pOrder->msglen = 0;
pOrder->msghead = NULL;
cs.Lock();
OrderList.AddTail(pOrder);
pOrder->pos = OrderList.GetTailPosition();
cs.Unlock();
 临界区的使用也非常简单,只有两个动作,一个 Lock,一个 Unlock。位于 Lock 与 Unlock 之间的变量只能被执行 Lock 操作的线程访问,而其他希望访问这些变量的线程将被操作系统挂起,直到这些变量被 Unlock!
 
 在 Dota 游戏中有很多英雄的技能是需要持续施法的,比如沙王的大招,而如果施法过程中被敌方英雄用技能或者道具打断了,这次施法就作废了,同时进入CD状态。临界区的作用其实就相当于沙王在施放大招前开启了黑皇杖,这样就不会被打断,确保技能成功施放。哈哈,对不起,最近 Dota 比较上瘾。总之一句话,涉及到需要被多个线程访问的数据,在使用的时候尤其是写操作的时候,请一定使用线程同步。
 
 现在,让我们回过头来,看看 Decode 函数是如何工作!参考代码如下:
 Decode 的主要工作就是提取数据块中的消息,第一步则是搜索消息的分隔符“0xeeee”,一个数据块内至少会包含一条消息。提取到消息以后,Decode 会遍历 OrderList,看看提取到的消息是否是 Order 指定的消息种类,如果匹配上了 Order 数据结构中的 flag 和 dna,则将通过 PostMessage 通知主窗口程序进行相关处理。复制代码UINT Decode(HWND hwnd, const PMEMBUFFER pTcpData)
{
        u_char *head = pTcpData->head;
        UINT i, len = pTcpData->len - 1;
        UINT msgnums = 0, msglen = 0, msghead = 0;
        PORDER pOrder;
        POSITION pos;
        for(i = 0; i < len; i++)
        {
                if(*(u_short*)(head + i) == 0xeeee)
                {
                        msglen = i - msghead;
                        if(msglen){
                                pos = OrderList.GetHeadPosition();
                                while(pos){
                                        pOrder = (PORDER)OrderList.GetNext(pos);
                                        if((*(head + msghead) == pOrder->flag)
                                        &&(*(UINT*)(head + msghead + pOrder->off) == pOrder->dna))
                                        {                                        
                                                pOrder->msghead = new u_char[msglen];
                                                pOrder->msglen = msglen;
                                                memcpy(pOrder->msghead, head + msghead, msglen);
                                                ::PostMessage(hwnd, WM_USER_PCAPMSG, pOrder->msgid, (UINT)pOrder);
                                                msgnums++;
                                                break;
                                        }
                                }
                                i = i + 1 ;
                                msghead = i + 1;
                        }
                }
        }
        return msgnums;
}
 3)消息响应以及动作执行
 当 Decode 函数的找到了由 Order 数据结构所描述的游戏消息,会通过 Windows API 函数 PostMessage 发送一条名为 WM_USER_PCAPMSG 的消息到主窗口程序的消息处理函数。WM_USER_PCAPMSG 的定义如下:
 通过 VS2010 的类向导(“ Ctrl + Shift + X ”)可以添加此自定义消息和与之对应的消息处理函数,比如:OnUserPcapMsg。完成后,系统将自动添加如下消息处理函数:复制代码#define WM_USER_PCAPMSG WM_USER+1
当主窗口程序收到一条 ID 为 WM_USER_PCAPMSG 的消息后,就会自动调用 CGameRobotDlg::OnUserPcapMsg,参数 wParam 和 lParam 分别对应 Decode 中 PostMessage 的第三个和第四个参数。其中 wParam 用于描述游戏消息类型,比如是工具耐久消息还是背包负重消息;lParam 则是指向描述此条消息的 Order 数据结构。下面让我们来看看 OnUserPcapMsg 到底是如何定义的(仅以检测工具耐久度为例):复制代码afx_msg LRESULT CGameRobotDlg::OnUserPcapMsg(WPARAM wParam, LPARAM lParam)
函数体中出现了双 switch 结构,外层 switch 用于区分游戏消息种类,比如是工具耐久还是背包负重;内层 switch 用于区分当前指令的执行状态,比如是保持 ORDER_STATUS_LISTENING 还是 ORDER_STATUS_PAUSE。以工具耐久度为例,在之前的封包分析中,我们已经明确工具耐久度数值存储在距离消息首字母偏移量 11 字节的地方,即:复制代码afx_msg LRESULT CTestDlg::OnUserPcapMsg(WPARAM wParam, LPARAM lParam)
{
        PORDER pOrder = (PORDER)(lParam);
        u_char *s;
        CCriticalSection cs;
        switch(wParam)
        {
        case WM_ORDER_GETTOOL:
                switch(pOrder->status)
                {
                case ORDER_STATUS_LISTENING:
                        s = (u_char*)pOrder->msghead + 11;
                        if(*(u_short*)s < 5){
                                cs.Lock();
                                pOrder->status = ORDER_STATUS_PAUSE;
                                delete pOrder->msghead;
                                pOrder->msglen = 0;
                                pOrder->msghead = NULL;
                                cs.Unlock();
                                if(ChangeCharEquip(1,iEquipPos++)){
                                        cs.Lock()
                                        pOrder->status = ORDER_STATUS_LISTENING;
                                        cs.Unlock();
                                }
                        }else{
                                cs.Lock();
                                delete pOrder->msghead;
                                pOrder->msglen = 0;
                                pOrder->msghead = NULL;
                                cs.Unlock();
                        }
                        break;
                }
                break;
        }
        return 0;
}
因此,工具耐久度的值为:*(u_short*)s。当此数值小于 5 的时候,执行函数 ChangeCharEquip 更换工具。这里需要提醒注意的是,代码中修改了指令状态(pOrder->status),从 ORDER_STATUS_LISTENING 变成 ORDER_STATUS_PAUSE。为什么要暂停呢!因为 Windows 的消息处理过程也是多线程异步执行的,而 ChangeCharEquip 更换工具的操作是需要一定时间才能完成,因此在执行 ChangeCharEquip 操作前,应该先暂停监控工具耐久度,等待 ChangeCharEquip 执行完毕再将指令状态重新设置成 ORDER_STATUS_LISTENING。这样就能避免两次 ChangeCharEquip 操作互相重叠,从而确保了游戏控制的准确性!复制代码s = (u_char*)pOrder->msghead + 11;
 至于 ChangeCharEquip 的内容,无非就是一系列 ClickLeftMouseButton 函数的组合,不同游戏,甚至相同游戏不同的分辨率都会导致代码不一样。这里就不给出具体代码了。
 
 到此,基于 WinPcap 封包捕获及分析的网游辅助(外挂)程序开发中的关键技术点都讲解完了!要实现功能丰富以及运行稳定的网游辅助(外挂)程序绝非易事,需要不断的练习和总结。
 
 | 
 |