找回密码
 开放注册

QQ登录

只需一步,快速开始

微信登录

微信扫码,快速开始

搜索
查看: 695|回复: 0

城里城外看SSDT

[复制链接]

264

主题

210

回帖

621

牛毛

一级牛人

要不  我也改

积分
621
发表于 2008-7-18 16:51:23 | 显示全部楼层 |阅读模式 来自 陕西省西安市
现在所谓过主动无非就是从Ring3跳到Ring0层.看完这篇文章你就知道了.











___________________________________

作者:李马


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 DescriptorTable,系统服务描述符表。这个表就是一个把ring3的Win32API和ring0的内核API联系起来的角色,下面我将以API函数OpenProcess为例说明这个联系的过程。
      你可以用任何反汇编工具来打开你的kernel32.dll,然后你会发现在OpenProcess中有类似这样的汇编代码:
                                           [td]call ds:NtOpenProcess[/td]           
                     
      这就是说,OpenProcess调用了ntdll.dll的NtOpenProcess函数。那么继续反汇编之,你会发现ntdll.dll中的这个函数很短:
                                           [td]mov eax, 7Ah
            mov edx, 7FFE0300h
            call dword ptr [edx]
            retn 10h[/td]           
                     
      另外,call的一句实质是调用了KiFastSystemCall:
                                           [td]mov edx, esp
            sysenter[/td]           
                     
      上面是我的XP Professional sp2中ntdll.dll的反汇编结果,如果你用的是2000系统,那么可能是这个样子:
                                           [td]mov eax, 6Ah
            lea edx, [esp+4]
            int 2Eh
            retn 10h[/td]           
                     
      虽然它们存在着些许不同,但都可以这么来概括:
      
把一个数放入eax(XP是0x7A,2000是0x6A),这个数值称作系统的服务号。
把参数堆栈指针(esp+4)放入edx。
sysenter或int 2Eh。
      好了,你在ring3能看到的东西就到此为止了。事实上,在ntdll.dll中的这些函数可以称作真正的NT系统服务的存根(Stub)函数。分隔ring3与ring0城里城外的这一道叹息之墙,也正是由它们打通的。接下来SSDT就要出场了,come some music。
      站在城墙看城外
      插一句先,貌似到现在为止我仍然没有讲出来SSDT是个什么东西,真正可以算是“犹抱琵琶半遮面”了。——书接上文,在你调用sysenter或int2Eh之后,Windows系统将会捕获你的这个调用,然后进入ring0层,并调用内核服务函数NtOpenProcess,这个过程如下图所示。


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




当程序的处理流程进入ring0之后,系统会根据服务号(eax)在SSDT这个系统服务描述符表中查找对应的表项,这个找到的表项就是系统服务函数NtOpenProcess的真正地址。之后,系统会根据这个地址调用相应的系统服务函数,并把结果返回给ntdll.dll中的NtOpenProcess。图中的“SSDT”所示即为系统服务描述符表的各个表项;右侧的“ntoskrnl.exe”则为Windows系统内核服务进程(ntoskrnl即为NT OSKerneL的缩写),它提供了相对应的各个系统服务函数。ntoskrnl.exe这个文件位于Windows的system32目录下,有兴趣的朋友可以反汇编一下。
      附带说两点。根据你处理器的不同,系统内核服务进程可能也是不一样的。真正运行于系统上的内核服务进程可能还有ntkrnlmp.exe、ntkrnlpa.exe这样的情况——不过为了统一起见,下文仍统称这个进程为ntoskrnl.exe。另外,SSDT中的各个表项也未必会全部指向ntoskrnl.exe中的服务函数,因为你机器上的杀毒监控或其它驱动程序可能会改写SSDT中的某些表项——这也就是所谓的“挂钩SSDT”——以达到它们的“主动防御”式杀毒方式或其它的特定目的。
      KeServiceDescriptorTable
      事实上,SSDT并不仅仅只包含一个庞大的地址索引表,它还包含着一些其它有用的信息,诸如地址索引的基地址、服务函数个数等等。ntoskrnl.exe中的一个导出项KeServiceDescriptorTable即是SSDT的真身,亦即它在内核中的数据实体。SSDT的数据结构定义如下:
                                           [td]typedef struct _tagSSDT {
               PVOID pvSSDTBase;
               PVOID pvServiceCounterTable;
               ULONG ulNumberOfServices;
               PVOID pvParamTableBase;
            } SSDT, *PSSDT;[/td]           
                     
      其中,pvSSDTBase就是上面所说的“系统服务描述符表”的基地址。pvServiceCounterTable则指向另一个索引表,该表包含了每个服务表项被调用的次数;不过这个值只在Checkd Build的内核中有效,在FreeBuild的内核中,这个值总为NULL(注:Check/Free是DDK的Build模式,如果你只使用SDK,可以简单地把它们理解为Debug/Release)。ulNumberOfServices表示当前系统所支持的服务个数。pvParamTableBase指向SSPT(SystemService Parameter Table,即系统服务参数表),该表格包含了每个服务所需的参数字节数。
      下面,让我们开看看这个结构里边到底有什么。打开内核调试器(以kd为例),输入命令显示KeServiceDescriptorTable,如下。
                                           [td]lkd> dd KeServiceDescriptorTable l4
            8055ab80 804e3d20 000****0000 00***11c 804d9f48[/td]           
                     
      接下来,亦可根据基地址与服务总数来查看整个服务表的各项:
                                           [td]lkd> dd 804e3d20 l11c
            804e3d20 805****7691 f84***17aa f84***17b4 f84***17be
            804e3d30 f84***17c8 f84***17d2 f84***17dc f84***17e6
            804e3d40 80***41c f84***17fa f84***04 f84***80e
            804e3d50 f84***18 f84***22 f84***82c f84***36
            ...[/td]           
                     
      你获得的结果可能和我会有不同——我指的是那堆以十六进制f开头的地址项,因为我的SSDT被System Safety Monitor接管了,没留下几个原生的ntoskrnl.exe表项。
      现在是写些代码的时候了。KeServiceDescriptorTable及SSDT各个表项的读取只能在ring0层完成,于是这里我使用了内核驱动并借助DeviceIoControl来完成。其中DeviceIoControl的分发代码实现如下面的代码所示,没有什么技术含量,所以不再解释。
                                           [td]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;
            // ...
            }[/td]           
                     
      补充一下,再。DDK的头文件中有一件很遗憾的事情,那就是其中并未声明KeServiceDescriptorTable,不过我们可以自己手动添加之:
                                           [td]extern PSSDT KeServiceDescriptorTable;[/td]           
                     
      ——当然,如果你对DDK开发实在不感兴趣的话,亦可以直接使用配套代码压缩包中的SSDTDump.sys,并使用DeviceIoControl发送IOCTL_GETSSDT和IOCTL_GETPROC控制码即可;或者,直接调用我为你准备好的两个函数:
                                           [td]BOOL GetSSDT( IN HANDLE hDriver, OUT PSSDT buf );
            BOOL GetProc( IN HANDLE hDriver, IN ULONG ulIndex, OUT PULONG buf );[/td]           
                     
      获取详细模块信息
      虽然我们现在可以获取任意一个服务号所对应的函数地址了已经,但是你可能仍然不满意,认为只有获得了这个服务函数所在的模块才是王道。换句话说,对于一个干净的SSDT表来说,它里边的表项应该都是指向ntoskrnl.exe的;如果SSDT之中有若干个表项被改写(挂钩),那么我们应该知道是哪一个或哪一些模块替换了这些服务。
      首先我们需要获得当前在ring0层加载了那些模块。如我在本文开头所说,为了尽可能地少涉及ring0层的东西,于是在这里我使用了ntdll.dll的NtQuerySystemInformation函数。关键代码如下:
                                           [td]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 );
            }[/td]           
                     
      需要说明的是,这个函数是利用内核的PsLoadedModuleList链表来枚举系统模块的,因此如果你遇到了能够隐藏驱动的Rootkit,那么这种方法是无法找到被隐藏的模块的。在这种情况下,枚举系统的“\\Driver”目录对象可能可以更好解决这个问题,在此不再赘述了就。
      接下来,是根据SSDT中的地址表项查找模块。有了SYSTEM_MODULE_INFORMATION结构中的模块基地址与模块大小,这个工作完成起来也很容易:
                                           [td]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.Base;
                 ULONG ulMax  = ulBase + pList->smi.Size;
                if ( ulBase <= ulAddr && ulAddr < ulMax )
                 {
                  // 对于路径信息,截取之
                   PCSTR pszModule = strrchr( pList->smi.ImageName, &#39;\\\\&#39; );
                  if ( NULL != pszModule )
                   {
                     lstrcpynA( buf, pszModule + 1, dwSize );
                   }
                  else
                   {
                     lstrcpynA( buf, pList->smi.ImageName, dwSize );
                   }
                  return TRUE;
                 }
               }
              return FALSE;
            }[/td]           
                     
      详细枚举系统服务项
      到现在为止,还遗留有一个问题,就是获得服务号对应的服务函数名。比如XP下0x7A对应着NtOpenProcess,但是到2000下,NtOpenProcess就改为0x6A了。
      ——有一个好消息一个坏消息,你先听哪个?
      ——什么坏消息?
      ——Windows并没有给我们开放这样现成的函数,所有的工作都需要我们自己来做。
      ——那好消息呢?
      ——牛粪有的是。
      坏了,串词儿了。好消息是我们可以通过枚举ntdll.dll的导出函数来间接枚举SSDT所有表项所对应的函数,因为所有的内核服务函数对应于ntdll.dll的同名函数都是这样开头的:
                                           [td]mov eax, <ServiceIndex>[/td]           
                     
      对应的机器码为:
                                           [td]B8 <ServiceIndex>[/td]           
                     
      再说一遍:非常幸运,仅就我手头上的2000 sp4、XP、XP sp1、XP sp2、2003的ntdll.dll而言,无一例外。不过MarkRussinovich的《深入解析Windows操作系统》一书中指出,IA64的调用方式与此不同——由于手头上没有相应的文件,所以在这里不进行讨论了就。
      接着说。我们可以把mov的一句用如下的一个结构来表示:
                                           [td]#pragma pack( push, 1 )
            typedef struct _tagSSDTEntry {
               BYTE  byMov;  // 0xb8
               DWORD dwIndex;
            } SSDTENTRY;
            #pragma pack( pop )[/td]           
                     
      那么,我们可以对ntdll.dll的所有导出函数进行枚举,并筛选出“Nt”开头者,以SSDTENTRY的结构取出其开头5个字节进行比对——这就是整个的枚举过程。相关的PE文件格式解析我不再解释,可参考注释。整个代码如下:
                                           [td]#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 + (DWORD)hNtDll );
                if ( &#39;N&#39; == pszName[0] && &#39;t&#39; == pszName[1] )
                 {
                  // 找到了函数,则定位至查找表
                   LPWORD pOrdNameArray = (LPWORD)( (DWORD)hNtDll + pExpDir->AddressOfNameOrdinals );
                  // 定位至总表
                   LPDWORD pFuncArray  = (LPDWORD)( (DWORD)hNtDll + pExpDir->AddressOfFunctions );
                   LPCVOID pFunc     = (LPCVOID)( (DWORD)hNtDll + pFuncArray[pOrdNameArray] );
                  
                  // 解析函数,获取服务名
                   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 );
            }[/td]           
                     
      下图是示例程序SSDTDump在XP sp2上的部分运行截图,显示了SSDT的基地址、服务个数,以及各个表项所对应的服务号、所在模块、地址和服务名。


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

本版积分规则

帮助|Archiver|小黑屋|通信管理局专项备案号:[2008]238号|NB5用户社区 ( 皖ICP备08004151号;皖公网安备34010402700514号 )

GMT+8, 2025-1-12 20:48 , Processed in 0.125186 second(s), 29 queries , Yac On.

Powered by Discuz! X3.5

快速回复 返回顶部 返回列表