作者: 蔡江育
本打算直接公布 "幻想修改器 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