用C/C++實現SMC動態代碼加密技術

http://www.uml.org.cn/c%2B%2B/200710313.asp

 

 

用C/C++實現SMC動態代碼加密技術

 

作者:orbit 出處:CSDN

 

摘要:所謂SMC(Self Modifying Code)技術,就是一種將可執行文件中的代碼或數據進行加密,防止別人使用逆向工程工具(比如一些常見的反彙編工具)對程序進行靜態分析的方法,只有程序運行時纔對代碼和數據進行解密,從而正常運行程序和訪問數據。計算機病毒通常也會採用SMC技術動態修改內存中的可執行代碼來達到變形或對代碼加密的目的,從而躲過殺毒軟件的查殺或者迷惑反病毒工作者對代碼進行分析。由於該技術需要直接讀寫對內存中的機器碼,所以多采用彙編語言實現,這使得很多想在自己的程序中使用SMC技術進行軟件加密的C/C++程序員望而卻步。針對這種現狀,本文提出了幾種基於C/C++語言的機器指令定位方法,從而用C/C++語言實現了動態代碼修改技術。

一、什麼是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文件的映象結構:

圖2-1 Windows 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文件具有相似的結構,只是各個段因爲對齊方式的不同而導致偏移位置略有不同,下圖演示了這種差別:

圖2-2 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(可寫)四個屬性的組合。

圖2-3 演示程序的Section Table

有了新的可讀寫代碼段之後的問題就是如何在程序運行期間定位到這個段的位置,並對其進行修改,這就需要知道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()函數複製到這個項目中,並緊跟其後添加一個空函數,函數類型和名稱隨便,比如:

void CalcRegCodeEnd();

然後在程序中就可以通過下面一行程序得到CalcRegCode()函數的長度:

int nFuncSize = ((char *)CalcRegCodeEnd - (char *)CalcRegCode);

不要急着編譯運行這個程序,因爲有個特殊情況需要了解,那就是這行代碼只在Release版本的程序中才能得到正確的結果,因爲Visual C++編譯器生成的Debug版本通常將一些調試信息放在函數代碼開始之前,所以函數開始位置被轉向到了一條跳轉指令jmp(0xE9),這樣VC的調試器就可以根據函數名定位到函數的調試信息,而這條跳轉指令又能保證函數體代碼被正確地執行,真是一舉兩得,但是也給我們的方法帶來了小小的麻煩。不過既然知道原因,就不難想出對策,下面的代碼就是針對這種情況做一下調整,通過簡單的計算得到函數代碼的真正開始位置:

char *pFuncAddr = (char *)CalcRegCode;
if(*((unsigned char*)pFuncAddr) == 0xE9)//判斷是否是跳轉指令
{
pFuncAddr++; //跳過0xE9指令
i =* (int *)pFuncAddr;//這個jmp指令的操作數,也就是跳轉的距離
pFuncAddr += (i + 4); //修正到正確的位置,多加4是因爲這個操作數也是4個字節
}

上面代碼的判斷依據就是函數的第一條指令通常不是跳轉指令(除非程序已經被破解了),調整的方法上面註釋已經有詳細說明,此處就不再贅述。至此,函數在內存映象中的定位問題已經解決,剩下的事情就是如何定位函數開始位置在PE文件中的偏移量,以便我們的外部工具CryptExe.exe能夠對其進行初始加密。不過,這次我們同樣沒有完美的解決方法,更糟糕的是,對於這個問題我們甚至連“不完美”的方法都沒有,那爲什麼還要浪費時間寫這些沒用的東西?因爲我們還有最後一條“救命稻草”,這根所謂的“救命稻草”就是:代碼的內存映象地址和代碼在PE文件中的偏移位置存在線性關係。在本文前面“對代碼段加密”一節曾經提到Windows加載PE可執行文件的方式是一種簡單的按照PE文件字節順序的方式,加載到內存中的PE文件和存放在磁盤上的PE文件具有相似的結構,只是各個段因爲對齊方式的不同而導致段的偏移位置略有不同,這也就是說,這種不同是指的段偏移位置不同,而代碼在段內相對於段首的偏移量是不變的,當鏈接器生成可執行文件時,代碼的段內偏移量就已經固定下來了,並且不會因爲Windows加載可執行文件到內存中的不同位置而改變。也就是說,代碼在內存映象中的地址與PE文件中的偏移量存在以下線性關係:

代碼內存虛擬地址 - 代碼段內存虛擬地址 = 代碼文件偏移量 - 代碼段的文件偏移

轉換這個等式就可以得到文件偏移的計算公式:

代碼文件偏移量 = 代碼內存虛擬地址 - (代碼段內存虛擬地址 - 代碼段的文件偏移)

公式中的“代碼段的文件偏移”就存在與段頭部表中的PointerToRawData屬性,在介紹PE文件的段頭部結構時沒有提到這個屬性,這個屬性給出了段的原始數據在文件中的開始位置,這也是段頭部信息中一個很重要的屬性。“代碼段內存虛擬地址”可以通過段頭部的VirtualAddress屬性獲得,前面已經介紹過,這個屬性是一個相對虛地址,需要加上PE內存映象的基地址纔是代碼段的內存虛擬地址。有了這些信息,就可以很容易地根據虛擬地址計算出PE文件偏移位置,具體的算法和上一節介紹的GetSectionPointer()函數相似,就是遍歷段頭部信息表,找到代碼段“.text”(這是VC編譯器生成的默認代碼段名稱,如果是Borland的編譯器,可能是“CODE”),然後根據PointerToRawData屬性、VirtualAddress屬性和程序的基地址計算出文件偏移位置,下面給出這個計算函數的代碼:

int VAtoFileOffset(void *pModuleBase,void *pVA)
{
IMAGE_DOS_HEADER *pDosHead;
IMAGE_FILE_HEADER *pPEHead;
IMAGE_SECTION_HEADER *pSection;

if(::IsBadReadPtr(pModuleBase,sizeof(IMAGE_DOS_HEADER)) || ::IsBadReadPtr(pVA,4))
return -1;

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 -1;

unsigned char *pszVA = (unsigned char *)pVA;
int nFileOffset = -1;

//定位到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;

//跳過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(".text", (const char*)pSection[i].Name,5)) //比較段名稱
{
//代碼文件偏移量 = 代碼內存虛擬地址 - (代碼段內存虛擬地址 - 代碼段的文件偏移)
nFileOffset = pszVA - (pszModuleBase + pSection[i].VirtualAddress - pSection[i].PointerToRawData);
break;
}
}

return nFileOffset;
}

現在,已經得到了函數代碼在文件中的偏移位置和大小,似乎可以收工慶祝了,不過如果你仔細研究可這種方法就會發現事情沒有那麼簡單,這種方法除了前面提到的幾個不夠“完美”的手段之外,還存在一個重大“缺陷”。好吧,現在就坦白這個“缺陷”,那就是到,代碼在文件中的偏移位置和大小是在自己的程序中計算得到的,外部加密工具CryptExe.exe如何得到這兩個值?這確實是個棘手的問題,不過既然這種對函數代碼的加密方式已經有這麼多的“不完美”,也不在乎再多一個,本文采用的方法是給程序加一個隱蔽功能,這個隱蔽功能不向用戶公開,開發者利用這個隱蔽功能獲取這兩個重要的參數。這看起來很“ungainly”,不過現在在軟件中添加隱蔽功能的的軟件也不少啊,想到這裏應該可以心理平衡一點了吧。本文的例子程序將這個隱蔽功能添加到“關於...”對話框中,只要在打開“關於...”對話框的時候按住鍵盤右側的“Ctrl”鍵,此時顯示出來的關於對話框就會多兩個控件,分別顯示函數代碼塊在PE文件中的偏移位置和代碼塊長度,所用的方法就是通過計算函數地址得到函數代碼塊的長度,通過VAtoFileOffset()函數計算出函數代碼塊在PE文件中的偏移位置。本例程序運行結果如圖3-1所示:

圖3-1 通過“關於...”對話框獲得文件偏移位置

然後就可以使用如下命令對例子程序CrkTest3.exe中的CalcRegCode()函數進行加密:

CryptExe.exe CrkTest3.exe 5584 112

下面就是加密後的CalcRegCode()函數在某反彙編工具中顯示的代碼,因爲代碼已經經過異或加密,而反彙編工具不知道,還按照加密後的機器碼生成彙編代碼,所以結果看起來很奇怪,當然也不能正常運行。

圖3-2 加密後的CalcRegCode()函數代碼

在程序中使用CalcRegCode()函數的方法和上一節介紹的方法一樣,就是先調用DecryptBlock()對函數代碼塊解密,然後正常調用CalcRegCode()函數,最後再調用EncryptBlock()加密,當然,異常處理也是必不可少的。除此之外,還有一些細節需要注意,首先是代碼段的讀寫問題,上一節的例子創建了一個單獨的數據段,編譯器創建這個段的時候就爲其指定了“可寫”屬性,所以沒有特別強調對段屬性的修改,但是這一節的例子是直接修改“.text”段的代碼,而這個段默認屬性是隻讀的,所以需要考慮段屬性的修改問題。有兩種方法可以解決這個問題,一種是採用上一節介紹的方法,使用以下預處理指令爲“.text”段添加“可寫”屬性:

#pragma comment(linker, "/SECTION:.text,ERW")

另一種方法是在程序生成以後使用peeditor之類的工具修改“.text”的段屬性,涉及到具體的軟件操作,這裏就不再贅述。下面來介紹一下使用這種方法的注意事項,首先要注意的就是CalcRegCodeEnd()函數的設計,這個函數看似無關緊要,但是它卻有一個檢查加密是否越界的功能,最好不要將其設計成空函數,而是爲其填充一些有意義的代碼,這樣就可以在隱蔽功能中調用這個函數,如果這個函數出現代碼異常就說明通過函數地址方法計算出來的函數代碼塊大小不正確,導致這個函數受到破壞,這時就要重新設計CalcRegCode()函數的結構。另一個需要注意的地方是編譯器的優化方式,很多C/C++編譯器都提供了優化代碼的功能,比如VC的編譯器就有對生成代碼大小和運行速度進行優化的選項,這裏需要提醒大家的是儘量不要使用對可執行文件代碼大小進行優化的選項,理由很簡單,編譯器對代碼大小進行優化的時候可能影響到生成代碼的連續性,本節提到的這種對函數代碼塊加密的方法很依賴於代碼塊的連續性,所以,儘量不要使用減少可執行文件大小的優化選項。最後一個需要注意的事項就是每次編譯程序後都要重新通過隱蔽功能重新獲得偏移位置,因爲編譯代碼就涉及到重新生成機器代碼,當然會影響到代碼的位置。只要編譯完成以後,這個偏移位置就固定下了,即使程序加載過程中被Windows重定位了,也不會影響到計算出來的偏移位置,因爲本方法計算採用的內存虛擬地址就是根據程序的內存映象基地址計算出來的,基地址變了,這個虛擬地址也會跟着改變。況且很多EXE文件都沒有地址重定位段,Windows默許這種情況出現就意味着Windows會盡力保證將程序加載到默認的基地址位置,不過對於DLL(動態鏈接庫)來說就沒有這麼幸運了,它在加載的時候被重定位幾乎是“家常便飯”,雖然從理論上講DLL的加載方式和EXE的加載方式沒有區別,但是本人依然不推薦在DLL中使用這種方法。

四、對代碼塊使用SMC方式加密

本節將在前面介紹的方法的基礎上,再介紹一種直接對函數內的代碼塊(片斷)進行加密的方法,這種方法將更加隱蔽,使破解者更難以定位。通過圖3-2可以看出,加密後的函數代碼與沒有加密的函數代碼有明顯的不同,主要使函數入口部分的代碼,通常正常的函數入口部分代碼是棧操作,比如基地址寄存器(ebp)的值入棧,爲局部變量預置空間等等,破解者看到如圖3-2那樣的函數代碼,肯定可以判斷出這個函數已經被做過手腳了。如果能夠避開函數入口處和結尾部分的敏感代碼,直接修改函數內部某個位置的代碼片斷,就可以極大地提高隱蔽性。直接對代碼塊加密的難點在於代碼塊的定位,本文前面介紹的兩種方法可以通過段屬性或函數地址定位到代碼塊的開始位置,但是直接對任意函數內的代碼塊進行加密,無論是在內存映象中還是在PE文件中都沒有很好的方法可以用來定位代碼塊的開始位置。當然,沒有好的方法並不等於沒有方法,人們在實踐中還是探索出了一些比較實用的方法。目前最廣泛採用的方法是使用對某個特徵代碼序列的查找來定位代碼塊的開始位置,就是在程序設計的過程中,人爲地構造一個特殊的代碼序列,編譯器會根據這個代碼序列生成相應的機器碼,然後就可以在內存映象中或文件中搜索一段機器代碼序列,從而實現代碼片斷的定位。可見,這種方法的重點就是構造一個代碼序列,所謂的代碼序列就是一條或幾條連續的代碼,這些代碼應該不具備一般性,也就是越特殊,程序的其它地方越不容易出現重複越好,目的是爲了防止查找使出現多重匹配,不能唯一定位到人爲構造的代碼序列的位置。

以往的資料在介紹這種方法的時候多采用彙編語言爲例子,構造特徵代碼序列也是使用匯編代碼,這是因爲從彙編語言到機器語言比較直觀,而且基本上是一一對應,不會產生變形,同時也便於設計人員通過彙編代碼手工翻譯出機器代碼。象C/C++這樣的高級語言,編譯器根據C/C++代碼生成機器代碼的過程中需要經過多個步驟,這中間有很多不確定因素都可能導致編譯器不能產生預想的機器代碼序列,這也就是大家都“不約而同”地採用彙編語言的原因。但是,這並不說明就不能使用C/C++構造特徵代碼序列,使用嵌入式彙編就是最簡單的方法,如果對彙編語言不瞭解,或者擔心嵌入的彙編代碼影響了寄存器的正常使用,也可以簡單地使用_emit指令在當前代碼位置嵌入一些特殊的數據構成特徵數據組,特徵數據組和特徵代碼序列一樣可以通過查找的方法定位代碼塊的位置。下面的例子就是使用嵌入式彙編在當前代碼中添加了一個字符串“HelloWorld/0”:
__asm
{
_emit 'H';
_emit 'e';
_emit 'l';
_emit 'l';
_emit 'o';
_emit 'W';
_emit 'o';
_emit 'r';
_emit 'l';
_emit 'd';
_emit 0x00;
}
在文件中定位這個位置時就可以使用一些16進制編輯器在文件中查找這個特徵字符串,找到開始位置後向後偏移11個字節就是代碼塊的開始位置。不過本節介紹的方法不使用嵌入式構造特徵代碼序列,而是利用C/C++語言中與彙編語言對應性最好的賦值語句實現了一種特徵代碼構造方法。

C/C++語言中將常數賦值給某個變量的簡單賦值語句,通常可以被翻譯成一條簡單的彙編代碼,以下面的C/C++代碼爲例:
DWORD dwSignVar = 0;//定義一個全局變量
dwSignVar = 0x5A5A5A5A;

這條賦值語句彙編成機器代碼後就是:
mov DWORD PTR [AAAAAAAAH], 5A5A5A5AH

最終生成的機器碼就是:C7 05 AA AA AA AA 5A 5A 5A 5A,C7 05是mov指令的機器碼,緊跟其後的四個字節是mov指令的第一個操作數,就是變量的dwSignVar的地址AAAAAAAA,再後面的四個字節是mov指令的第二個操作數,也就是常數0x5A5A5A5A。如果在需要加密的關鍵代碼塊的開始位置和結束位置使用幾條這樣精心構造的賦值語句,就可以在關鍵代碼塊前後各形成一個比較長的特徵代碼序列,從而實現代碼塊的查找定位。這種使用C/C++語言構在特徵代碼序列的方法同樣有很多需要注意的地方,首先是變量要使用全局變量,因爲通常將全局變量安排在數據段,這樣可以保證程序被加載到內存中執行的時候它的虛擬地址是固定的,這一點很重要,因爲這個地址(就是上面例子中的AA AA AA AA)是特徵代碼序列的重要組成部分,它必須是固定的,不隨程序每次加載運行而改變。其次是賦值語句的使用數量問題,一般連續的一至兩條賦值語句就可以了,如果太多反而會起副作用,這是因爲編譯器進行代碼優化的時候爲了對寄存器訪問進行優化,通常會調整代碼的順序,這樣就很可能在我們的賦值語句中間插入其它代碼,從而影響特徵代碼序列。最後一點需要注意的是必須是直接常數賦值,這個常數不能使用變量替代,也就是說不能用一個值是0x5A5A5A5A的變量代替這個常數,因爲那樣會導致mov指令的第二個操作數發生變化,無法得到預期的mov指令。

剩下的問題就是如何定位代碼塊的位置以及如何將代碼塊的文件偏移位置顯示出來。定位代碼塊的位置很簡單,就是從一個指定的內存位置開始,搜索特徵字符串,因爲特徵代碼塊一般是位於某個函數內部,所以通常從一個函數的開始位置搜索特徵代碼序列。例子中的函數FindCodeTag()就是負責在一塊內存區域中定位特徵代碼序列的位置:
int FindCodeTag(void *pStartAddr, unsigned long *pTagLoc, unsigned long lTagValue, int nSerachLength)
{
int nPos = -1;
int i = 0;
unsigned char *pAddr = (unsigned char *)pStartAddr;
while(i < nSerachLength)
{
if((*pAddr == 0xC7) && (*(pAddr + 1) == 0x05))//查找mov指令
{
unsigned long *Loc = (unsigned long *)((unsigned char*)pAddr + 2);
if(*Loc == (unsigned long)pTagLoc)//此處的數據*Loc就是全局靜態變量的地址
{
unsigned long *Val = (unsigned long *)((unsigned char*)pAddr + 6);
if(*Val == lTagValue)//此處的數據*Val就是常數lTagValue值
{
nPos = i;
break;//find tag
}
}
}
pAddr++;
i++;
}

return nPos;
}

第一個參數pStartAddr是開始位置,第二個參數就是賦值語句中使用的全局變量的地址,第三個常數是賦值語句中常數的值,最後一個參數是搜索區間的長度,如果從pStartAddr開始超過nSerachLength長度的區域中沒有找到特徵代碼序列,就返回-1表示沒有找到,否則就返回特徵代碼序列現對於pStartAddr的偏移量。如果在某個函數內部使用瞭如下賦值語句作爲特徵代碼序列:
void SomeFunction()
{
......
dwSignVar = 0x5A5A5A5A;
......//關鍵代碼塊
dwSignVar2 = 0x61616161;
}

那麼就可以這樣找到它的開始位置:
int nStartPos = FindCodeTag((void *)SomeFunction,&dwSignVar,0x5A5A5A5A,1000);//1000是個大致估計的值
nStartPos += 10;//10 是特徵代碼序列(也就是mov指令)的長度
返回值只是特徵代碼序列的開始位置,還要向後偏移10各字節(這條mov指令的長度)纔是代碼塊的真正開始位置,這裏的搜索長度1000只是一個估計值,也可以使用上一節介紹的方法通過函數地址差值計算出搜索長度的大小。同樣的方法可以得到另一個特徵代碼序列的開始位置(也就是關鍵代碼塊的結束位置):
int nEndPos = FindCodeTag((void *)SomeFunction,&dwSignVar2,0x61616161,1000);
計算nEndPos和nStartPos的差值就是關鍵代碼塊的大小。從內存地址計算出文件偏移位置的方法和上一節介紹的方法一樣,使用VAtoFileOffset()函數計算出這個偏移量,然後使用“關於...”對話框的隱蔽功能顯示給軟件開發人員。其它問題,比如異常處理、Debug版函數地址修正以及修改代碼段的讀寫屬性等等問題都已經在上一節介紹了,此處不再贅述,具體內容可參考CrkTest2的例子代碼,演示程序CrkTest2的使用方法和上一節的例子程序CrkTest3類似,程序編譯完成以後要使用CryptExe.exe對關鍵代碼加密,否則會出現指令異常。

五、總結

本文介紹了三種使用SMC動態修改代碼技術實現的代碼加密方法,這些方法採用動靜結合的方式,通過對可執行程序文件的靜態加密,提高了程序反靜態分析的能力,在運行過程中對裝載到內存中的可執行程序代碼進行動態修改,對動態反跟蹤,反調試也很有幫助,如果能夠在程序中合理地應用這些方法,可以提高軟件的安全性,增加破解難度。本文還使用具體的例子程序演示了每種方法的具體使用,這裏例子都是用C/C++語言實現,大大降低了程序員在自己的軟件中使用這些技術的門檻。

關於演示程序代碼

演示程序的代碼有四部分組成,CryptExe是外部加密工具CryptExe.exe的源代碼,CrkTest是第二節介紹的對整個代碼段加密的演示程序,CrkTest3是對函數進行加密的演示程序,CrkTest2是對內部代碼片斷進行修改加密的演示程序,所有的代碼都在VC6和Visual Studio 2003下編譯測試通過,演示程序的使用也很簡單,首先編譯生成演示程序(如果是CrkTest2和CrkTest3,請使用Release方式生成程序,如果要在Debug版本中使用,請參考本文第三節介紹的方法修正函數地址),然後按照本文介紹的方法使用CryptExe.exe工具加密生成的應用程序,最後就可以運行演示程序看結果了。對這些演示程序感興趣的朋友還可以訪問以下鏈接獲取代碼的最新修改和勘誤:
http://blog.csdn.net/orbit/

參考文獻

[1] 段剛.軟件加密技術內幕.北京:電子工業出版社,2004.

[2] Matt Pietrek.Peering Inside the PE: A Tour of the Win32 Portable Executable File Format.MSDN Magazine,1994

發佈了10 篇原創文章 · 獲贊 3 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章