作者:星軌(oRbIt)
E_Mail :inte2000@163.com
摘要:所謂SMC(Self Modifying Code)技術,就是一種將可執行檔中的代碼或資料進行加密,防止別人使用逆向工程工具(比如一些常見的反組譯碼工具)對程式進行靜態分析的方法,只有程式運行時才對代碼和資料進行解密,從而正常運行程式和訪問資料。電腦病毒通常也會採用SMC技術動態修改記憶體中的可執行代碼來達到變形或對代碼加密的目的,從而躲過殺毒軟體的查殺或者迷惑反病毒工作者對代碼進行分析。由於該技術需要直接讀寫對記憶體中的機器碼,所以多採用組合語言實現,這使得很多想在自己的程式中使用SMC技術進行軟體加密的C/C++程式員望而卻步。針對這種現狀,本文提出了幾種基於C/C++語言的機器指令定位方法,從而用C/C++語言實現了動態代碼修改技術。
關鍵詞:SMC 動態代碼修改 軟體加密
一、什麼是SMC技術
所謂SMC(Self Modifying Code)技術,就是一種將可執行檔中的代碼或資料進行加密,防止別人使用逆向工程工具(比如一些常見的反組譯碼工具)對程式進行靜態分析的方法,只有程式運行時才對代碼和資料進行解密,從而正常運行程式和訪問資料。電腦病毒通常也會採用SMC技術動態修改記憶體中的可執行代碼來達到變形或對代碼加密的目的,從而躲過殺毒軟體的查殺或者迷惑反病毒工作者對代碼進行分析。現在,很多加密軟體(或者稱為“殼”程式)為了防止Cracker(破解者)跟蹤自己的代碼,也採用了動態代碼修改技術對自身代碼進行保護。以下的虛擬碼示範了一種SMC技術的典型應用:
proc main:
............
IF .運行條件滿足
CALL DecryptProc (Address of MyProc);對某個函數代碼解密
........
CALL MyProc ;調用這個函數
........
CALL EncryptProc (Address of MyProc);再對代碼進行加密,防止程式被Dump
......
end main
在自己的軟體中使用SMC(代碼自修改)技術可以極大地提高軟體的安全性,保護私人資料和關鍵功能代碼,對防止軟體破解也可以起到很好的作用。但是,SMC技術需要直接讀寫對記憶體中的機器碼,需要對組合語言和機器碼有相當的瞭解,具體的實現一般都是採用組合語言。由於組合語言晦澀難懂,不容易掌握,這使得很多想在自己的程式中使用SMC技術進行軟體加密的C/C++程式員望而卻步。難道只能用組合語言實現SMC技術?其實不然,從理論上講,只要支援指標變數和記憶體直接存取,象C/C++這樣的進階語言一樣可以使用SMC技術。本文就是利用C/C++語言的一些特性,比如函數地址和變數地址直接存取等特性,實現了幾種對運行中的代碼和資料進行動態加密和解密的方法。首先是利用Windows可執行檔的結構特性,實現了一種對整個程式碼片段進行動態加密解密的方法;接著又利用C/C++語言中函數名稱就是函數地址的特性,實現了一種對函數整體進行加密解密的方法;最後採用在代碼中插入特徵代碼序列,通過尋找匹配特徵代碼序列定位代碼的方式,實現了一種對任意代碼片斷進行解密解密的方法。下面就分別介紹這幾種方法。
二、對整個程式碼片段使用SMC方式加密解密
在程式中使用SMC最簡單的方法就是修改(或加密)整個資料區段或程式碼片段,這裡首先要講一下“段”的概念。這個“段”有兩層含義,第一層含義是程式在記憶體中的分布,老的16位作業系統對記憶體使用量分段映射的方式,使用不同的段分別存放代碼、資料和堆棧,使用專用的基底位址暫存器訪問這些段,於是就有了程式碼片段、資料區段和堆棧段等等區分。隨著32位Windows的興起,一種新的32位平坦(Flat)記憶體模式被引入Windows記憶體管理機制,在平坦模式下對段的區分已經沒有意義了,但是段的概念依然被保留下來,這些同名的基底位址暫存器現在被成為“段選取器”,只是它們的作用和普通的寄存器已經沒有區別了。段的另一層含義是指儲存在磁碟上的Windows可執行檔中的資料結構(就是PE檔案中的Section),是Windows在裝載這個可執行檔時對代碼和資料定位的參考。不過要真正理解段的概念,還需要瞭解Windows 可執行檔的結構和Windows將可執行檔載入到記憶體中的方式。
Microsoft為它的32位Windows系統設計了一種全新的可執行檔格式,被成為“Portable Executable”,也就是PE格式,PE格式的可執行檔適用於包括Windows 9X、Windows NT、Windows 2000、Windows XP以及Windows 2003在內的所有32位作業系統,估計以後的Windows新版本也將繼續支援PE格式。PE檔案格式將檔案資料群組織成一個線性資料結構,圖2-1展示了一個標準PE檔案的映象結構:
位於檔案最開始部位的是一個MS-DOS頭部和一段DOS stub代碼,在PE檔案中保留這一部分是為了DOS和Windows系統共存那一段時期設計的,當程式運行在DOS系統時,DOS系統按照DOS可執行檔的格式調用DOS stub代碼,一個典型的DOS stub代碼就是在控制台上輸出一行提示:“This program cannot be run in MS-DOS mode”,當然不同的編譯器產生的DOS stub代碼也各不相同。曾經有一段時間很流行一種既可以在DOS系統上運行,又可以在Windows上啟動並執行程式,其原理就是人為地替換這段DOS stub代碼。緊跟在DOS stub代碼之後的就是PE檔案的內容了,首先是一個PE檔案標誌,這個標誌有4個位元組,也就是“PE/0/0”。這之後緊接著PE檔案頭(PE Header)和可選頭部(Optional Header,也可以理解為這個PE檔案的一些選項和參數),這兩個頭結構存放PE檔案的很多重要訊息,比如檔案包含的段(Sections)數、時間戳記、裝入基址和程式進入點等資訊。這些之後是所有的段頭部,段頭部之後跟隨著所有的段實體。PE檔案的尾部還可能包含其它一些混雜的資訊,包括重分配資訊、偵錯符號表資訊、行號資訊等等,這些資訊並不是一個PE檔案必須的部分,比如正常發布的Release版本的程式就沒有偵錯符號表資訊和行號資訊,所以圖2-1 表示的結構圖中省略了這些資訊。
在整個頭結構中,我們關心的僅僅是各個段的段頭部,因為段頭部包含這個段在檔案中的起始位置、長度以及該段被映射到記憶體中的相對位置,在對記憶體中的代碼修改時,需要這些資訊定位記憶體讀寫地址和讀寫地區長度。下面來看看winnt.h中對段首部的定義,
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
在這個頭結構中我們關心的是Name、VirtualSize、VirtualAddress和Characteristics四個屬性。Name是這個段的名稱,長度是8個位元組,段名稱一般以“.”開始,如“.text”,“.data”等等,但是這並不意味著段名稱必須以“.”開始,這隻是Microsoft的編譯器的一個約定,很多編譯器並不遵循這個約定。段名稱對直接修改記憶體代碼和資料是一個很重要的屬性。因為在記憶體中定位段頭部是通過搜尋這個Name字串來實現的。VirtualSize是一個段的真實長度,它有別於SizeOfRawData,SizeOfRawData是檔案對齊後的長度,通常PE檔案是以200H位元組對齊的,所以SizeOfRawData是200H的整數倍。但是被Windows裝入記憶體中就不一定是按照200H位元組對齊了,所以要用VirtualSize來確定段的長度。VirtualAddress是這個段在記憶體中的相對虛地址(RVA),這個相對虛地址加上程式載入的基地址就是這個段在記憶體中的真正地址。最後是段屬性Characteristics,操作這個段屬性的目的是為這個段增加可寫入的屬性,因為Windows不允許向一個唯讀段寫資料。段屬性由一些標誌位組成,各個常用標誌位的含義以及它們的值如下表所示:
Flag |
意義 |
0x00000020 |
這是一個程式碼片段 |
0x00000040 |
這個段包含已初始化資料 |
0x00000080 |
這個段包含未初始化資料 |
0x02000000 |
這個段的資料可被丟棄(EXE檔案裝載完成後,進程就不需要這些資料了) |
0x10000000 |
該段可以執行 |
0x20000000 |
該段為共用段 |
0x40000000 |
該段可讀 |
0x80000000 |
該段可寫 |
【表 2-1】常用段屬性標誌位
通常編譯器產生的程式的程式碼片段具有0x00000020、0x10000000和0x40000000屬性,如果我們要修改程式碼片段的代碼,就需要為其添加0x80000000標誌,否則會引起Windows報告非法訪問的異常。
PE格式檔案的使用,使得Windows載入可執行檔不用再象以前一樣將可執行檔拆開,在記憶體中東一塊西一塊地放置,取而代之的是一種簡單的載入方式,就是按照順序將PE檔案讀取到記憶體中,這也使得載入到記憶體中的PE檔案和存放在磁碟上的PE檔案具有相似的結構,只是各個段因為對齊的不同而導致位移位置略有不同,示範了這種差別:
上面只是簡單介紹了PE檔案的格式以及載入方式,如果想更加深入瞭解PE檔案,可以查閱本文的參考文獻[2],下面本文就通過一個簡單的例子介紹一下如何通過直接存取記憶體實現對代碼的動態加密和解密。首先要說明的是不能對編譯器產生的預設程式碼片段進行全程式碼片段加密,這是很顯然的,因為整個程式的入口代碼也在預設程式碼片段,如果對整個預設程式碼片段加密,你將沒有機會對其解密,從而造成程式載入運行失敗。不同的編譯器產生的預設代碼名稱是不一樣的,一般Microsoft的編譯器會將所有的代碼放置在一個名為“.text”的預設程式碼片段中,而Borland的編譯器的預設程式碼片段名為“CODE”,其它的編譯器可能有其它的代碼建置原則,不過有一點是相通的,就是不能對程式進入點所在的程式碼片段實行整段加密。針對這種情況,本文介紹的策略就是將需要加密的重要代碼或資料放置在一個單獨的程式碼片段中,然後通過記憶體尋找定位到這個段並對其進行加密解密操作。首先是通知編譯器在產生代碼時產生一個新的程式碼片段,並將我們指定的代碼放置在這個程式碼片段中,對於做到這一點,不同的編譯器有不同的實現方法,本文的例子使用的編譯器是Visual C++,可以使用先行編譯指令#pragma為程式添加一個程式碼片段。首先用VC的嚮導產生一個Win32應用程式架構,然後添加如下代碼:
#pragma code_seg(".scode")
int CalcRegCode(const char *pszUserName, char *pCodeBuf, int nbufSize)
{
if(!pszUserName || !pCodeBuf)
return 0;
int nLength = strlen(pszUserName);
if(nLength <= 0 || nLength >= nbufSize)
return 0;
if(::IsBadReadPtr(pszUserName,nLength) || ::IsBadWritePtr(pCodeBuf,nbufSize))
return 0;
for(int i = 0; i < nLength; i++)
pCodeBuf[i] = pszUserName[i] + 1;//為了示範,僅僅是作個移位變換
pCodeBuf[nLength] = 0;
return nLength;
}
#pragma code_seg()
#pragma comment(linker, "/SECTION:.scode,ERW")
CalcRegCode()函數根據使用者名稱產生一個合法的註冊碼,這是一個應該受到重點保護的函數,所以要對其進行加密,此處的CalcRegCode()函數代碼非常簡單,只是為了示範之用,其功能就是把使用者名稱向後移一位形成註冊碼。#pragma code_seg(".scode")指令是告訴編譯器為程式產生一個名為“.scode”的程式碼片段,另一個不帶參數的先行編譯指令#pragma code_seg()告訴編譯器此處是新程式碼片段的結束位置,這兩個先行編譯指令之間的代碼將被編譯器放置在這個名為“.scode”的新程式碼片段中。段的名稱“.scode”可以根據自己的意願隨意命名,但是長度(不包括結尾的/0結束符)不能超過8個位元組,這是由Windows PE檔案的結構所決定的。最後一行#pragma comment(linker, "/SECTION:.scode,ERW")是告訴連結程式最終在產生代碼時添加這個名為“.scode”的程式碼片段,段屬性為“ERW”,分別表示可執行、可讀和可寫。也可以不使用先行編譯指令#pragma comment,直接在編譯選項中添加“/SECTION:.scode,ERW”選項也可以達到相同的目的。現在編譯這個程式,使用PE檔案查看工具可以看到程式中已經有了一個名為“.scode”的程式碼片段,段屬性為0xE0000020,也就是0x00000020(程式碼片段)、0x10000000(可執行)、0x40000000(可讀)和0x80000000(可寫)四個屬性的組合。
有了新的可讀寫程式碼片段之後的問題就是如何在程式運行期間定位到這個段的位置,並對其進行修改,這就需要知道PE檔案載入以後在記憶體中的位置。當一個可執行程式被Windows載入以後,Windows的虛擬記憶體管理機制就為其映射了一個單獨的4GB記憶體空間(當然應用程式只能使用其中的一部分,另一部分被作業系統佔用),應用程式中的地址都被映射到這個虛擬記憶體空間中,整個PE檔案被映射到這個虛擬空間的某一段中,開始的位置就被稱為映象基地址(Image Base),這個地址當然也是一個“虛地址”(區別於在記憶體硬體中的真真實位址)。Windows提供了一個API用於獲得應用程式的基地址,這個API就是GetModuleHandle(),它的函數原型是:
HMODULE GetModuleHandle(LPCTSTR lpModuleName);
參數lpModuleName用於指定模組的名字,如果是獲得當前可執行檔載入的基地址,只需傳遞一個NULL就可以了,傳回值類型HMODULE看起來有些神秘,其實可以將其強制轉換成一個void類型的指標使用,它指向的位置就是我們需要的基地址。找到映象基地址以後,就可以根據PE檔案的結構依次遍曆所有的Section(段)表,找到名為“.scode”的段,然後通過段表中的VirtualAddress屬性得到“.scode”段在記憶體中的起始地址,實際上這個VirtualAddress只是相對於映象基地址的一個位移量,,“.scode”段的真正位置要通過VirtualAddress加上映象基地址獲得。“.scode”段的大小通過VirtualSize屬性得到,這個大小是對齊前的大小,也就是全部代碼的真正大小,不包括為對齊而填充的0位元組。在前面對PE檔案介紹的基礎上,不難寫出這個尋找程式,下面就給出一個尋找某個段的虛地址和大小的通用函數:
bool GetSectionPointer(void *pModuleBase,const char *lpszSection,void** ppPos,LPDWORD lpSize)
{
IMAGE_DOS_HEADER *pDosHead;
IMAGE_FILE_HEADER *pPEHead;
IMAGE_SECTION_HEADER *pSection;
*ppPos = NULL;
*lpSize = 0;
if(::IsBadReadPtr(pModuleBase,sizeof(IMAGE_DOS_HEADER)) || ::IsBadReadPtr(lpszSection,8))
return false;
if(strlen(lpszSection) >= 16)
return false;
char szSecName[16];
memset(szSecName,0,16);
strncpy(szSecName,lpszSection,IMAGE_SIZEOF_SHORT_NAME);
unsigned char *pszModuleBase = (unsigned char *)pModuleBase;
pDosHead = (IMAGE_DOS_HEADER *)pszModuleBase;
//跳過DOS頭不和DOS stub代碼,定位到PE標誌位置
DWORD Signature = *(DWORD *)(pszModuleBase + pDosHead->e_lfanew);
if(Signature != IMAGE_NT_SIGNATURE) //"PE/0/0"
return false;
//定位到PE header
pPEHead = (IMAGE_FILE_HEADER *)(pszModuleBase + pDosHead->e_lfanew + sizeof(DWORD));
int nSizeofOptionHeader;
if(pPEHead->SizeOfOptionalHeader == 0)
nSizeofOptionHeader = sizeof(IMAGE_OPTIONAL_HEADER);
else
nSizeofOptionHeader = pPEHead->SizeOfOptionalHeader;
bool bFind = false;
//跳過PE header和Option Header,定位到Section表位置
pSection = (IMAGE_SECTION_HEADER *)((unsigned char *)pPEHead + sizeof(IMAGE_FILE_HEADER) + nSizeofOptionHeader);
for(int i = 0; i < pPEHead->NumberOfSections; i++)
{
if(!strncmp(szSecName, (const char*)pSection[i].Name,IMAGE_SIZEOF_SHORT_NAME)) //比較段名稱
{
*ppPos = (void *)(pszModuleBase + pSection[i].VirtualAddress);//計算實際虛地址
*lpSize = pSection[i].Misc.VirtualSize;//實際大小
bFind = true;
break;
}
}
return bFind;
}
雖然對CalcRegCode()函數做了很多手腳,但是在程式中對CalcRegCode()函數的使用方式和調用其它的函數沒有區別,只是需要在調用之前對“.scode”段解密。由於本文介紹的方法需要較多的記憶體直接操作,特別是對程式要啟動並執行代碼進行讀寫操作,很可能會引起代碼的異常,比如對代碼解密失敗將導致程式運行不可預料的指令,如果你不想讓你的程式死的很難看,最好使用異常處理。以下就是對CalcRegCode()函數的使用方法:
try
{
bool bFind = GetSectionPointer((void *)hImageBase,".scode",&pSecAddr,&dwSecSize);
if(!bFind || !pSecAddr)
throw "Not find special section!";
//注意,解密和加密函數也是重要的函數,這兩個函數的調用最好放在距離CalcRegCode()函數調用
//遠一點的位置,避免被發現
DecryptBlock(pSecAddr,dwSecSize,0x5A);//首先解密程式碼片段
CalcRegCode("system",szBuff,128);//調用註冊碼計算函數
EncryptBlock(pSecAddr,dwSecSize,0x5A);//調用後加密程式碼片段
}
....//異常處理
到現在為止所有的動態準備工作已經做完,只差最後一道工序,那就是在程式產生之後對“.scode”程式碼片段預先加密。由於編譯器產生的程式碼是不加密的代碼,為了使本文介紹的方法能夠正常使用,必須手工對PE檔案中的“.scode”段進行加密處理。本文的例子代碼中有一個小程式CryptExe.exe,這是個命令列工具,可以加密指定PE檔案的某個位置。剩下的工作就是在磁碟檔案中定位“.scode”段的位移位置。在磁碟檔案中定位“.scode”段和在記憶體映象中定位“.scode”段的方法一樣,也是尋找Section表中的“.scode”段,然後通過段相應的屬性定位這個段在檔案中的位移位置和大小(此時需要訪問的屬性是PointerToRawData和SizeOfRawData)。不過還有更簡單的方式,那就是使用PE檔案查看工具直接查看位移位置和大小,以前面的Section Table為例(圖3),示範程式的“.socde”段在檔案中的位移位置是6000H,大小是1000H,換成成十進位分別是24576和4096,使用以下命令列就可以對示範程式進行初始加密:
CryptExe.exe CrkTest.exe 24576 4096
現在運行CrkTest.exe,會彈出一個OK訊息框,顯示的內容就是根據字串“system”計算出來的註冊碼“tztufn”,如果在CrkTest.exe產生之後忘記了對其進行預先加密,就會出現一個Error訊息框,顯示錯誤資訊。至此,就完整地實現了對真箇程式碼片段進行SMC加密解密的功能。
三、對整個函數體使用SMC方式加密
上一節本文介紹了一種動態加密代碼的方法,就是在程式運行期間對整個程式碼片段進行加密和解密操作,可以保護一些對軟體防破解至關重要的代碼,但是這樣的方法也有一些弊端,那就是需要一個額外的程式碼片段,這有點兒“此地無銀三百兩”的感覺,這個額外的程式碼片段無疑會成為破解者重點“照顧”的對象。這一節本文將介紹一種對某個函數的代碼進行加密解密的方法,這種方法不需要建立額外的程式碼片段,使用上比較隱蔽,不易覺察。
對單個函數進行加密和對整個程式碼片段加密的原理一樣,也需要在記憶體映象和PE檔案中定位代碼的起始位置和代碼塊的大小,只是代碼定位方式不同。首先介紹一下如何在程式的記憶體映象中定位函數的起始位置和函數代碼塊的大小。C/C++語言有一個特性,那就是函數名就代表函數的開始地址,所以根據函數名可以得到代碼塊在記憶體中的位置,剩下的問題就是如何確定函數代碼塊的大小,也就是如何找到函數的最後一條指令的位置。很不幸,對於這個問題除了直接查看彙編代碼之外確實沒有很完美的解決方案,不過,如果我僅僅說:去查看彙編代碼吧,找到最後的ret指令就行了,那就太“不負責任”了,也違背了本文的初衷。“行走江湖”,第一招不行肯定要有“Plan B”,備用方案當然是一些不太“完美”的方法,比如本文使用的方法就是計算與這個函數相鄰的下一個函數的起始位置與這個函數的起始位置的差,這個差值就可大致認為是函數代碼塊的大小。儘管很多資料也都介紹了這種方法,但是這種方法的不完美性還是表現在以下兩個方面:一方面是編譯器不能保證兩個C/C++代碼相鄰的函數在最終產生的機器代碼中也是相鄰的,沒有任何編譯器做了這個承諾,所以使用這種方法是有風險的。另一方面的不完美性是因為這種方法對函數有很多的約束,這種約束體現在編譯器產生代碼的策略上,很多資料對此都有特別的說明,比如函數中最好不要使用longjmp()之類的函數,也不要使用switch...case語句,當然更不能使用異常處理機制了,這是因為當代碼中出現上述情況時,編譯器不能保證產生的程式碼會在一個連續的代碼塊中,特別是異常處理這種情況。儘管這種方法有這樣那樣的不完美性,但它還是得到了廣泛應用,因為對於第一個不完美性,除非出現意外情況,很多編譯器都會儘力做到代碼的連續性,至於第二個不完美性,只要巧妙地構造代碼,避免上述語句的使用,同時合理設定if判斷語句,縮減函數代碼長度,就可以避免長跳轉代碼塊的出現。看來,使用這種方法雖然不是十分安全,不過只要方法得當,也還是值得信賴的,作者在參與的幾個軟體加密項目中都使用了這種方法,目前都能夠可靠地工作,所以,此處推薦使用這種簡單的方法。
現在還是用一個例子來看看具體的效果吧。首先使用VC建立一個基於對話方塊的項目,然後將上一個例子中的CalcRegCode()函數複製到這個項目中,並緊跟其後添加一個空函數,函數類型和名稱隨便,比如:
【未完成...】
代碼下載