城里城外看SSDT


引子

2006年,中國互聯網上的斗爭硝煙彌漫。這時的戰場上,先前頗為流行的窗口掛鈎、API掛鈎、進程注入等技術已然成為昨日黃花,大有逐漸淡出之勢;取而代之的,則是更狠毒、更為赤裸裸的詞匯:驅動、隱藏進程、Rootkit……
前不久,我不經意翻出自己2005年9月寫下的一篇文章《DLL的遠程注入技術》,在下面看到了一位名叫L4bm0s的網友說這種技術已經過時了。雖然我也曾想過擬出若干辯解之詞聊作應對,不過最終還是作罷了——畢竟,拿出些新的、有技術含量的東西才是王道。於是這一次,李馬首度從ring3(應用層)的圍城跨出,一躍而投身於ring0(內核層)這一更廣闊的天地,便有了這篇《城里城外看SSDT》。——顧名思義,城里和城外的這一牆之隔,就是ring3與ring0的分界。
在這篇文章里,我會用到太多雜七雜八的東西,比如匯編,比如內核調試器,比如DDK。這誠然是一件令我瞻前顧后畏首畏尾的事情——一方面在ring0我不得不依靠這些東西,另一方面我實在擔心它們會導致我這篇文章的閱讀門檻過高。所以,我決定盡可能少地涉及驅動、內核與DDK,也不會對諸如如何使用內核調試器等問題作任何講解——你只需要知道我大概在做些什么,這就足夠了。

什么是SSDT?

什么是SSDT?自然,這個是我必須回答的問題。不過在此之前,請你打開命令行(cmd.exe)窗口,並輸入“dir”並回車——好了,列出了當前目錄下的所有文件和子目錄。
那么,以程序員的視角來看,整個過程應該是這樣的:

由用戶輸入dir命令。 cmd.exe獲取用戶輸入的dir命令,在內部調用對應的Win32 API函數FindFirstFile、FindNextFile和FindClose,獲取當前目錄下的文件和子目錄。 cmd.exe將文件名和子目錄輸出至控制台窗口,也就是返回給用戶。

到此為止我們可以看到,cmd.exe扮演了一個非常至關重要的角色,也就是用戶與Win32 API的交互。——你大概已經可以猜到,我下面要說到的SSDT亦必將扮演這個角色,這實在是一點新意都沒有。
沒錯,你猜對了。SSDT的全稱是System Services Descriptor Table,系統服務描述符表。這個表就是一個把ring3的Win32 API和ring0的內核API聯系起來的角色,下面我將以API函數OpenProcess為例說明這個聯系的過程。
你可以用任何反匯編工具來打開你的kernel32.dll,然后你會發現在OpenProcess中有類似這樣的匯編代碼:

匯編代碼
call ds:NtOpenProcess  

這就是說,OpenProcess調用了ntdll.dll的NtOpenProcess函數。那么繼續反匯編之,你會發現ntdll.dll中的這個函數很短:

匯編代碼
mov eax, 7Ah   mov edx, 7FFE0300h   call dword ptr [edx]   retn 10h  

另外,call的一句實質是調用了KiFastSystemCall:

C++代碼
mov edx, esp   sysenter  

上面是我的XP Professional sp2中ntdll.dll的反匯編結果,如果你用的是2000系統,那么可能是這個樣子:

C++代碼
mov eax, 6Ah   lea edx, [esp+4]   int 2Eh   retn 10h  

雖然它們存在着些許不同,但都可以這么來概括:

把一個數放入eax(XP是0x7A,2000是0x6A),這個數值稱作系統的服務號。 把參數堆棧指針(esp+4)放入edx。 sysenter或int 2Eh。

好了,你在ring3能看到的東西就到此為止了。事實上,在ntdll.dll中的這些函數可以稱作真正的NT系統服務的存根(Stub)函數。分隔ring3與ring0城里城外的這一道嘆息之牆,也正是由它們打通的。接下來SSDT就要出場了,come some music。

站在城牆看城外

插一句先,貌似到現在為止我仍然沒有講出來SSDT是個什么東西,真正可以算是“猶抱琵琶半遮面”了。——書接上文,在你調用sysenter或int 2Eh之后,Windows系統將會捕獲你的這個調用,然后進入ring0層,並調用內核服務函數NtOpenProcess,這個過程如下圖所示。

SSDT在這個過程中所扮演的角色是至關重要的。讓我們先看一看它的結構,如下圖。

當程序的處理流程進入ring0之后,系統會根據服務號(eax)在SSDT這個系統服務描述符表中查找對應的表項,這個找到的表項就是系統服務函數NtOpenProcess的真正地址。之后,系統會根據這個地址調用相應的系統服務函數,並把結果返回給ntdll.dll中的NtOpenProcess。圖中的“SSDT”所示即為系統服務描述符表的各個表項;右側的“ntoskrnl.exe”則為Windows系統內核服務進程(ntoskrnl即為NT OS KerneL的縮寫),它提供了相對應的各個系統服務函數。ntoskrnl.exe這個文件位於Windows的system32目錄下,有興趣的朋友可以反匯編一下。
附帶說兩點。根據你處理器的不同,系統內核服務進程可能也是不一樣的。真正運行於系統上的內核服務進程可能還有ntkrnlmp.exe、ntkrnlpa.exe這樣的情況——不過為了統一起見,下文仍統稱這個進程為ntoskrnl.exe。另外,SSDT中的各個表項也未必會全部指向ntoskrnl.exe中的服務函數,因為你機器上的殺毒監控或其它驅動程序可能會改寫SSDT中的某些表項——這也就是所謂的“掛鈎SSDT”——以達到它們的“主動防御”式殺毒方式或其它的特定目的。

KeServiceDescriptorTable

事實上,SSDT並不僅僅只包含一個龐大的地址索引表,它還包含着一些其它有用的信息,諸如地址索引的基地址、服務函數個數等等。ntoskrnl.exe中的一個導出項KeServiceDescriptorTable即是SSDT的真身,亦即它在內核中的數據實體。SSDT的數據結構定義如下:

C++代碼
typedef struct _tagSSDT {       PVOID pvSSDTBase;       PVOID pvServiceCounterTable;       ULONG ulNumberOfServices;       PVOID pvParamTableBase;   } SSDT, *PSSDT;  

其中,pvSSDTBase就是上面所說的“系統服務描述符表”的基地址。pvServiceCounterTable則指向另一個索引表,該表包含了每個服務表項被調用的次數;不過這個值只在Checkd Build的內核中有效,在Free Build的內核中,這個值總為NULL(注:Check/Free是DDK的Build模式,如果你只使用SDK,可以簡單地把它們理解為Debug/Release)。ulNumberOfServices表示當前系統所支持的服務個數。pvParamTableBase指向SSPT(System Service Parameter Table,即系統服務參數表),該表格包含了每個服務所需的參數字節數。
下面,讓我們開看看這個結構里邊到底有什么。打開內核調試器(以kd為例),輸入命令顯示KeServiceDescriptorTable,如下。

WinDbg輸出
lkd> dd KeServiceDescriptorTable l4   8055ab80 804e3d20 00000000 0000011c 804d9f48  

接下來,亦可根據基地址與服務總數來查看整個服務表的各項:

WinDbg輸出
lkd> dd 804e3d20 l11c   804e3d20 80587691 f84317aa f84317b4 f84317be   804e3d30 f84317c8 f84317d2 f84317dc f84317e6   804e3d40 8057741c f84317fa f8431804 f843180e   804e3d50 f8431818 f8431822 f843182c f8431836   ...  

你獲得的結果可能和我會有不同——我指的是那堆以十六進制f開頭的地址項,因為我的SSDT被System Safety Monitor接管了,沒留下幾個原生的ntoskrnl.exe表項。
現在是寫些代碼的時候了。KeServiceDescriptorTable及SSDT各個表項的讀取只能在ring0層完成,於是這里我使用了內核驅動並借助DeviceIoControl來完成。其中DeviceIoControl的分發代碼實現如下面的代碼所示,沒有什么技術含量,所以不再解釋。

switch ( IoControlCode ) 
{
case IOCTL_GETSSDT:
    {
        __try
        {
            ProbeForWrite( OutputBuffer, sizeof( SSDT ), sizeof( ULONG ) );
            RtlCopyMemory( OutputBuffer, KeServiceDescriptorTable, sizeof( SSDT ) );
        }
        __except ( EXCEPTION_EXECUTE_HANDLER )
        {
            IoStatus->Status = GetExceptionCode();
        }
    }
    break;
case IOCTL_GETPROC:
    {
        ULONG uIndex = 0;
        PULONG pBase = NULL;

        __try
        {
            ProbeForRead( InputBuffer, sizeof( ULONG ), sizeof( ULONG ) );
            ProbeForWrite( OutputBuffer, sizeof( ULONG ), sizeof( ULONG ) );
        }
        __except( EXCEPTION_EXECUTE_HANDLER )
        {
            IoStatus->Status = GetExceptionCode();
            break;
        }

        uIndex = *(PULONG)InputBuffer;
        if ( KeServiceDescriptorTable->ulNumberOfServices <= uIndex )
        {
            IoStatus->Status = STATUS_INVALID_PARAMETER;
            break;
        }
        pBase = KeServiceDescriptorTable->pvSSDTBase;
        *((PULONG)OutputBuffer) = *( pBase + uIndex );
    }
    break;
// ...



補充一下,再。DDK的頭文件中有一件很遺憾的事情,那就是其中並未聲明KeServiceDescriptorTable,不過我們可以自己手動添加之:

extern PSSDT KeServiceDescriptorTable; 


——當然,如果你對DDK開發實在不感興趣的話,亦可以直接使用配套代碼壓縮包中的SSDTDump.sys,並使用DeviceIoControl發送IOCTL_GETSSDT和IOCTL_GETPROC控制碼即可;或者,直接調用我為你准備好的兩個函數:

BOOL GetSSDT( IN HANDLE hDriver, OUT PSSDT buf );
BOOL GetProc( IN HANDLE hDriver, IN ULONG ulIndex, OUT PULONG buf ); 


獲取詳細模塊信息

雖然我們現在可以獲取任意一個服務號所對應的函數地址了已經,但是你可能仍然不滿意,認為只有獲得了這個服務函數所在的模塊才是王道。換句話說,對於一個干凈的SSDT表來說,它里邊的表項應該都是指向ntoskrnl.exe的;如果SSDT之中有若干個表項被改寫(掛鈎),那么我們應該知道是哪一個或哪一些模塊替換了這些服務。

首先我們需要獲得當前在ring0層加載了那些模塊。如我在本文開頭所說,為了盡可能地少涉及ring0層的東西,於是在這里我使用了ntdll.dll的NtQuerySystemInformation函數。關鍵代碼如下:

typedef struct _SYSTEM_MODULE_INFORMATION { 
    ULONG Reserved[2]; 
    PVOID Base; 
    ULONG Size; 
    ULONG Flags; 
    USHORT Index; 
    USHORT Unknown; 
    USHORT LoadCount; 
    USHORT ModuleNameOffset; 
    CHAR ImageName[256]; 
} SYSTEM_MODULE_INFORMATION, *PSYSTEM_MODULE_INFORMATION; 

typedef struct _tagSysModuleList {
    ULONG ulCount;
    SYSTEM_MODULE_INFORMATION smi[1];
} SYSMODULELIST, *PSYSMODULELIST;

s = NtQuerySystemInformation( SystemModuleInformation, pRet,
    sizeof( SYSMODULELIST ), &nRetSize );
if ( STATUS_INFO_LENGTH_MISMATCH == s )
{
    // 緩沖區太小,重新分配
    delete pRet;
    pRet = (PSYSMODULELIST)new BYTE[nRetSize];
    s = NtQuerySystemInformation( SystemModuleInformation, pRet,
        nRetSize, &nRetSize ); 



需要說明的是,這個函數是利用內核的PsLoadedModuleList鏈表來枚舉系統模塊的,因此如果你遇到了能夠隱藏驅動的Rootkit,那么這種方法是無法找到被隱藏的模塊的。在這種情況下,枚舉系統的“\Driver”目錄對象可能可以更好解決這個問題,在此不再贅述了就。

接下來,是根據SSDT中的地址表項查找模塊。有了SYSTEM_MODULE_INFORMATION結構中的模塊基地址與模塊大小,這個工作完成起來也很容易:

BOOL FindModuleByAddr( IN ULONG ulAddr, IN PSYSMODULELIST pList,
                      OUT LPSTR buf, IN DWORD dwSize )
{
    for ( ULONG i = 0; i < pList->ulCount; ++i )
    {
        ULONG ulBase = (ULONG)pList->smi[i].Base;
        ULONG ulMax  = ulBase + pList->smi[i].Size;
        if ( ulBase <= ulAddr && ulAddr < ulMax )
        {
            // 對於路徑信息,截取之
            PCSTR pszModule = strrchr( pList->smi[i].ImageName, '\\' );
            if ( NULL != pszModule )
            {
                lstrcpynA( buf, pszModule + 1, dwSize );
            }
            else
            {
                lstrcpynA( buf, pList->smi[i].ImageName, dwSize );
            }
            return TRUE;
        }
    }
    return FALSE;



詳細枚舉系統服務項

到現在為止,還遺留有一個問題,就是獲得服務號對應的服務函數名。比如XP下0x7A對應着NtOpenProcess,但是到2000下,NtOpenProcess就改為0x6A了。

——有一個好消息一個壞消息,你先聽哪個?

——什么壞消息?

——Windows並沒有給我們開放這樣現成的函數,所有的工作都需要我們自己來做。

——那好消息呢?

——牛糞有的是。

壞了,串詞兒了。好消息是我們可以通過枚舉ntdll.dll的導出函數來間接枚舉SSDT所有表項所對應的函數,因為所有的內核服務函數對應於ntdll.dll的同名函數都是這樣開頭的:

mov eax, <ServiceIndex> 


對應的機器碼為:

B8 <ServiceIndex> 


再說一遍:非常幸運,僅就我手頭上的2000 sp4、XP、XP sp1、XP sp2、2003的ntdll.dll而言,無一例外。不過Mark Russinovich的《深入解析Windows操作系統》一書中指出,IA64的調用方式與此不同——由於手頭上沒有相應的文件,所以在這里不進行討論了就。

接着說。我們可以把mov的一句用如下的一個結構來表示:

#pragma pack( push, 1 )
typedef struct _tagSSDTEntry {
    BYTE  byMov;   // 0xb8
    DWORD dwIndex;
} SSDTENTRY;
#pragma pack( pop ) 


那么,我們可以對ntdll.dll的所有導出函數進行枚舉,並篩選出“Nt”開頭者,以SSDTENTRY的結構取出其開頭5個字節進行比對——這就是整個的枚舉過程。相關的PE文件格式解析我不再解釋,可參考注釋。整個代碼如下:

#define MOV        0xb8

void EnumSSDT( IN HANDLE hDriver, IN HMODULE hNtDll )
{
    DWORD dwOffset                  = (DWORD)hNtDll;
    PIMAGE_EXPORT_DIRECTORY pExpDir = NULL;
    int nNameCnt                    = 0;
    LPDWORD pNameArray              = NULL;
    int i                           = 0;

    // 到PE頭部
    dwOffset += ((PIMAGE_DOS_HEADER)hNtDll)->e_lfanew + sizeof( DWORD );
    // 到第一個數據目錄
    dwOffset += sizeof( IMAGE_FILE_HEADER ) + sizeof( IMAGE_OPTIONAL_HEADER )
        - IMAGE_NUMBEROF_DIRECTORY_ENTRIES * sizeof( IMAGE_DATA_DIRECTORY );
    // 到導出表位置
    dwOffset = (DWORD)hNtDll
        + ((PIMAGE_DATA_DIRECTORY)dwOffset)->VirtualAddress;
    pExpDir = (PIMAGE_EXPORT_DIRECTORY)dwOffset;

    nNameCnt = pExpDir->NumberOfNames;
    // 到函數名RVA數組
    pNameArray = (LPDWORD)( (DWORD)hNtDll + pExpDir->AddressOfNames );

    // 初始化系統模塊鏈表
    PSYSMODULELIST pList = CreateModuleList( hNtDll );

    // 循環查找函數名
    for ( i = 0; i < nNameCnt; ++i )
    {
        PCSTR pszName = (PCSTR)( pNameArray[i] + (DWORD)hNtDll );
        if ( 'N' == pszName[0] && 't' == pszName[1] )
        {
            // 找到了函數,則定位至查找表
            LPWORD pOrdNameArray = (LPWORD)( (DWORD)hNtDll + pExpDir->AddressOfNameOrdinals );
            // 定位至總表
            LPDWORD pFuncArray   = (LPDWORD)( (DWORD)hNtDll + pExpDir->AddressOfFunctions );
            LPCVOID pFunc        = (LPCVOID)( (DWORD)hNtDll + pFuncArray[pOrdNameArray[i]] );
            
            // 解析函數,獲取服務名
            SSDTENTRY entry;
            CopyMemory( &entry, pFunc, sizeof( SSDTENTRY ) );
            if ( MOV == entry.byMov )
            {
                ULONG ulAddr = 0;
                GetProc( hDriver, entry.dwIndex, &ulAddr );

                CHAR strModule[MAX_PATH] = "[Unknown Module]";
                FindModuleByAddr( ulAddr, pList, strModule, MAX_PATH );
                printf( "0x%04X\t%s\t0x%08X\t%s\r\n", entry.dwIndex,
                    strModule, ulAddr, pszName );
            }
        }
    }

    DestroyModuleList( pList );



下圖是示例程序SSDTDump在XP sp2上的部分運行截圖,顯示了SSDT的基地址、服務個數,以及各個表項所對應的服務號、所在模塊、地址和服務名。
下圖是示例程序SSDTDump在XP sp2上的部分運行截圖,顯示了SSDT的基地址、服務個數,以及各個表項所對應的服務號、所在模塊、地址和服務名。

結語

ring3與ring0,城里與城外之間為一道嘆息之牆所間隔,SSDT則是越過此牆的一道必經之門。因此,很多殺毒軟件也勢必會圍繞着它大做文章。無論是System Safety Monitor的系統監控,還是卡巴斯基的主動防御,都是掛鈎了SSDT。這樣,病毒尚在ring3內發作之時,便被扼殺於搖籃之內。
內核最高權限,本就是兵家必爭之地,魔高一尺道高一丈的爭奪於此亦已變成頗為稀松平常之事。可以說和這些爭奪比起來,SSDT的相關技術簡直不值一提。但最初發作的病毒體總是從ring3開始的——換句話說,任你未來會成長為何等的武林高手,我都可以在你學走路的時候殺掉你——知曉了SSDT的這點優勢,所有的病毒咂吧咂吧也就都沒味兒了。所以說么,殺毒莫如防毒。
——就此打住罷,貌似扯遠大發了。

附件:ssdtdump.zip


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com