1.4 游戏的3个核心概念
开发外挂,不仅需要良好的逆向分析和调试能力,还需要对游戏有一定的了解。正所谓有的放矢——有了对游戏的认识这一基础,就能更快地分析并找出目标。本节就带领读者简单地认识游戏。
从本质上看,游戏是由数据和代码构成的。数据包括资源、协议、内存对象和按键等;而代码则构成了游戏逻辑。这里的每个方面都涉及很多知识,都可以单独写成一本书。所以,本书会从外挂开发者最感兴趣的游戏资源、协议和内存对象这3个方面来阐述游戏。
1.4.1 游戏资源的加/解密
游戏资源是指为游戏引擎提供的图片、声音、地图、动画等原始素材文件。早期游戏中的这些原始素材文件,可能只是在游戏客户端分门别类地散落在游戏安装目录下,但是,现在的游戏客户端为了提高读取资源文件的I/O效率和保护游戏资源不被破解,常把这些原始素材文件打包和加密,使游戏客户端安装目录下只剩一个类似script.pvf或script.dat形式的资源文件。资源文件的加/解密理论知识,读者可以参考《揭秘数据解密的关键技术》一书。
如图1-3所示:本章的资源包中有一个用笔者设计的打包器打包的资源文件winsun.pvf,这个资源文件包含若干个目录和若干个文件,每个目录下有一个文件;还有一个DecrptResource.exe程序,这个程序会根据资源文件progressvalue.dat中的值来改变进度条的移动速度。
图1-3 解密脚本文件的程序和资源文件
启动DecrptResource.exe程序,显示界面如图1-4所示。单击“开始”按钮,进度条会在向前移动500000步之后结束运行,如图1-5所示。
图1-4 DecrptResource.exe运行界面
图1-5 以1/500000的速度移动进度条
如果单击“回退”按钮,进度条将退回如图1-4所示的样子。如果单击“读取脚本进度值”按钮,进度值会发生改变,同时会解密资源文件winsun.pvf并释放整个资源目录包中的文件,如图1-6和图1-7所示。
图1-6 读取脚本进度值
图1-7 winsun.pvf释放的脚本目录和文件
这个时候,单击“开始”按钮,进度条将只前移100步就结束运行。
上面这个例子模拟的是游戏的解密资源文件和获取资源文件关键数据的过程。当然,如图1-7所示的过程只是为了展示方便,现实中的游戏往往不会把整个目录和文件释放出来暴露在客户端。
下面,我们从静态和动态的角度分析一下winsun.pvf这个资源文件,看看它是如何定位到解密入口的。在这个过程中,相信读者会明白游戏资源是怎么一回事,以及如何分析游戏资源。这个例子所涉及的打包器代码和解包器代码就不提供给读者了,希望读者能自己动手来破解DecrptResource.exe程序,从而彻底掌握资源文件winsun.pvf的打包和解包过程。
经过打包的资源文件,一般存储在游戏的安装目录下,而且体积都比较大,所以很容易找到。把游戏程序拉到IDA中进行反汇编,按下【Shift】+【F12】组合键,打开“Strings window”选项卡,看看是否有对这个资源文件的引用。
IDA对DecrptResource.exe的反汇编结果如图1-8所示。
图1-8 DecrptResource.exe的IDA Strings窗口
在如图1-8所示的“Strings window”选项卡中,可以看到对资源文件winsun.pvf的引用。双击这个引用,来到如图1-9所示的界面。
图1-9 winsun.pvf的字符串引用点
把鼠标放到字符串“Source[ ]”上并按下【X】键,定位到交叉引用点,如图1-10所示。
图1-10 winsun.pvf的代码引用点
如图1-11所示,字符串“\\winsun.pvf”的地址被压入栈顶,同时,下面还有一个“call j_ScriptDecrypt”的调用。很明显,资源文件就是winsun.pvf,解密这个文件的程序就是ScriptDecrypt函数。只要结合动态调试工具,继续跟进ScriptDecrypt函数,就可以掌握整个解密过程。
图1-11 跳到引用winsun.pvf的代码点
上面这些都是静态分析。游戏在使用资源文件之前,必须打开这个文件,而打开文件的动作,一般由Windows的API CreateFile或者C语言的库函数fopen等实现。所以,可以在CreateFile或fopen处设置下断点,通过动态调试的方法来定位解密的入口。
对于ScriptDecrypt函数实现过程的细节,就留给读者自己分析和研究了。这个过程需要耐心和细心,还需要一些想象和猜测。为了帮助读者更快地完成分析,下面给出资源文件winsun.pvf的设计图和部分数据结构的简要说明,希望能对读者破解ScriptDecrypt函数有所帮助。
winsun.pvf资源文件由3个部分组成,如图1-12所示,分别是文件头、目录表和文件库。
图1-12 winsun.pvf的整体架构
文件头的数据结构如下。
// 文件头 typedef struct _FILE_HEAD{ DWORD dwFileNameLen; // 文件名的长度 BYTE bFileName[dwFileNameLen]; // 文件名 DWORD dwFileDirSrcSize; // 待解密的目录表大小 DWORD dwFileDirSrcKey; // 解密文件目录的密钥 DWORD dwFileDirSum; // 文件目录结构的个数 }FILE_HEAD, *PFILE_HEAD;
目录表中目录项的结构体如下。
// 目录表中目录项的结构 typedef struct _FILE_DIR_TABLE_ENTRY{ DWORD dwDirLevel; // 目录层数 DWORD dwDirFileNameLen; // 带路径的文件名长度 BYTE bDirFileName[dwDirFileNameLen]; // 带路径的文件名 DWORD dwFileSize; // 文件大小 DWORD dwFileDecryptKey; // 解密该文件的密钥 DWORD dwFileOffset; // 文件相对于文件库的偏移 } FILE_DIR_TABLE_ENTRY, *PFILE_DIR_TABLE_ENTRY;
目录表被解密之后,为了在内存中方便地定位目录表,程序会在内存中将FILE_DIR_TABLE_ENTRY结构重新映射成MEM_DIR_TABLE_ENTRY。
// 内存文件目录表项,由FILE_DIR_TABLE_ENTRY映射过来 typedef struct _MEM_DIR_TABLE_ENTRY{ DWORD dwMemAddr; // 结构体地址 DWORD dwStructLable; // 结构体标识,便于区别结构的开始 PWSTR pwstrDirFileName; // 指向新创建的空间,保存带路径的文件名 DWORD dwDirFileNameLen; // 带路径的文件名长度 DWORD dwFileSize; // 文件的大小 DWORD dwAllocFileBufLen; // 实际要分配的存放文件的空间大小 DWORD dwFileDecryptKey; // 解密该文件的密钥 DWORD dwFileOffset; // 该文件相对于文件体的偏移 PVOID pNewFileBuf; // 如果是动画,就指向新创建的空间,以存放读出的文件内容 }MEM_DIR_TABLE_ENTRY, *PMEM_DIR_TABLE_ENTRY;
接下来,将上面这些结构体与资源文件winsun.pvf对应,得出详细的整体架构,如图1-13所示,这是解密的结果。
图1-13 winsun.pvf的详细架构
文件目录和文件库中的文件解密过程如图1-14所示。fread函数根据文件头提供的目录信息读取目录到缓存中,然后调用decrypt函数解密缓存中的文件目录,得到FILE_DIR_TABLE_ENTRY结构体数组。
图1-14 解密文件目录的过程
文件目录映射成内存目录的逻辑过程如图1-15所示。这个映射使游戏程序可以在内存中方便、快速地定位文件,而不用解密和释放整个目录。fread函数根据MEM_DIR_TABLE_ENTRY提供的文件信息,读取文件库中对应的文件并将其放入pBuff中,然后调用decrypt函数来解密pBuff中的加密文件。
以上就是资源文件winsun.pvf的整个解密过程,希望能帮助读者认识资源的加密和打包。不过,在实际操作中,读者可能会碰到各种困难,需要具体问题具体分析。
图1-15 目录映射
1.4.2 游戏协议之发包模型
游戏协议是客户端与服务器以及客户端与客户端之间进行通信的基础。这里讨论的游戏协议是指Windows网络协议栈中的应用层协议。事实上,协议就是封装或解封数据所采用的某种特定格式的通信约定。当通信的一端按一定的格式封装数据并将其发送到另一端后,另一端再按同等格式来解封这些数据,这样就完成了一次通信。
在Windows网络协议栈中,游戏数据封装/解封的过程如图1-16所示。
图1-16 协议数据封装/解封的过程
图1-16展示的封装/解封过程都是由Windows系统中的协议模块处理的。游戏通信端为了快速分类处理游戏数据,也为游戏数据设计了相应的协议,即游戏采用某种格式来封装数据。
游戏中数据的格式划分各不相同,但是从宏观上看,通常采用数据包头加数据包体的格式。为了说明游戏协议数据的格式,下面先给出一种模拟格式。
// 数据包头 typedef struct _PACKET_HEAD { BYTE bPacketType; // 包的类型,命令包为1,通知包为2 WORD wProtocolId; // 协议的ID DWORD dwLength; // 包的长度,包头加包体的字节数 DWORD dwCrc32; // 包体的CRC32校验值,防止包体被篡改 }PACKET_HEAD, *PPACKET_HEAD; // 数据包体 typedef struct _PACKET_BODY { DWORD dwPacketCount; // 包的计数 BYTE bBodyContent[1]; // 根据具体的游戏协议ID来确定内容 }PACKET_BODY, *PPACKET_BODY; // 数据包 typedef struct _PACKET { PACKET_HEAD stPacketHead; PACKET_BODY stPacketBody; }PACKET,*PPACKET;
游戏客户端接收到用户的操作指令,然后根据具体的协议,把数据封装成包(Packet)发送出去。
在上面的模拟格式的基础上,为了帮助读者更好地理解游戏协议,下面给出一段用户摆摊出售物品的模拟行为代码。摆摊出售物品的行为至少涉及3条基本协议,分别是建立商店、摆上物品出售、关闭商店,对应的协议ID放在一个枚举类型中。
enum ENUM_PROTOCOL_ID { ENUM_CMDPACKET_CREATE_SHOP, // 建立商店 ENUM_CMDPACKET_SELL_ITEMS, // 摆上物品出售 ENUM_CMDPACKET_CLOSE_ SHOP // 关闭商店 };
下面我们看看将ENUM_CMDPACKET_SELLER_ITEMS(摆上物品出售)这条协议封装到包中的过程。
// ENUM_CMDPACKET_SELLER_ITEMS对应的数据内容 typedef struct _SELL_ITEMS{ DWORD dwShopNameLen; // 商店名字的长度 CHAR szName[dwShopNameLen]; // 商店的名字 WORD wItemColumNum; // 物品在物品栏中的编号 DWORD dwItemSellMoney; // 某个物品的售价 WORD wShopColumNum; // 商店栏编号 DWORD dwSellCount; // 同一个物品有多少个拿来出售 }SELL_ITEMS, *PSELL_ITEMS; // 下面是伪代码 SELL_ITEMS stSellItem = {0}; // 初始化要出售商品的信息 stSellItem. dwShopNameLen = strlen("winsunshop"); // 初始化商店名称的长度 strncpy(stSellItem.szName, "winsunshop", strlen("winsunshop")); // 初始化商店名称 stSellItem.wItemColumNum = 1; // 物品栏编号 stSellItem.dwItemSellMoney = 1000; // 物品的售价 stSellItem.wShopColumNum = 2; // 商店栏编号 stSellItem.dwSellCount = 1; // 出售个数 // 为数据包分配空间 DWORD dwPacketLen = sizeof(PACKET_HEAD) + 4 + sizeof(SELL_ITEMS); PPACKET pPacket = new BYTE[dwPacketLen]; // 初始化包头 pPacket->stPacketHead.bPacketType = 0x01; // 命令包 pPacket->stPacketHead. wProtocolId = ENUM_CMDPACKET_SELL_ITEMS; // 摆摊出售商品 pPacket->stPacketHead. dwLength = dwPacketLen; // 数据包的长度 pPacket->stPacketBody. dwPacketCount = 1; // 包计数 // 复制协议内容到包体中 memcpy(pPacket->stPacketBody. bBodyContent, // 复制协议内容 &stSellItem, sizeof(SELL_ITEMS)); // 计算并重新设置CRC值 pPakcet->stPacketHead. dwCrc32 = CRC32(pPacket->stPacketBody);
经过上面的数据封装之后,接下来就是将pPacket指向的数据包发送出去。但是,发包通常不是简单地调用send函数,而是先对数据进行加密,然后才发送出去。
接下来给出一个高效的发包模型。通过对这个模型的分析,读者可以更好地理解断下send函数回溯定位Call方法的由来以及整个游戏的通信逻辑,如图1-17所示。可以看到,用户的每个动作在游戏客户端逻辑中都有一个类似Switch结构的case与之对应,可能的Switch如下。
switch(OP)
➢ case —— 开启商店。
// 省略部分代码 break;
➢ case —— 摆摊出售物品,步骤如下。
(1)收集数据。
(2)将数据复制到待发送缓存中(以包的形式)。
(3)读取待发送的包并进行加密。
(4)用加密后的包覆盖原始包。
(5)调用send函数发送加密后的包。
➢ case —— 关闭商店。
// 省略部分代码 break;
图1-17 发包模型
每一个用户操作指令,最终都会变成一个被加密的包,存储在发包模型的可循环利用的缓存中,然后才发送出去。这个发包模型的优点如下。
➢ 统一了发包管理,例如,图1-17中的步骤2、步骤3、步骤4可以封装成一个发包出口函数。
➢ 可循环利用的缓存提高了内存的利用率。
但是,这个发包模型也有不足之处。从游戏安全的角度看,它的弱点在于比较容易从send函数进行回溯分析,从而定位Switch中的某个case,进而挖掘出有价值的Call。如果要对这个发包模型进行改正,可以将图1-17中步骤2、步骤3、步骤4的操作放在一个线程中专门处理,将步骤1的操作放在另外的线程中处理,线程间采用信号量或其他方式同步。这样,即使断下send函数来回溯,也不太容易定位。
当服务器端收到ENUM_CMDPACKET_SELL_ITEMS这个协议包的时候,就会为用户建立商店并摆上物品,所有这些都以数据的形式体现。下面以一幅简单的描述图来结束本节的协议之旅,如图1-18所示。
图1-18 服务器端解封包后的数据操作
1.4.3 游戏内存对象布局
游戏中的各种对象,如玩家、怪物、武器、装备、精灵等,都以内存块的形式存在。其中,有些以结构体形式组织成块,负责保存对象的状态;有些以类的实例形式组织成块,在保存对象的同时提供对象的行为。所以,定位对象内存块的意义重大,不仅可以窥视对象的实例变量的内存布局,而且可以了解对象的行为。另外,修改玩家或怪物的HP/MP和速度、修改武器和装备的属性、召唤精灵等外挂行为,都需要定位对象的内存块。
本节将重点讨论如何定位对象内存块,以及常见对象内存块的内存布局和组织方式。
让我们从一个简化的CPLAYER类开始。
// CPLAYER类的定义 class CPLAYER { public: CPLAYER (DWORD dwHP, DWORD dwVector) { m_dwHP = dwHp; m_dwVector = dwVector;} // CPLAYER类的构造函数 virtual DWORD GetVector(){return m_dwVector;} // 获取速度 virtual void SetVector(DWORD dwVector){m_dwVector = dwVector;} // 设置速度 virtual DWORD GetHP(){return m_dwHP;} // 获取血量 virtual void SetHP(DWORD dwHP){m_dwHP = dwHP;} // 设置血量 private: DWORD m_dwHP; // 血量 DWORD m_dwVector; // 速度 };
上面的CPLAYER类比较简单:1个CPLAYER() 构造函数用于初始化血量及速度,4个虚函数用于获取和设置血量及速度。接下来,我们新建一个CPLAYER对象,将它拖到IDA里,分析一下CPLAYER对象的内存布局,如图1-19所示。
图1-19 CPLAYER对象
可以看到,“new”操作符后面紧跟着就是调用CPLAYER对象的构造函数sub_401000。下面,让我们进入sub_401000函数,如图1-20所示。
图1-20 CPLAYER对象的构造函数
图1-20中的3条指令如下。
➢ mov dword ptr[eax], offset off_4050AC
➢ mov [eax+4], ecx
➢ mov [eax+8], edx
通过这3条指令的赋值操作,可以分析出CPLAYER对象的内存布局,如图1-21所示。
图1-21 CPLAYER对象的内存布局
根据图1-21所展示的CPLAYER对象的内存布局,我们可以很方便地修改m_dwHP和m_dwVector,从而改变血量和速度。
从以上对CPLAYER对象的分析可以发现,如果一个类有构造函数,那么新建这个类的对象的时候,后面紧跟着的是这个类的对应构造函数的调用。同时,根据IDA的分析结果,构造函数能够暴露这个类的部分对象的内存布局。所以,要静态窥视类对象的内存布局,可以搜索“new”操作符,然后在它的后面挖掘构造函数,根据构造函数来分析对象内存布局。
在游戏的可执行文件(如game.exe)中定位“new”操作符的方法大致有两种:一是通过IDA的静态反汇编,随机浏览反汇编代码,找到任意一个“new”操作符,通过这个“new”操作的交叉引用一次性全部定位“new”操作;二是通过动态Hook技术“HOOK new”调用。
在使用第二种方法之前,我们有必要了解一下如图1-22所示的Windows内存管理架构。
图1-22 Windows内存管理架构
可以看到,“new”和“malloc”都是C运行时堆,都调用底层的堆管理器函数来获取服务。如果读者想深入了解堆管理,可以参考Mario Hewrdt和Daniel Pravat的Advanced Windows Debugging一书中关于Heap的介绍。如果用IDA逆向分析new函数,我们将发现:new函数会调用msvcrt.dll导出的malloc() 函数,malloc() 函数继续调用kernel32.dll导出的HeapAlloc() 函数,而HeapAlloc() 函数其实就是NTDLL.dll导出的RtlAllocateHeap() 函数。所以,如果要Hook new函数,可以使用Hook malloc() 语句或Hook HeapAlloc()/RtlAllocateHeap() 语句。不过,如果要Hook HeapAlloc() 函数或RtlAllocateHeap() 函数,我们就会发现,进程中对堆管理函数的频繁调用将干扰Hook的本意。关于这一点,在Justin Seitz的Gray Hat Python一书关于Hooking的部分也提到过,而且,该书还提供了用hippie_easy.py脚本来动态获取RtlAllocateHeap() 函数的调用情况。不过,为了尽量不受干扰,我们可以直接Hook malloc() 函数,而不深入NTDLL层,然后通过堆栈回溯来定位调用“new”操作符的返回地址。
上面所采用的分析方法,是通过“new”操作符后的构造函数来分析对象的部分内存布局的。当然,如果想知道此次截获的“new”操作针对的是角色对象、怪物对象、装备对象还是其他对象,就要结合差异分析的思想了。例如,我们可以在切换角色、切换地图、重新选怪或脱穿装备等行为的前后,看看是否有新的“new”调用。如果“new”调用发生,那么与引入新行为相关的“new”对象的操作肯定存在于这个行为发生之后的“new”调用集合中,这可以帮助我们缩小分析范围。更详细的运用差异思想来分析外挂和游戏的实例,参见第6.3节。
差异思想在Cheat Engine(作弊器)中也有丰富的体现,例如Cheat Engine的内存扫描(Memory Scan)功能。
在本节的最后,让我们看看如何运用差异思想来定位创建的新堆块。一个NewHeapDlg程序如图1-23所示。
图1-23 NewHeap界面
NewHeapDlg进程中堆块的分配信息如图1-24所示,我们可以看到通过Heap32 ListFirst、Heap32ListNext、Heap32First和Heap32Next这4个API枚举出来的当前进程堆信息。
图1-24 NewHeap初始进程堆信息
如果单击“NewHeap”界面上的“new地址”按钮,NewHeapDlg程序将通过“new”操作符为堆分配一个新地址;如果单击“显示new地址”按钮,NewHeapDlg程序将给出当前新分配的堆地址,如图1-25所示。这个时候,如果再次枚举NewHeapDlg进程堆,我们将看到如图1-26所示的堆信息。
图1-25 分配新地址
图1-26 分配新地址后的进程堆信息
如图1-24所示,共枚举出824个堆块,而经过一次“new”操作后,如图1-26所示,共枚举出828个堆块。对比两次dump的进程堆信息,我们可以快速发现新建的堆内存。Cheat Engine中有一项枚举进程堆块的信息,它位于“Memory View”→“View”→“Heaplist”菜单中。这个Heaplist只是动态跟踪进程当前已分配的堆块信息,并不像内存扫描那样多次枚举后对比差异。所以,我们既可以通过给Cheat Engine写插件来实现这种差异分析,也可以通过堆块枚举的API来实现这种差异分析。枚举堆块的代码大致如下。
void EnumProcessHeaps() { BOOL bHeapSuccess = FALSE; BOOL bHeapListSuccess; HEAPENTRY32 stHeapEntry32 = {0}; HEAPLIST32 stHeapList32 = {0}; WORD wHeapCounts = 0; HANDLE hHeapSnap = CreateToolhelp32Snapshot(TH32CS_SNAPHEAPLIST, GetCurrentProcessId()); if( INVALID_HANDLE_VALUE == hHeapSnap ) { OutputDbgInfo(("[-] EnumProcessHeap CreateToolhelp32S napshoterror!")); return; } stHeapList32.dwSize = sizeof(HEAPLIST32); bHeapListSuccess = Heap32ListFirst(hHeapSnap, &stHeapList32); if ( bHeapListSuccess == FALSE) { OutputDbgInfo(("[-] Heap32ListFirst error!")); return; } do { ZeroMemory(&stHeapEntry32, sizeof(stHeapEntry32)); stHeapEntry32.dwSize = sizeof(HEAPENTRY32); bHeapSuccess = Heap32First(&stHeapEntry32, GetCurrentProcessId(), stHeapList32.th32HeapID); if ( bHeapSuccess == FALSE) { bHeapListSuccess = Heap32ListNext(hHeapSnap, &stHeapList32); continue; } do { OutputDbgInfo(("PID: %d, Heap ID: %d, Addr: 0x%0x, Size: %d", \ stHeapEntry32.th32ProcessID, stHeapEntry32.th32HeapID, \ stHeapEntry32.dwAddress, stHeapEntry32.dwBlockSize)); wHeapCounts++; bHeapSuccess = Heap32Next(&stHeapEntry32); } while (bHeapSuccess == TRUE); bHeapListSuccess = Heap32ListNext(hHeapSnap, &stHeapList32); } while (bHeapListSuccess == TRUE); OutputDbgInfo(("[!] 共枚举到 %d个堆地址!", wHeapCounts)); }
到目前为止,本书已经介绍了通过类构造函数来分析对象内存布局和通过动态Hook“new”操作符或差异枚举堆块来定位关键内存块的方法。下面就让我们看看在游戏中一般如何组织和管理一些关键对象内存。
角色、怪物、物品等在游戏画面中出现的对象,一般会存放在一个较大的对象指针数组中,通过多级指针可以定位这个对象指针数组,对象之间通过对象类型字段和ID字段来区分。这些对象的大致组织方式如图1-27所示。
图1-27 全局保存对象
可以看出,角色、怪物、物品等对象的地址都保存在一个对象数组中,它们偏移m和n的地方分别代表对象的类型和对象的UID(全局唯一ID)。通过类型我们可以知道这个对象是怪物对象还是物品对象,通过ID我们可以知道这个对象区别于其他对象的全局唯一标识。在游戏中,这个对象数组保存对象指针。根据以上信息,再结合C++ 中的多态思想,我们就可以非常方便地在游戏的每一帧中通过遍历数组来更新对象的状态了。
对于外挂程序作者而言,获取这个对象数组非常重要。这个对象数组里保存了对象的地址,获取对象的地址再进行对象内存布局分析,就可以实现吸怪(与对象坐标有关)、加速等功能。那么,应该如何分析得出对象数组指针呢?我们可以对任意一个对象地址下读断点,然后进行回溯分析,最终推导出这样一个公式—— [[[[全局指针 ]+x ]+y ]+z ]。这个公式就代表了对象数组的首地址。
下面,再让我们看看游戏角色更换装备的时候,装备对象是如何在角色和物品栏之间切换的,如图1-28所示。
图1-28 更换装备的过程
可以看到,角色对象偏移X的地方是一个拥有装备对象地址的数组,物品栏也是一个对象地址数组。当角色对象从物品栏选取某个装备的时候,根据装备对象偏移 Y处提供的编号,相应装备对象的地址就会放入角色对象所对应的地址(角色对象基地址+X +编号×4)处,同时,物品栏数组的对应位置会被清空。当角色对象脱下某个装备的时候,角色对象所对应的存放装备对象地址的位置将被清空,同时,将该装备对象地址写入物品栏数组的任意位置。根据装备切换过程,我们可以运用Cheat Engine的内存扫描功能,当角色对象穿着装备的时候,扫描一道非零值,当角色对象脱掉装备的时候,扫描一道零值,一直扫描下去,很快就能定位存放装备对象的地址,同时定位装备对象的地址了。