用 MASM32 编写通用游戏改器流程
   作者:蔡江育 于2004-5-29上传 

                      用 MASM32 编写通用游戏改器流程
                                                                  作者: 蔡江育

    本打算直接公布 "幻想修改器 1.1" 源代码算了,但是由于它的大部分代码都是我第一次学 Masm32写的,注释又少,代码也不规范化,对于初学者来说极不方便,所以还不如直接把编写这种软件的思想写出来还好些,这也是对那些支持我的人的一个交待.

    时下,网络游戏横行江湖,单机版的游戏修改器已是昨日黄花,好像已无用武之地。但是我们了解了单机版的通用游戏修改器的编程原理后,再结合网络知识应该不难写出“外挂”来。

    其实像 “金山游侠”,“FPE”,等等编写这些游戏修改器并不难,只不过是你不知道这些东西的思路罢了。也难怪网上关于制做通用游戏修改器的教程可以说是没有,可能是因为商业原因吧!大部分都是针对于某一个游戏的专用修改器而写的。(闲话少说!)
    
    由于时间原因我不能一一细写,只能把那些关键部分写一写或描说。(终于开始了!)
  
一 、初始的准备工作。
      
   首先我们可以这样想一下,要改写游戏的数据值,必需要有能对这个游戏进程中的数据改写的所有权限。这样我们才可以随心所欲的改游戏中的生命值,能量,锁定金钱等等。由于 windows中是不允许进程之间直接相互读的,所以我们要按照 windows的要求来操作方可进行读写。步骤如下:
   要想能对某一个进程的数据进行读写,必需要获得这个进程的句柄,然后用Windows提供的ReadProcessMemory和WriteProcessMemory这两个API来读写游戏的内存。 
   获取进程句柄的方法很多,相信大家都应该知道吧!我还是提一下吧,第一种就是用CreateToolhelp32Snapshot,遍历系统中的所有进程,然后从中得到进程 ID后,再用 OpenProcess,打开,参数中一定要指明PROCESS_ALL_ACCESS,否则以后在写或查目标进程数据时会产生错误。第二种是用 Enuwindows(也就是递归法),或 Getwindow来得到所有窗体的句柄后用 GetWindowThreadProcessId,得到进程ID,再用 OpenProcess打开得到。需要说明的是,如果用第二种方法你还要过滤一下,因为有很多系统窗口是不可见的而且还要判断此窗口是不是父窗口,窗口标题是否为空。
      所以还要加上 (IsWindowVisible,GetParent)这两个函数来判断,要不然展现在用户面前的是一大堆没用的信息。
      一般的通游戏修改器都是采用的第二种方法都是以窗口标题来显示的用户只需点击相应的标题就可以了。
   由于时间原因 CreateToolhelp32snapshot 和 Enumwindows我就不介绍了,我是采用的第一种方法
   上面那两个许多资料上都有介绍。我就说一下如何用 Getwindow吧!

          invoke GetDesktopWindow  ;得到桌面窗口的句柄 
            invoke GetWindow,eax,GW_CHILD ;寻找桌面窗口的第一个子窗口
            invoke GetWindow,eax,GW_HWNDFIRST 
              mov phwnd,eax           ;为这个子窗口寻找第一个兄弟窗口。
            invoke GetParent,eax   ;判断这个窗口是不是父窗口
        .if !eax               ;如果这个窗口没有父窗口,则置标志
           mov parent,1     
        .endif
           mov eax,phwnd
         .while eax
               .if parent
                   mov parent,0   ;复位标志
                 invoke GetWindowText,phwnd,addr titl,sizeof titl ;得到窗口标题文字
       .if eax  ;如果标题文字不为空则发到组合列表框。
  invoke SendDlgItemMessage,hWinMain,combox1,CB_ADDSTRING,0,addr titl
                .endif
              .endif
  invoke GetWindow,phwnd,GW_HWNDNEXT ;寻找这个窗口的下一个兄弟窗口
            mov    phwnd,eax
              invoke GetParent,eax
                 .if !eax
      invoke IsWindowVisible,phwnd ;再判断这个窗口是否是可见的
            .if eax
                 mov parent,1
                    .endif
            .endif
            mov eax,phwnd
         .endw
            invoke   SendDlgItemMessage,hWnd,combox1,CB_SETCURSEL,0,0
   这样我们便实现了通用游戏修改器的目标进程选择功能。大家在写的过程中把符合条件的窗口句柄都保存在缓冲区中并把位置排列的和发到组合列表框中的窗口标题文字一一对应,这样可以通过选中的索引号去缓冲区中取相对
应的句柄。然后再去得到进程句柄。
       
 二、查询目标进程内存使用情况。

   上一步中我们得到了游戏进程的句柄。现在我们可以对它操作了。怎样操作呢?
      由于我们写的游戏修改器是通用的,所以要扫描内存(废话!),因为虚拟内存技术的出现 Windows可以为每个进程提拱 2GB的内存(2000,XP可以提供 3GB)。这么大的内存空间我们去一个个的扫吗?当然不是,要是那样,用户要等很长时间。怎么办呢?可能你会说要是知道这个进程用了多少内存,只要扫描它用的那些内存区不就行了,对。就扫它提交的(用的)内存区。这样就会少很多了,win98的内存分配中用户方式的起始地址是从:0X00400000h----0x7FFFFFFFh的.所以我们只要在这个空间中查询进程提交的内存区(页面)就可以了。内存区(页面)有三种状态 “未用(Free)、保留(Reserved)和提交(Committed)”我们只需要查提交的内存页面有多少就可以了。方法如下:

              lea edi,lpbaseaddr  ;将要存放每一个内存页面基地址的缓冲区。

              lea esi,lpsize      ;将要存放和上面相对应的页面范围的缓冲区。
       .while min<=7FFFFFFFH   ;min=0x00400000h
            invoke VirtualQueryEx,hprocess,min,addr lpm,sizeof lpm
         .if lpm.State==MEM_COMMIT      ;若是提交状态
             .if lpm.Protect & (PAGE_READWRITE or PAGE_EXECUTE_READWRITE)
                 push lpm.BaseAddress  ;返回提交的内存页面的基地址。    
                 pop [edi]             ;保存以便以后进行扫描
                 push lpm.RegionSize   ;返回提交的内存页面大小。
                 pop [esi]
                 add edi,4
                 add esi,4
                 inc pagenum1          ;页面计数器,统计共提交了多少页,以后扫描时就扫这些页即可


             .endif
        .endif
                 mov edx,lpm.RegionSize    ;再查下一页
                 add min1,edx
       .endw
   这样,我们就把该进程提交的每一个内存页面的基地址(首地址),和对应的页面区域保存在变量 lpbaseaddr 和 lpsize中。以后就可以从这两个变量中不断的取出每一页面的起始地址和范围进行扫描了。
   以上代码中 hprocess为要查询的目标进程的句柄,min为用查询的起始地址。
   lpm 为指向 MEMORY_BASIC_INFORMATION结构的指针,(关于这个结构大家可以在 windows.inc中看一看)用于返回内存空间的信息;第四个参数是这个“结构”的长度。
   lpm.protect &(page_readewrite ......)是判断这个提交的页面是否是可以读写的。(也就是再进行过滤).
   好了以上代码就过滤出了真正我们要扫描的内存区了。

三、开始搜索

     前面我们已知道了要扫描的内存区,下面是一个以“双字”查找为例的模型。
        
     .while ebx<=pagenum1    ;pagenum1 是前面查询的共提交了多少内存页
      .
      .
      .
           invoke ReadProcessMemory,hprocess,Baseaddress,addr Dbuffer,size,NULL
      .              
      .
      .
           ;Baseaddress 为第 n 页的基地址,也就是上一步中查询时保存在变量 lpbaseaddr 中的某一页的

基地址值 。           size 为提交的第 n 页的页面大小(同上,保存在 lpsize中的某一内存页面的范围值)


           ;Dbuffer:是从这一页的基地址开始读 size(范围值)个数据到 Dbuffer缓冲区中。
                .
      .
      .
       lea edi,dbuffer   ;得到缓冲区首址,准备向其取值比较。
            mov eax,Dsearch   ;Dsearch=你输入的将要搜索的值.
       .while ecx                .
      .
             SCASD            ;串扫描指令,找出和 Dsearch相等的值。
             jnz continue
             inc total       ;找到的地址数加 1
      .
      .
      .
             ;保存符合条件的地址。
       continue:  
             inc ecx    
       .endw
             inc ebx

            ;再得到下一页的基地址,和页面大小后再扫,直到扫描完提交的所有内存页就完成了第一次内存

搜索。
             .
     .endw
  
   这样我们就得到了第一次搜索到的地址。第二次和第 n 次搜索就不用我说了吧,稍加改动就可以了,只需要从第一次保存的地址值中再一次读出所有值和目标值比较。反复几下就筛出来啦!
需要注意的是你在写的过程中还需要判断用户输入的是 字节,字,还是双字等。
    
    
  四、如何锁定值。
      如果锁定游戏中人物的生命值就无敌了,刚开始不知道原理时还感觉挺神奇的,写过之后,才知道完成这步是如止简单只需要写个定时器过程,并设定定时间,到了指定的时间后,windows 会调用你的定时器过程,向这个地址写入你要锁定的值就可以了。(这样就完了吗?)但是如果我是锁定多个地址值,例如既要锁定生命,又要锁定能量,还又要锁定 ....由于每次锁定的地址,和锁定值都不一样。你总不能为每一个定时器去写个过程吧!还有你不知道用户要锁定多少个地址。怎么办?也许你想到了,对,用“链表”不要小看“数据结构”里的那些枯燥东西,关键时候它还是会起作用的。   为什么要用“链表”,因为第一:你不知道用户究竟要锁定多少个地址。第二:每一个锁定的地址和锁定值都不相同,也许这个地址我锁定8000,那个地址又要锁10000,而且又是共用一个定时器过程。所以我认为用链表是一个比较可行的方法。思路如下:先定义一个定时器的结构来存放你输入的锁定值 ,和地址以及定时器ID。
    
        timer   struct  
     idtimer  dd ?  ;存放定时器ID ,
     address  dd ?  ;存放将要锁定的目标地址。
     value    dd ?  ;存放将要锁定的值。
     next     dd ?  ;存放下一个定时器结构的地址。
        timer   ends
      
     这样你每次要锁定一个地址时,会生成一个如下的数据结构。
                 0     4     8      12
              ----- ----- ------ ------- ........  -----------
          定时器ID1|地址1| 数据1|下一个定时器的地址|.........|定时器ID 9|.......
              ----- ----- ------ -------.......... -----------
      在汇编里面做链表和 C语言里是一样的,稍做改动就行了,我反而觉得在汇编里做更简单些。
   这样就生成了一个定时器链。在定时器过程中你先取得这个定时器表的首地址,然后依次遍历这个链表取出定时器的 ID值和定时器过程中的 _idEvent参数比较。若符合,则取出相应的地址n,数据n 后即可。
   定时器过程如下:
             _lockadd Proc _hWnd,uMsg,_idEvent,_dwTime
          local @modv,@moda
          pushad      
          mov edx,bhead   ;得到定时器链表的首地址
      @@: mov eax,[edx]    ;取第一个链结点中的定时器ID值。
          or eax,eax
          jz exit          ;如果链表已空,则退出。
          cmp eax,_idEvent
          jz  @F
          mov edx,[edx+12]  ;如果不是这一个结点则取下一个结点的首地址(遍历)。
          jmp @B
     @@:  mov eax,[edx+4]    ;如果相等则取出将要锁定的地址。
          mov @modv,eax      
          mov eax,[edx+8]    ;并取出你输入的锁定值。
          mov @moda,eax      
          invoke WriteProcessMemory,hprocess,@modv,addr @moda,size,NULL 。
       exit:
          popad
          ret
           _lockadd endp
            
     这样就完成了锁定多个不同的地址。取消锁定时也只需遍历这个表,找出要取消的“地址” 注意是“地址”,通过地址  便可以找到这个地址所对应的定时器ID值。(指针前移 4 个字节就是定时器ID值 ) 然后 KillTimer 后,再把这个结点的指  针域的内容给到前一个结点的指针域中就完成了取消锁定工作。在编写这个功能的时候写两个过程即可,即一个创建链表 过 程 :Createlist,和删除过程:deletelist.以后进行锁定和取消锁定时就特别方便了。你也许会说为什么不用数组?用数组 在建立时是很方便,但是在删除时你要进行整体移位,会浪费很多时间,而且申请的数组空间也不能明确确定为多大。也许 还有更好的方法,大家如果发现请要记得告诉我呀!


   五、如何用热键从游戏中切出

       这一步如果不是 DX 的游戏,只要按装“键盘钩子”(WH_KEYBOARD)再指定一个热键,例如F2键,当钩子钩到是F2键后,便向主程序发送一个自定义消息(WM_HOOK)。主程序接到这个消息后检查是否是F2键按下,若是则得到前台进程(游戏)的窗口句柄,再通过窗口柄得到进程的ID----再用  OpenProcess就得到了进程句柄。得到后就可以向前面所术的那
样进行操作了。
  主程序的判断过程如下:
     .if eax==WM_HOOK            ;如果接收到钩子过程发过来的消息
     mov eax,wParam      ;wParam中存放着虚拟键
             xor ebx,ebx
             mov ebx,lParam      ;得到键的状态
             AND EBX,80000000H   ;如果最高位为1,则此键是按下后弹起。
       .if   eax==VK_F2 &&EBX
             invoke GetForegroundWindow   ;得到前台进程的窗口句柄
         .if eax!=hWnd       ;eax=目标进程的窗口句柄
             mov    hwndt,eax
        invoke GetWindowText,hwndt,addr cbuffer2,cch  ;得到目标进程的窗口标题文字。
        invoke GetWindowThreadProcessId,hwndt,addr hprocid
                invoke CloseWindow,hwndt   ;将目标窗口最小化。
                invoke OpenProcess,PROCESS_ALL_ACCESS,0,hprocid
         .endif
       .endif
     .endif
        以上代码通过热键,从游戏从切出了。需要注意的是,如果是在自己的修改器上按热键必需得过滤掉

,所以上面那行 eax!=hWnd 就是判断是否是自己的窗口句柄,以免产生不可预料的错误。返回游戏只需键入以下代码就可以了。
      
        ```` .elseif ax==rgame
            invoke SetForegroundWindow,hwndt ; 将游戏窗口设为前台窗口
            invoke OpenIcon,hwndt            ;并最大化这个窗口
   大家也可以试试其它方法,这里就不一一叙说了。(小插曲:要想在 DX 下弹出,好像要编写 COM,因为微软的 "DX" 是一个 COM。我没有研究这方面,好像要钩挂它的函数来实现弹出,大家若有兴趣可以一起研究一下。)

  六、关于模糊搜索(低阶搜索)。
      
      模糊搜索似乎是必需的功能,因为格斗一类的游戏都是用的“血槽”来作为生命值,所以我们修改这类游戏时不知道具体的值,所以无法实现普通的按值查找。所以模糊搜索就产生了。其实编写模糊搜索这个功能是比较简单的,先说一下思想:我们在扫描这类游戏时,首先是输入一个 “?”号,为什么要输入“?”号,因为你不知道具体的值,所以我们要一次性的把所有提交的内存页面的值读出来存放到一个具体位置。第二次假设你输入的是 ‘+' 号,则再一次把所有提交的内存页面的值读出和前一次读出的相比较,保留大于前一次数据值的地址,这样就完成了第一次模糊查找。第三次就同上了,也就是再从保留的那些地址中读出值再比较再保留大的。这样反复几下找出来了,简单吧!需要提一下的是,编写这个功能  最好是建立内存映射文件来保存一次性读出的所有提交的内存页面值。而且速度还会快不少。我写这个功能时创建了二个内 存映射文件,一个用来保存值,一个用来保存地址,因为模糊搜索第一次读出的数据会很多。
这里仅以第一遍内存映象为例。当用户输入 "?" 后的操作过程模型如下:
     _image1        proc
                    local @sizen
                            mov ebx,1
                            mov edi,lpaddress1    
                            mov esi,lpaddress2     ;同上
                     .while ebx<=pagenum1        ;提交的总页数
                            mov edx,[edi]              
                            mov tempaddr,edx      
                            mov edx,[esi]        
                            mov tempsize,edx
                            pushad
     invoke ReadProcessMemory,hprocess,tempaddr,mdata1,tempsize,NULL
     invoke WriteFile,hfile,mdata1,tempsize,addr @sizen,NULL ;将读出数据写

入文件
                            popad
                            inc ebx
                            add edi,4
                            add esi,4
                            mov eax,length1
                            add eax,tempsize
                            mov length1,eax
                  invoke SetFilePointer,hfile,eax,NULL,FILE_BEGIN  ;指针后移
                     .endw
                         ret
       _image1             endp
      
    这样我们就把在初始化时把所有的数据都读入到文件中。下次用这个文件建立一个内存映射文件:
    invoke CreateFileMapping,hfile,NULL,PAGE_READWRITE,0,0,NULL
                    mov hfilemap,eax
        invoke MapViewOfFile,hfilemap,FILE_MAP_WRITE,0,0,0
                    mov lpmemory,eax
       然后跟据你第二次输入的 '+',或 '-'号再次从内存读出数据和以 lpmemory为首址开始的值进行依次比较,保留符合条件的地址就完成了第一次的查找。第二次和按值查找的差不多。你应该能写了来了吧!
        
 七、一些建议
    大家在编写过程中可以参考本人的例子:(幻想修改器 1.1 或 1.0)。
  由于时间原因这次我暂只能写到这里,还有一些其它技术相信大家也可以把它写出来。例如 Directx下的弹出功能,看见 CSDN上是用钩子实现的,也就是说把修改器的对话框代码写到钩子过程中。当你按热键后,你的代码便被windows 映射到了目标进程中,这样你就在游戏中直接弹出了,我也写了一个简单对话框在 “星际”中试验成功了但是好像鼠标不能工作,估计要自己写这个驱动吧。因为我没接触 Dx编程所以我没去细究,大家可以去试试,或者是写COM.估计“金山游侠”是用后者写的吧!后者好像更稳定。(个人猜测)
    还有就是如果你想提高性能使用线程会有所提高的,在单处理机中多线程并不能加快运行速度,因为windows是采用时间片轮转调度的,线程多了后每个线程等的时间也就越长。但是在时间上具有多线程的进程要比单线程的进程获得的时间片要多。假设系统以 20ms 的时间片调度一次,对于单线程来说如果这个线程运行时间超过了20ms将被挂起。操作系  统再调度其它的进程使用CPU.而如果是多线程,假如第一个线程被挂起,它可能会为第二个线程分配 20ms 的时间片再运行。在实现用户界面,后台搜索时都可以用线程去完成。其实大家也不用花这个力去编这种软件,知道工作原理就行了。
  如果你要写推荐你使用这些工具:SoftICE、VC++, OllyDbg(强烈推荐)。
    好了,这次就到这里。因为我到现在为止正式接触 windows编程也只有半年不到。论经验自然是谈不上,文章中有不足之处和不正确的地方请大家立即指出并通知我,我会修正的。最后要说的是,我觉得踏入 windows编程后,最重要的是思想,并且要能够通过现象看本质,透个某个软件的功能联想一下它是怎么实现的。
  
                                        作者:蔡江育
                                                            Email:cjycjl@21cn.com
                                            QQ:23181484
                           
                                                   修改于:        2004.05.29


欢迎访问AoGo汇编小站:http://www.aogosoft.com