SSDT詳解

本文對於SSDT的原理和作用進行細緻入微的解釋和說明,並且配以大量實例,相信您定會有所收穫!

什麼是SSDT?

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

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

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

彙編代碼
  1. call ds:NtOpenProcess  

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

彙編代碼
  1. mov eax, 7Ah   
  2. mov edx, 7FFE0300h   
  3. call dword ptr [edx]   
  4. retn 10h  

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

C++代碼
  1. mov edx, esp   
  2. sysenter  

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

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

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

  1. 把一個數放入eax(XP是0x7A,2000是0x6A),這個數值稱作系統的服務號。
  2. 把參數堆棧指針(esp+4)放入edx。
  3. 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++代碼
  1. typedef struct _tagSSDT {   
  2.     PVOID pvSSDTBase;   
  3.     PVOID pvServiceCounterTable;   
  4.     ULONG ulNumberOfServices;   
  5.     PVOID pvParamTableBase;   
  6. } SSDT, *PSSDT;  
  7. 其中,pvSSDTBase就是上面所說的“系統服務描述符表”的基地址。pvServiceCounterTable則指向另一個索引表,該表包含了每個服務表項被調用的次數;不過這個值只在Checkd Build的內核中有效,在Free Build的內核中,這個值總爲NULL(注:Check/Free是DDK的Build模式,如果你只使用SDK,可以簡單地把它們理解爲Debug/Release)。ulNumberOfServices表示當前系統所支持的服務個數。pvParamTableBase指向SSPT(System Service Parameter Table,即系統服務參數表),該表格包含了每個服務所需的參數字節數。
    下面,讓我們開看看這個結構裏邊到底有什麼。打開內核調試器(以kd爲例),輸入命令顯示KeServiceDescriptorTable,如下。

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

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

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

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

    C++代碼
    1. switch ( IoControlCode )   
    2. {   
    3. case IOCTL_GETSSDT:   
    4.      {   
    5.         __try  
    6.          {   
    7.              ProbeForWrite( OutputBuffer, sizeof( SSDT ), sizeof( ULONG ) );   
    8.              RtlCopyMemory( OutputBuffer, KeServiceDescriptorTable, sizeof( SSDT ) );   
    9.          }   
    10.          __except ( EXCEPTION_EXECUTE_HANDLER )   
    11.          {   
    12.              IoStatus->Status = GetExceptionCode();   
    13.          }   
    14.      }   
    15.     break;   
    16. case IOCTL_GETPROC:   
    17.      {   
    18.         ULONG uIndex = 0;   
    19.         PULONG pBase = NULL;   
    20.   
    21.         __try  
    22.          {   
    23.              ProbeForRead( InputBuffer, sizeof( ULONG ), sizeof( ULONG ) );   
    24.              ProbeForWrite( OutputBuffer, sizeof( ULONG ), sizeof( ULONG ) );   
    25.          }   
    26.          __except( EXCEPTION_EXECUTE_HANDLER )   
    27.          {   
    28.              IoStatus->Status = GetExceptionCode();   
    29.             break;   
    30.          }   
    31.   
    32.          uIndex = *(PULONG)InputBuffer;   
    33.         if ( KeServiceDescriptorTable->ulNumberOfServices <= uIndex )   
    34.          {   
    35.              IoStatus->Status = STATUS_INVALID_PARAMETER;   
    36.             break;   
    37.          }   
    38.          pBase = KeServiceDescriptorTable->pvSSDTBase;   
    39.          *((PULONG)OutputBuffer) = *( pBase + uIndex );   
    40.      }   
    41.     break;   
    42. // ...   
    43. }  

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

    C++代碼
    1. extern PSSDT KeServiceDescriptorTable;  

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

    C++代碼
    1. BOOL GetSSDT( IN HANDLE hDriver, OUT PSSDT buf );   
    2. BOOL GetProc( IN HANDLE hDriver, IN ULONG ulIndex, OUT PULONG buf );  

獲取詳細模塊信息

雖然我們現在可以獲取任意一個服務號所對應的函數地址了已經,但是你可能仍然不滿意,認爲只有獲得了這個服務函數所在的模塊纔是王道。換句話說,對於一個乾淨的SSDT表來說,它裏邊的表項應該都是指向ntoskrnl.exe的;如果SSDT之中有若干個表項被改寫(掛鉤),那麼我們應該知道是哪一個或哪一些模塊替換了這些服務。
首先我們需要獲得當前在ring0層加載了那些模塊。如我在本文開頭所說,爲了儘可能地少涉及ring0層的東西,於是在這裏我使用了ntdll.dll的NtQuerySystemInformation函數。關鍵代碼如下:

C++代碼
  1. typedef struct _SYSTEM_MODULE_INFORMATION {   
  2.     ULONG Reserved[2];   
  3.     PVOID Base;   
  4.     ULONG Size;   
  5.     ULONG Flags;   
  6.     USHORT Index;   
  7.     USHORT Unknown;   
  8.     USHORT LoadCount;   
  9.     USHORT ModuleNameOffset;   
  10.     CHAR ImageName[256];   
  11. } SYSTEM_MODULE_INFORMATION, *PSYSTEM_MODULE_INFORMATION;   
  12.   
  13. typedef struct _tagSysModuleList {   
  14.     ULONG ulCount;   
  15.      SYSTEM_MODULE_INFORMATION smi[1];   
  16. } SYSMODULELIST, *PSYSMODULELIST;   
  17.   
  18. s = NtQuerySystemInformation( SystemModuleInformation, pRet,   
  19.     sizeof( SYSMODULELIST ), &nRetSize );   
  20. if ( STATUS_INFO_LENGTH_MISMATCH == s )   
  21. {   
  22.     // 緩衝區太小,重新分配   
  23.     delete pRet;   
  24.      pRet = (PSYSMODULELIST)new BYTE[nRetSize];   
  25.      s = NtQuerySystemInformation( SystemModuleInformation, pRet,   
  26.          nRetSize, &nRetSize );   
  27. }  

需要說明的是,這個函數是利用內核的PsLoadedModuleList鏈表來枚舉系統模塊的,因此如果你遇到了能夠隱藏驅動的Rootkit,那麼這種方法是無法找到被隱藏的模塊的。在這種情況下,枚舉系統的“Driver”目錄對象可能可以更好解決這個問題,在此不再贅述了就。
接下來,是根據SSDT中的地址表項查找模塊。有了SYSTEM_MODULE_INFORMATION結構中的模塊基地址與模塊大小,這個工作完成起來也很容易:

C++代碼
  1. BOOL FindModuleByAddr( IN ULONG ulAddr, IN PSYSMODULELIST pList,   
  2.                        OUT LPSTR buf, IN DWORD dwSize )   
  3. {   
  4.     for ( ULONG i = 0; i < pList->ulCount; ++i )   
  5.      {   
  6.         ULONG ulBase = (ULONG)pList->smi[i].Base;   
  7.         ULONG ulMax   = ulBase + pList->smi[i].Size;   
  8.         if ( ulBase <= ulAddr && ulAddr < ulMax )   
  9.          {   
  10.             // 對於路徑信息,截取之   
  11.             PCSTR pszModule = strrchr( pList->smi[i].ImageName, '/' );   
  12.             if ( NULL != pszModule )   
  13.              {   
  14.                  lstrcpynA( buf, pszModule + 1, dwSize );   
  15.              }   
  16.             else  
  17.              {   
  18.                  lstrcpynA( buf, pList->smi[i].ImageName, dwSize );   
  19.              }   
  20.             return TRUE;   
  21.          }   
  22.      }   
  23.     return FALSE;   
  24. }  

詳細枚舉系統服務項

到現在爲止,還遺留有一個問題,就是獲得服務號對應的服務函數名。比如XP下0x7A對應着NtOpenProcess,但是到2000下,NtOpenProcess就改爲0x6A了。
——有一個好消息一個壞消息,你先聽哪個?
——什麼壞消息?
——Windows並沒有給我們開放這樣現成的函數,所有的工作都需要我們自己來做。
——那好消息呢?
——牛糞有的是。
壞了,串詞兒了。好消息是我們可以通過枚舉ntdll.dll的導出函數來間接枚舉SSDT所有表項所對應的函數,因爲所有的內核服務函數對應於ntdll.dll的同名函數都是這樣開頭的:

彙編代碼
  1. mov eax, <ServiceIndex>  

對應的機器碼爲:

機器碼
  1. B8 <ServiceIndex>  

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

C++代碼
  1. #pragma pack( push, 1 )   
  2. typedef struct _tagSSDTEntry {   
  3.     BYTE   byMov;   // 0xb8   
  4.     DWORD dwIndex;   
  5. } SSDTENTRY;   
  6. #pragma pack( pop )  

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

C++代碼
  1. #define MOV         0xb8   
  2.   
  3. void EnumSSDT( IN HANDLE hDriver, IN HMODULE hNtDll )   
  4. {   
  5.     DWORD dwOffset                   = (DWORD)hNtDll;   
  6.      PIMAGE_EXPORT_DIRECTORY pExpDir = NULL;   
  7.     int nNameCnt                     = 0;   
  8.     LPDWORD pNameArray               = NULL;   
  9.     int i                            = 0;   
  10.   
  11.     // 到PE頭部   
  12.      dwOffset += ((PIMAGE_DOS_HEADER)hNtDll)->e_lfanew + sizeof( DWORD );   
  13.     // 到第一個數據目錄   
  14.      dwOffset += sizeof( IMAGE_FILE_HEADER ) + sizeof( IMAGE_OPTIONAL_HEADER )   
  15.          - IMAGE_NUMBEROF_DIRECTORY_ENTRIES * sizeof( IMAGE_DATA_DIRECTORY );   
  16.     // 到導出表位置   
  17.      dwOffset = (DWORD)hNtDll   
  18.          + ((PIMAGE_DATA_DIRECTORY)dwOffset)->VirtualAddress;   
  19.      pExpDir = (PIMAGE_EXPORT_DIRECTORY)dwOffset;   
  20.   
  21.      nNameCnt = pExpDir->NumberOfNames;   
  22.     // 到函數名RVA數組   
  23.      pNameArray = (LPDWORD)( (DWORD)hNtDll + pExpDir->AddressOfNames );   
  24.   
  25.     // 初始化系統模塊鏈表   
  26.      PSYSMODULELIST pList = CreateModuleList( hNtDll );   
  27.   
  28.     // 循環查找函數名   
  29.     for ( i = 0; i < nNameCnt; ++i )   
  30.      {   
  31.         PCSTR pszName = (PCSTR)( pNameArray[i] + (DWORD)hNtDll );   
  32.         if ( 'N' == pszName[0] && 't' == pszName[1] )   
  33.          {   
  34.             // 找到了函數,則定位至查找表   
  35.             LPWORD pOrdNameArray = (LPWORD)( (DWORD)hNtDll + pExpDir->AddressOfNameOrdinals );   
  36.             // 定位至總表   
  37.             LPDWORD pFuncArray    = (LPDWORD)( (DWORD)hNtDll + pExpDir->AddressOfFunctions );   
  38.             LPCVOID pFunc         = (LPCVOID)( (DWORD)hNtDll + pFuncArray[pOrdNameArray[i]] );   
  39.                
  40.             // 解析函數,獲取服務名   
  41.              SSDTENTRY entry;   
  42.              CopyMemory( &entry, pFunc, sizeof( SSDTENTRY ) );   
  43.             if ( MOV == entry.byMov )   
  44.              {   
  45.                 ULONG ulAddr = 0;   
  46.                  GetProc( hDriver, entry.dwIndex, &ulAddr );   
  47.   
  48.                 CHAR strModule[MAX_PATH] = "[Unknown Module]";   
  49.                  FindModuleByAddr( ulAddr, pList, strModule, MAX_PATH );   
  50.                  printf( "0x%04X %s 0x%08X %s ", entry.dwIndex,   
  51.                      strModule, ulAddr, pszName );   
  52.              }   
  53.          }   
  54.      }   
  55.   
  56.      DestroyModuleList( pList );   
  57. }  

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

 

 

結語

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章