?? 病毒編程技術.txt
字號:
7月文章試讀:惡意代碼的親密接觸——病毒編程技術(上)
文 / 溫玉潔
生活在網絡時代,無論是作為一名程序員抑或是作為一名普通的電腦使用者,對病毒這個詞都已經不再陌生。網絡不僅僅是傳播信息的快速通道,從另外一個角度來看,也是病毒得以傳播和滋生的溫床,有資料顯示,未安裝補丁的Windows操作系統連接至internet平均10-15分鐘就會被蠕蟲或病毒感染。各種類型的病毒,在人們通過網絡查閱信息、交換文件、收聽視頻時正在悄悄地傳播。這些病毒或蠕蟲不僅在傳播過程中消耗大量的帶寬資源,而且會干擾系統功能的正常使用或造成數據丟失、甚至是硬件損壞,每個電腦用戶幾乎都有過系統被病毒感染而無法正常使用的經歷,大部分企業用戶也都有過因病毒發作致使業務系統不能正常運行的經歷。病毒距離我們,其實并不遙遠。
然而,不只普通用戶在面對各種夸大的報道和宣傳后感覺到茫然和恐懼,隨著計算機各個領域的細分和專業化,就連一些職業的程序員對病毒技術也缺乏深入的了解。病毒,不過是精心設計的一段程序,是編程技巧和優化技術的集中體現,是挑戰技術極限、無所不用其極的一種編程技術。其實病毒技術中的優化和各種精巧的構造,也完全可以在一些特殊的情況下使用,使得某些編程工作得以簡化;從另外一個角度來看,只有充分了解病毒技術,才能更好地研究應對之策,知己知彼,方能百戰不殆。
病毒不是某個系統下的專屬品,事實上現在各種流行的操作系統:從最初的Unix系統到其各種變體如Linux、Solaris、AIX、OS2等,從Windows到CE、Sybian等嵌入式系統,甚至是在某些專業化的大型機系統上,都無一例外地出現了病毒,各種平臺下病毒的基本原理類似的,但是針對不同系統的特性,實現可能區別很大,原因在于作為一種無所不用其極的技術,勢必利用各種系統相關的功能或弱點以取得各種特權和資源。正如生物的多樣性一樣,病毒種類繁多:包括源代碼病毒、宏病毒、腳本病毒以及與各種系統可執行文件系統相關的病毒等。本文將以使用最為廣泛的Windows操作系統下的PE病毒為例,說明病毒技術的原理以及實現技術,驅散籠罩在病毒技術上的迷霧。
* 病毒、蠕蟲、惡意代碼
傳統意義上的病毒是具有類似生物病毒特征的特殊代碼或程序,具有兩個最基本的特點:自我復制和自動傳播。蠕蟲,廣義上一般被認為是病毒的子類,同樣具有自我復制和傳播的特性,但鑒于蠕蟲通常利用系統漏洞而非感染文件系統進行傳播的特殊性,通常將其單獨作為一類。一般認為區分蠕蟲和傳統病毒的分類標準是看其是否依賴于宿主程序進行感染和傳播,如果必須依附于宿主程序才能進行感染和傳播的才是病毒。不過定義不是絕對的,當今病毒和蠕蟲技術的融合愈益深入,界限愈益模糊。很多病毒采用了很多的蠕蟲傳播技術,蠕蟲也不僅僅通過系統漏洞傳播,同時也通過感染文件系統進行傳播。此外還有有相當一部分程序雖然不具備自我復制和自我傳播的特征,但卻執行了未經用戶許可的代碼、做了未經用戶許可的事情,比如特洛伊木馬等間諜軟件、瀏覽器惡意腳本、一些廣告軟件等,顯然無法將其定義為傳統的病毒或蠕蟲,他們和蠕蟲、病毒一樣,同屬于一個更大的范疇——惡意代碼。本文重點闡述傳統病毒經常使用的技術。
* 病毒簡史
談病毒技術,無法回避病毒產生的歷史。早在1949年在馮·諾伊曼的一篇論文《復雜自動裝置的理論及組織的行為》中,即預見了可自我繁殖程序出現的可能。而現在眾所公認的病毒的萌牙于AT&T(貝爾實驗室)幾個年輕的天才程序員編制的磁芯大戰(CoreWar)游戲程序,已經具備了病毒的一些特征。隨后相關的實驗和研究在一些學者和天才的程序員中開始展開,正是這些創造了計算機系統的天才們,制造了計算機病毒。很難考證第一個真正的病毒出現在何時何地,但在20世紀80年代,隨著個人計算機的普及,病毒已經開始流行了,早期的計算機病毒是和當時的文件交換方式和操作系統特點聯系在一起的,那個時候發行軟件或交換文件主要通過軟盤進行,系統是基于文本界面的Unix或DOS,網絡尚未普及,因此這一時期的病毒大都是引導區病毒和文件型病毒,前者通過替換系統引導區代碼在系統啟動時獲取執行權,后者通過修改可執行文件嵌入代碼以在可執行文件執行時獲取控制權,更多病毒的則是二者的結合。IBM-PC的流行和MS DOS系統的普及使得DOS病毒在這一階段逐漸占據了統治地位。80年代后期因特網開始進入人們的視野,這時也出現了第一個因特網蠕蟲——莫里斯蠕蟲,借助于系統漏洞通過網絡進行快速傳播。90年代隨著電腦及網絡的進一步普及,病毒技術也有了很大的進步,這在很大程度上也是由于病毒受社會的關注程度以及反病毒軟件的進步,進一步刺激了病毒制作者群體的創造欲望,多態和變形技術開始出現,以對抗殺毒軟件的特征碼掃描。DOS操作系統病毒的絕對數量出現了爆炸性增長,但90年代后期隨著Windows的出現,DOS病毒和引導區病毒逐漸走向消亡,Windows病毒隨之則開始大量涌現,隨著微軟Office軟件的普及宏病毒出現了,各種腳本病毒也日益增多。因特網的普及在給人們帶來便利的同時也加快了病毒傳播的速度和范圍,靠Emai傳播的蠕蟲開始增多,時至今日仍然是蠕蟲的重要傳播途徑。從2000年至今,在進入21世紀的頭幾年里,Windows下PE病毒技術已經日益純熟、數量日益增多,但病毒排行榜的首位已經讓位給利用各種系統漏洞進行傳播的蠕蟲了,安全研究的深入、各種安全漏洞的大量披露給蠕蟲作者提供了很好的素材,特洛依木馬等惡意軟件數量呈現幾何級數的增長,病毒作者的關注點重新從Windows桌面系統轉向Unix系統、手機等嵌入移動設備上。安全研究也愈益受到社會的關注,病毒和反病毒的戰爭仍在繼續,在可預見的將來,仍將繼續。
不過,Windows PE文件病毒仍然占有非常大的比重。
* Windows平臺和PE文件格式
Windows平臺是當今最為流行的桌面系統,在服務器市場上,也占有相當的份額。其可執行文件(普通的用戶程序、共享庫以及NT系統的驅動文件)采用的是PE(Portable Executebale)文件格式。病毒要完成各種操作,在Windows系統上一般都是通過調用系統提供的API進行的,以保證在各種Windows版本上都能運行,因此讀者應對基本的API比較熟悉。病毒要實現對宿主程序的感染,就不可避免地要修改PE文件,因此要求讀者對PE文件格式有一定的了解,PE文件格式是一種復雜的文件格式,本文并不準備詳細講述PE文件格式,僅作在必要處簡單的介紹,如必要可進一步參閱相關資料[1][2][3]。PE文件結構和頭部部分主要域的格式如下圖1所示。由圖1可見,PE文件是由文件頭、節表、包含各種代碼和數據的節構成。文件頭中定義了PE文件的引入函數表、引出函數表、節數目、文件版本、文件大小、所屬子系統等相關的重要信息。節表則定義了實際數據節的大小、對齊、內存到文件如何進行映射等信息。后面的各個節則包含了實際的可執行代碼或數據。
圖1 PE文件結構及部分主要域的定義
* PE病毒技術剖析
典型的PE病毒修改PE文件,將病毒體代碼寫入PE文件文件中,更新頭部相關的數據結構,使得修改后的PE文件仍然是合法PE文件,然后將PE入口指針改為指向病毒代碼入口,這樣在系統加載PE文件后,病毒代碼就首先獲取了控制權,在執行完感染或破壞代碼后,再將控制權轉移給正常的程序代碼,這樣病毒代碼就神不知鬼不覺地悄悄運行了。染毒后的PE文件運行過程一般圖2所示:
圖2 染毒后的程序執行流程
這只是最常見的執行流程,事實上,隨著反病毒技術的進展,更多的病毒并不是在程序的入口獲取控制權,而是在程序運行中或退出時獲取控制權,以逃避殺毒軟件的初步掃描,這種技術又被稱為EPO技術,將在本文后半部分進行介紹。病毒代碼一般分成幾個主要功能模塊:解碼模塊、重定位模塊、文件搜索模塊、感染模塊、破壞模塊、加密變形模塊等,不同的病毒包含模塊不一定相同,比如解碼、加密變形等就是可選的;但文件搜索和感染模塊是幾乎每個PE病毒都具備的,因為自我復制我傳播是病毒的最基本的特征。有些病毒還可能實現了其他的模塊,比如Email發送、網絡掃描、內存感染等。一段典型的PE病毒代碼執行流程大致如下圖3所示:
圖3 一段典型的病毒代碼執行流程
從原理上看病毒非常簡單,但實現起來還有不少困難,其實如果解決了這些技術難點,一個五臟俱全的病毒也就形成了,本文后面將從一個病毒編寫者的角度就各個難點分別予以介紹。病毒可采用的技術幾乎涉及到Windows程序設計的所有方面,但限于篇幅,本文亦不可能全部介紹,本文將重點介紹Win32用戶模式病毒所常用的一些技術。
* 編程語言
任何語言只要表達能力足夠強,都可用于編寫PE病毒。但現存的絕大部分PE病毒都是直接用匯編編寫的,一方面是因為匯編編譯后的代碼短小精悍,可以充分進行人工優化,以滿足隱蔽性的要求;另外一方面之所以用匯編是因為其靈活和可控,病毒要同系統底層有時甚至是硬件打交道,由于編譯器的特點不盡相同,用高級語言實現某些功能甚至會更加麻煩,比如用匯編很方便地就可以直接進行自身重定位、自身代碼修改以及讀寫IO端口等操作,而用高級語言實現則相對煩瑣。用匯編還可以充分利用底層硬件支持的各種特性,限制非常少。但是用匯編編寫病毒的主要缺點就是編寫效率低,加上使用各種優化手段使得代碼閱讀起來相當困難,不過作為一種極限編程技術,對病毒作者而言,這些似乎都已經不再重要。本文假設讀者熟悉匯編語言,各種舉例使用Intel格式的匯編代碼,編譯器可使用MASM或FASM進行編譯,由于匯編語言表述算法較為不便,因此算法和原理性表述仍然采用C語言。在講述各種技術時,部分代碼直接取自病毒Elkern的源代碼,該病毒在2002年曾經大規模流行,其代碼被收錄于著名病毒雜志29A第7期中,有興趣的讀者可參閱其完整代碼。
* 重定位
病毒自身的重定位是病毒代碼在得以順利運行前應解決的最基本問題。病毒代碼在運行時同樣也要引用一些數據,比如API函數的名字、殺毒軟件的黑名單、系統相關的特殊數據等,由于病毒代碼在宿主進程中運行時的內存地址是在編譯匯編代碼時無法預知的,而病毒在感染不同的宿主時其位于宿主中的準確位置同樣也無法提前預知,因此病毒就要在運行時動態確定其引用數據的地址,否則,引用數據時幾乎肯定會發生錯誤。對于普通的PE文件比如動態鏈接庫而言,在被加載到不同地址處時由加載器根據PE中一個被稱為重定位表的特殊結構動態修正引用數據指令的地址,而重定位表是由編譯器在編譯階段生成的,因此動態鏈接庫本身無需為此做任何額外處理。病毒代碼則不同,必須自己動態確定需引用數據的地址。比如一段病毒代碼被加載在0x400000處,地址0x401000處的一條語句及其引用的數據定義如下所示,相關地址是編譯器在編譯時計算得到的,這里假設編譯時預設的基地址也是0x400000:
401000:
mov eax,dword ptr [402035]
......
402035:
db "hello world!",0
如果病毒代碼在宿主中也加載到基地址0x400000,顯然是能夠正常執行的,但如果這段代碼被加載在基地址0x500000運行時則出錯,對病毒而言,這是大多數時候都會遇到的情況,因為指令中引用的仍然是0x402035這個地址。如果病毒代碼不是在宿主進程中而是作為一個具有重定位表的獨立PE文件運行,正常情況下由系統加載器根據重定位表表項將 mov eax,dword ptr [402035]中的0x402035修改為正確值0x502305,這樣這句代碼就變成了mov eax,dword ptr [5402035],程序也就能準確無誤地運行了。不過很可惜,對在其它進程內運行病毒代碼而言,必須采取額外的手段、付出額外的代價感染宿主PE文件時就及時加以解決,否則將導致宿主進程無法正常運行。
至少有兩種方法可以解決重定位的問題:
A)第一種方法就是利用上述PE文件重定位表項的特殊作用構造相應的重定位表項。在感染目標PE文件時,將引用自身數據的需要被重定位的地址全部寫入目標PE文件的重定位表中,如果目標PE無任何重定位表項(如用MS linker的/fixed)則創建重定位表節并插入新的重定位項;若已經存在重定位表項,則在修改已存在的重定位表節,在其中插入包含了這些地址的新表項。重定位的工作就完全由系統加載器在加載PE文件的時候自動進行了。重定位表項由PE文件頭的DataDirectory數據中的第6個成員IMAGE_DIRECTORY_ENTRY_BASERELOC指向。該方法需要的代碼稍多,實現起來也相對比較復雜,另外如果目標文件無重定位表項(為了減小代碼體積,這種情況也不少見),處理起來就比較麻煩,只有用高級語言編寫病毒才常用該種方法,在一般的PE病毒中很少使用。
B)利用Intel X86體系結構的特殊指令,call或fnstenv等指令動態獲取當前指令的運行時地址,計算該地址與編譯時預定義地址的差值(被稱為delta offset),再將該差值加到原編譯時預定的地址上,得到的就是運行時數據的正確地址。對于intel x86指令集而言,在書寫代碼時,通過將delta offset放在某個寄存器中,然后通過變址尋址引用數據就可以解決引用數據重定位的難題。還以上例說明,假如上述指令塊被操作系統映射在0x500000處那么代碼及其在內存中的地址將變為:
501000:
mov eax,dword ptr [402035]
......
502035:
db "hello world!",0
顯然,mov指令引用的操作數地址是不正確的,如果我們知道了mov指令運行時地址是0x501000,那么計算該地址和編譯時該指令預設地址的差值:0x501000-0x401000 = 0x100000。很顯然指令引用的實際數據地址應該為0x402035+0x100000 = 0x502035。從上例可以看出,只要能夠在運行時確定某條指令動態運行時的地址,而其編譯時地址已知,我們就能夠通過將delta offset加到相應的地址上正確重定位任何代碼或數據的運行時地址。原理如圖4所示:
圖4 delta iffset
通常只要在病毒代碼的開始計算出delta offset,通過變址尋址的方式書寫引用數據的匯編代碼,即可保證病毒代碼在運行時被正確重定位。假設ebp包含了delta offset,使用如下變址尋址指令則可保證在運行時引用的數據地址是正確的:
;ebp包含了delta offset值
401000:
mov eax,dword ptr [ebp+0x402035]
......
402035:
db "hello world!",0
在書寫源程序時可以采用符號來代替硬編碼的地址值,上述的例子中給出的不過是編譯器對符號進行地址替換后的結果。現在的問題就轉換成如何獲取delta offset的值了,顯然:
call delta
delta:
pop ebp
sub ebp,offset delta
在運行時就動態計算出了delta offset值,因為call要將其后的第一條指令的地址壓入堆棧,因此pop ebp執行完畢后ebp中就是delta的運行時地址,減去delta的編譯時地址“offset delta”就得到了delta offset的值。除了用明顯的call指令外,還可以使用不那么明顯的fstenv、fsave、fxsave、fnstenv等浮點環境保存指令進行,這些指令也都可以獲取某條指令的運行時地址。以fnstenv為例,該指令將最后執行的一條FPU指令相關的協處理器的信息保存在指定的內存中,結構如下圖5所示:
圖5 浮點環境塊的結構
該結構偏移12字節處就是最后執行的浮點指令的運行時地址,因此我們也可以用如下一段指令獲取delta offset:
fpu_addr:
fnop
call GetPhAddr
sub ebp,fpu_addr
GetPhAddr:
sub esp,16
fnstenv [esp-12]
pop ebp
add esp,12
ret
delta offset也不一定非要放在ebp中,只不過是ebp作為棧幀指針一般過程都不將該寄存器用于其它用途,因此大部分病毒作者都習慣于將delta offset保存在ebp中,其實用其他寄存器也完全可以。
在優化過的病毒代碼中并不經常直接使用上述直接計算delta offset的代碼,比如在Elkern開頭寫成了類似如下的代碼:
call _start_ip
_start_ip:
pop ebp
;...
;使用
call [ebp+addrOpenProcess-_start_ip]
;...
addrOpenProcess dd 0
;而不是
call _start_ip
_start_ip:
pop ebp
sub ebp,_start_ip
call [ebp+addrOpenProcess]
為什么不采用第二種書寫代碼的方式?其原因在于盡管第一種格式在書寫源碼時顯得比較羅嗦,但是addrOpenProcess-_start_ip是一個較小相對偏移值,一般不超過兩個字節,因此生成的指令較短,而addrOpenProcess在32 Win32編譯環境下一般是4個字節的地址值,生成的指令也就較長。有時對病毒對大小要求很苛刻,更多時候也是為了顯示其超俗的編程技巧,病毒作者大量采用這種優化,對這種優化原理感興趣的讀者請參閱Intel手冊卷2中的指令格式說明。
* API函數地址的獲取
在能夠正確重定位之后,病毒就可以運行自己代碼了。但是這還遠遠不夠,要搜索文件、讀寫文件、進行進程枚舉等操作總不能在有Win32 API的情況下自己用匯編完全重新實現一套吧,那樣的編碼量過大而且兼容性很差。Win9X/NT/2000/XP/2003系統都實現了同一套在各個不同的版本上都高度兼容的Win32 API,因此調用系統提供的Win32 API實現各種功能對病毒而言就是自然而然的事情了。
所以接下來要解決的問題就是如何動態獲取Win32 API的地址。最早的PE病毒采用的是預編碼的方法,比如Windows 2000中CreateFileA的地址是0x7EE63260,那么就在病毒代碼中使用call [7EE63260h]調用該API,但問題是不同的Windows版本之間該API的地址并不完全相同,使用該方法的病毒可能只能在Windows 2000的某個版本上運行。因此病毒作者自然而然地回到PE結構上來探求解決方法,我們知道系統加載PE文件的時候,可以將其引入的特定DLL中函數的運行時地址填入PE的引入函數表中,那么系統是如何為PE引入表填入正確的函數地址的呢?答案是系統解析引入DLL的導出函數表,然后根據名字或序號搜索到相應引出函數的的RVA(相對虛擬地址),然后再和模塊在內存中的實際加載地址相加,就可以得到API函數的運行時真正地址。在研究操作系統是如何實現動態PE文件鏈接的過程中,病毒作者找到了以下兩種解決方案:
A)在感染PE文件的時候,可以搜索宿主的函數引入表的相關地址,如果發現要使用的函數已經被引入,則將對該API的調用指向該引入表函數地址,若未引入,則修改引入表增加該函數的引入表項,并將對該API的調用指向新增加的引入函數地址。這樣在宿主程序啟動的時候,系統加載器已經把正確的API函數地址填好了,病毒代碼即可正確地直接調用該函數。
B)系統可以解析DLL的導出表,自然病毒也可以通過這種手段從DLL中獲取所需要的API地址。要在運行時解析搜索DLL的導出表,必須首先獲取DLL在內存中的真實加載地址,只有這樣才能解析從PE的頭部信息中找到導出表的位置。應該首先解析哪個DLL呢?我們知道Kernel32.DLL幾乎在所有的Win32進程中都要被加載,其中包含了大部分常用的API,特別是其中的LoadLibrary和GetProcAddress兩個API可以獲取任意DLL中導出的任意函數,在迄今為止的所有Windows平臺上都是如此。只要獲取了Kernel32.DLL在進程中加載的基址,然后解析Kernel32.DLL的導出表獲取常用的API地址,如需要可進一步使用Kernel32.DLL中的LoadLibrary和GetProcAddress兩個API更簡單地獲取任意其他DLL中導出函數的地址并進行調用。
* 獲取Kernel32.DLL基址
獲取Kernel32.DLL基址的方法很多,最常見的一種是搜索法,如果已知Kernel32.DLL加載的大致地址,那么可由該地址向高地址或低地址進行搜索可以找到其基址。另外一種方法是搜索NT PEB結構中的模塊列表獲取Kernel32.DLL的準確加載基址。下面看一下具體的實現代碼:
方法1:暴力搜索獲取Kernel32.DLL的基址
最初的病毒是指定一個大致的加載地址,比如根據實驗在9X下其加載地址是0xBFF70000;在Windows 2000下加載基址是0x77E80000;在XP和2003下其加載基址是0x77E60000,因此在NT系統下就可以從0x77e00000開始向高地址搜索,在9X下可以從0xBFF00000開始向高地址搜索,如果搜索到Kernel32.DLL的加載地址,其頭部一定是“MZ”標志,由模塊起始偏移0x3C的雙字確定的PE頭部標志必然是“PE”標志,因此可根據這兩個標志判斷是否找到了模塊加載地址,也許有人認為該方法不可靠,因為如果恰好有某段數據符合這兩個特征,那么找到的基址可能就是錯誤的,但經實驗證明,該判斷方法非常可靠,基本不會出現錯誤。有一點需要注意的是,在所有版本的Windows系統下Kernel32.DLL的加載基址都是按照0x10000對齊的,根據這一特點可以不必逐字節搜索,按照64K對齊的邊界地址搜索即可。
從大致的一個地址開始搜索Kernel32.DLL基址可能會出現讀寫到未映射內存區域的情況,因此需要和SEH配合使用。如果有在各個版本下準確獲取Kernel32.DLL中某地址的通用方法,那么就可以更可靠地從該地址開始向低地址搜索,顯然會更加通用。事實上,這種方法是存在的。在系統加載PE文件跳轉到PE入口點第一條指令的時候,堆棧頂保存的就是Kernel32.DLL中的某個地址,Elkern中采用的就是這種方法:
_start:
pushfd ;If some flags,especial DF,changed,some APIs can crash down!!!
pushad
_start_@1 equ $
;......
mov ebx,[esp+9*4] ;前面已經由pushfd和pushad壓入了9個雙字
?? 快捷鍵說明
復制代碼
Ctrl + C
搜索代碼
Ctrl + F
全屏模式
F11
切換主題
Ctrl + Shift + D
顯示快捷鍵
?
增大字號
Ctrl + =
減小字號
Ctrl + -