城里城外看SSDT

您可以任意转载这篇文章,但请在转载时注明原始链接和作者,谢谢。

引子

2006年,中国互联网上的斗争硝烟弥漫。这时的战场上,先前颇为流行的窗口挂钩、API挂钩、进程注入等技术已然成为昨日黄花,大有逐渐淡出之势;取而代之的,则是更狠毒、更为赤裸裸的词汇:驱动、隐藏进程、Rootkit……
前不久,我不经意翻出自己2005年9月写下的一篇文章《DLL的远程注入技术》,在下面看到了一位名叫L4bm0s的网友说这种技术已经过时了。虽然我也曾想过拟出若干辩解之词聊作应对,不过最终还是作罢了——毕竟,拿出些新的、有技术含量的东西才是王道。于是这一次,李马首度从ring3(应用层)的围城跨出,一跃而投身于ring0(内核层)这一更广阔的天地,便有了这篇《城里城外看SSDT》。——顾名思义,城里和城外的这一墙之隔,就是ring3与ring0的分界。
在这篇文章里,我会用到太多杂七杂八的东西,比如汇编,比如内核调试器,比如DDK。这诚然是一件令我瞻前顾后畏首畏尾的事情——一方面在ring0我不得不依靠这些东西,另一方面我实在担心它们会导致我这篇文章的阅读门槛过高。所以,我决定尽可能少地涉及驱动、内核与DDK,也不会对诸如如何使用内核调试器等问题作任何讲解——你只需要知道我大概在做些什么,这就足够了。

什么是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,这个过程如下图所示。

大小: 3.3 K
尺寸: 303 x 310
浏览: 143 次
点击打开新窗口浏览全图

SSDT在这个过程中所扮演的角色是至关重要的。让我们先看一看它的结构,如下图。

大小: 2.55 K
尺寸: 451 x 158
浏览: 109 次
点击打开新窗口浏览全图

当程序的处理流程进入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;  

其中,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 ), sizeofULONG ) );   
  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, sizeofULONG ), sizeofULONG ) );   
  24.             ProbeForWrite( OutputBuffer, sizeofULONG ), sizeofULONG ) );   
  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 + sizeofDWORD );   
  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的基地址、服务个数,以及各个表项所对应的服务号、所在模块、地址和服务名。

大小: 14.33 K
尺寸: 500 x 169
浏览: 96 次
点击打开新窗口浏览全图

结语

ring3与ring0,城里与城外之间为一道叹息之墙所间隔,SSDT则是越过此墙的一道必经之门。因此,很多杀毒软件也势必会围绕着它大做文章。无论是System Safety Monitor的系统监控,还是卡巴斯基的主动防御,都是挂钩了SSDT。这样,病毒尚在ring3内发作之时,便被扼杀于摇篮之内。
内核最高权限,本就是兵家必争之地,魔高一尺道高一丈的争夺于此亦已变成颇为稀松平常之事。可以说和这些争夺比起来,SSDT的相关技术简直不值一提。但最初发作的病毒体总是从ring3开始的——换句话说,任你未来会成长为何等的武林高手,我都可以在你学走路的时候杀掉你——知晓了SSDT的这点优势,所有的病毒咂吧咂吧也就都没味儿了。所以说么,杀毒莫如防毒。
——就此打住罢,貌似扯远大发了。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值