本文對於SSDT的原理和作用進行細緻入微的解釋和說明,並且配以大量實例,相信您定會有所收穫!
什麼是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:
- mov edx, esp
- sysenter
上面是我的XP Professional sp2中ntdll.dll的反彙編結果,如果你用的是2000系統,那麼可能是這個樣子:
- 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的數據結構定義如下:
- 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的分發代碼實現如下面的代碼所示,沒有什麼技術含量,所以不再解釋。C++代碼- 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,不過我們可以自己手動添加之:
C++代碼- extern PSSDT KeServiceDescriptorTable;
——當然,如果你對DDK開發實在不感興趣的話,亦可以直接使用配套代碼壓縮包中的SSDTDump.sys,並使用DeviceIoControl發送IOCTL_GETSSDT和IOCTL_GETPROC控制碼即可;或者,直接調用我爲你準備好的兩個函數:
C++代碼- 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 %s 0x%08X %s ", entry.dwIndex,
- strModule, ulAddr, pszName );
- }
- }
- }
- DestroyModuleList( pList );
- }
下圖是示例程序SSDTDump在XP sp2上的部分運行截圖,顯示了SSDT的基地址、服務個數,以及各個表項所對應的服務號、所在模塊、地址和服務名。
結語
ring3與ring0,城裏與城外之間爲一道嘆息之牆所間隔,SSDT則是越過此牆的一道必經之門。因此,很多殺毒軟件也勢必會圍繞着它大做文章。無論是System Safety Monitor的系統監控,還是卡巴斯基的主動防禦,都是掛鉤了SSDT。這樣,病毒尚在ring3內發作之時,便被扼殺於搖籃之內。
內核最高權限,本就是兵家必爭之地,魔高一尺道高一丈的爭奪於此亦已變成頗爲稀鬆平常之事。可以說和這些爭奪比起來,SSDT的相關技術簡直不值一提。但最初發作的病毒體總是從ring3開始的——換句話說,任你未來會成長爲何等的武林高手,我都可以在你學走路的時候殺掉你——知曉了SSDT的這點優勢,所有的病毒咂吧咂吧也就都沒味兒了。所以說麼,殺毒莫如防毒。
——就此打住罷,貌似扯遠大發了。