引子 2006年,中国互联网上的斗争硝烟弥漫。这时的战场上,先前颇为流行的窗口挂钩、API挂钩、进程注入等技术已然成为昨日黄花,大有逐渐淡出之势;取而代之的,则是更狠毒、更为赤裸裸的词汇:驱动、隐藏进程、Rootkit…… 什么是SSDT? 什么是SSDT?自然,这个是我必须回答的问题。不过在此之前,请你打开命令行(cmd.exe)窗口,并输入“dir”并回车——好了,列出了当前目录下的所有文件和子目录。 到此为止我们可以看到,cmd.exe扮演了一个非常至关重要的角色,也就是用户与Win32 API的交互。——你大概已经可以猜到,我下面要说到的SSDT亦必将扮演这个角色,这实在是一点新意都没有。 汇编代码 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目录下,有兴趣的朋友可以反汇编一下。 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,即系统服务参数表),该表格包含了每个服务所需的参数字节数。 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表项。 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内发作之时,便被扼杀于摇篮之内。 附件:ssdtdump.zip |
|