Rise的自留地

记录生活中的点滴,分享编程技术和游戏开发经验。

0%

开发软件时经常需要把一些东西做成可配置的,于是就需要用到配置文件,以前多是用ini文件,然后自己写个类来解析。现在有了XML,许多应用软件就喜欢把配置文件做成XML格式。但是如果我们的程序本身很小,为了读取个配置文件却去用Xerces XML之类的库,恐怕会得不偿失。那么用TinyXML吧,它很小,只有六个文件,加到项目中就可以开始我们的配置文件之旅了。

点这里下载本文的配套代码

引子

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中有类似这样的汇编代码:

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

虽然它们存在着些许不同,但都可以这么来概括:

  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的数据结构定义如下:

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,如下。

lkd> dd KeServiceDescriptorTable l4
8055ab80 804e3d20 00000000 0000011c 804d9f48

接下来,亦可根据基地址与服务总数来查看整个服务表的各项:

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的分发代码实现如下面的代码所示,没有什么技术含量,所以不再解释。

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的基地址、服务个数,以及各个表项所对应的服务号、所在模块、地址和服务名。

结语

ring3与ring0,城里与城外之间为一道叹息之墙所间隔,SSDT则是越过此墙的一道必经之门。因此,很多杀毒软件也势必会围绕着它大做文章。无论是System Safety Monitor的系统监控,还是卡巴斯基的主动防御,都是挂钩了SSDT。这样,病毒尚在ring3内发作之时,便被扼杀于摇篮之内。

内核最高权限,本就是兵家必争之地,魔高一尺道高一丈的争夺于此亦已变成颇为稀松平常之事。可以说和这些争夺比起来,SSDT的相关技术简直不值一提。但最初发作的病毒体总是从ring3开始的——换句话说,任你未来会成长为何等的武林高手,我都可以在你学走路的时候杀掉你——知晓了SSDT的这点优势,所有的病毒咂吧咂吧也就都没味儿了。所以说么,杀毒莫如防毒。

——就此打住罢,貌似扯远大发了。

终于,喧闹一时的“熊猫烧香”案尘埃落定,李俊及其同伙伏法。等待他们的虽是牢狱,但更有重新改过的机会。对于熊猫,这是个结尾;但对于很多人,这只是个开始。一个简单的蠕虫,竟然折射出了万万千千的嘴脸。我并无意将这个话题扯大,但是我不得不说。关于道德,关于盲目的畸形崇拜。

作为一个有五年Windows编程经验的职业程序员,我认为我有资格写下这些文字。如果来访的你以名气为尺度来衡量李马和李俊,那么你可以暂缓写下你的评论,先看看其他来访者的评论再说不迟。另外,我不想分析“熊猫烧香”这个病毒——直说就是不屑于分析。如果你抱着“你牛逼你也写一个啊”的态度来看李马,就先搜索一下Japussy这个开源的病毒源码,我没必要在这个问题上和你多费唇舌。

谈正题,道德与盲目的畸形崇拜。写病毒的人沦丧了自己的道德,盲目崇拜的人则助长了更多的人去沦丧自己的道德。随便写一个蠕虫,竟被媒体奉为“天才”,甚至于蒙蔽了民众的双眼,“吸纳李俊入安全部门”的呼声频起。我并不想借此讨论中国人治和法治的矛盾——我是赞成法外施恩的,但是就李俊来说,他还够不上陈盈豪的那个资格。我记得我说过,现在写Windows病毒的门槛很低,随随便便一个蹩脚的程序员就可以搞定。但这个事实对于绝大多数的网民来说仍然是个盲点,所以他们亦仍然沉浸在对所谓“天才”的盲目的、畸形的崇拜中。于是,“天才”这个头衔就像潘多拉魔盒一样,频频吸引着一些不知天高地厚的蹩脚程序员们。他们打开这个魔盒,释放了自己内心的恶灵,为害人间。

今年8月份的时候,我顺着50bang的访问来源来到了一个14岁少年的百度空间,这是一个用脑残体书写他名字的孩子,但他更是红极一时的“小浩”病毒作者。是的,接下来我看到了他转载的《城里城外看SSDT》,说明他对技术的热爱,当然也说明,他也许也会看到我这篇blog。——历史总是惊人的相似,他终于也被“天才”这个邪恶的光环俯身了。然而他不知道,不懂事写下并开源的病毒被某些别有用心的人利用了——这些人并不是编译病毒变种搞破坏的人,而是一些借刀杀人的商业实体的决策者。虽然14岁的你已经可以用MFC写病毒了,但你并不能预料成年人所使用的阴毒手段。由此看来,我们的这个互联网真的需要少一些浮躁,把“天才”这个词归还给和它真正相配的Bill Gates们。

就说这么多了。技术这东西是为产业服务的,换句话说,是有利益驱动的。但是,君子爱财取之有道,别拿亏心钱才是王道。

不知道诸位看官是否有过这样的经历:在不经意之间发现一个DLL文件,它里边有不少有趣的导出函数——但是由于你不知道如何调用这些函数,所以只能大发感慨而又无能为力焉。固然有些知名的DLL可以直接通过搜索引擎来找到它的使用方式(比如本文中的例子ipsearcher.dll),不过我们诚然不能希望自己总能交到这样的好运。所以在本文中,李马希望通过自己文理不甚通达的讲解能够给大家以授人以渔的效果。

先决条件

阅读本文,你需要具备以下先决条件:

  • 初步了解汇编语言,虽然你并不一定需要去读懂DLL中导出函数的汇编代码,但是你至少应该了解诸如push、mov这些常用的汇编指令。
  • 一个能够查看DLL中导出函数的工具,Visual Studio中自带的Dependency Walker就足够胜任了,当然你也可以选择eXeScope。
  • 一个调试器。理论上讲VC也可以完成调试的工作,但它毕竟是更加针对于源代码一级调试的工具,所以你最好选择一个专用的汇编调试器。在本文中我用的是OllyDbg——我不会介绍有关这个调试工具的任何东西,而只是简要介绍我的调试过程。

准备好了吗?那么我们做一个热身运动吧先。

热身——函数调用约定

这里要详细介绍的是有关函数调用约定的内容,如果你已经了解了这方面的内容,可以跳过本节。

你可能在学习Windows程序设计的时候早已接触过“函数调用约定”这个词汇了,那个时候你所了解的内容可能是一个笼统的概念,内容大抵是说函数调用约定就是指的函数参数进栈顺序以及堆栈修正方式。譬如cdecl调用约定是函数参数自右而左进栈,由调用者修复堆栈;stdcall调用约定亦是函数参数自右而左进栈,但是由被调用者修复堆栈……噢不,这太晦涩了——在源代码上我们是无法看到这些东西的!

那么我们别无选择,只有深入到汇编一层了。考虑以下C++代码:

#include <stdio.h>
int __cdecl max1( int a, int b )
{
return a > b ? a : b;
}
int __stdcall max2( int a, int b )
{
return a > b ? a : b;
}
int main()
{
    printf( "max( 1, 2 ) of cdecl version: %d\n", max1( 1, 2 ) );
    printf( "max( 1, 2 ) of stdcall version: %d\n", max2( 1, 2 ) );
return 0;
}

对应的汇编代码为:

; int __cdecl max1( int a, int b )
00401000 MOV EAX,DWORD PTR SS:[ESP+4]
00401004 MOV ECX,DWORD PTR SS:[ESP+8]
00401008 CMP EAX,ECX
0040100A JG SHORT CppTest.0040100E
0040100C MOV EAX,ECX
0040100E RETN
; int __stdcall max2( int a, int b )
00401010 MOV EAX,DWORD PTR SS:[ESP+4]
00401014 MOV ECX,DWORD PTR SS:[ESP+8]
00401018 CMP EAX,ECX
0040101A JG SHORT CppTest.0040101E
0040101C MOV EAX,ECX
0040101E RETN 8 ; 被调用者的堆栈修正
; max1( 1, 2 )
00401030 PUSH 2
00401032 PUSH 1
00401034 CALL CppTest.00401000
00401039 ADD ESP,8 ; 调用者的堆栈修正
; max2( 1, 2 )
0040104A PUSH 2
0040104C PUSH 1
0040104E CALL CppTest.00401010

好了,我来简要介绍一下。函数参数传入函数体是借由堆栈段完成的,也就是将各个参数依某种次序推入SS中——在cdecl与stdcall约定中,这个次序都是自右而左的。另外,由于将参数推入了堆栈致使堆栈指针ESP发生了变化,所以要在函数结束的时候重新修正ESP。从上边的汇编代码中你也可以很清楚地看到,cdecl约定是在调用max1之后修正的ESP,而stdcall约定则是在max2返回时借由RETN 8完成了这个修正工作。

另外,从上边的汇编代码中还可以看到,函数的返回值是由EAX带回的。

庖丁解牛

在了解了以上的知识后,我们就可以使用调试器来调试那个未知的DLL了。可以说,这整个的调试过程充满了惊险和刺激,而且我们还需要一定的技巧——如果你像我一样不喜欢阅读汇编代码的话。

在本文中,我所选择的调试示例是FTerm中附带的ipsearcher.dll,它提供了对纯真IP数据库的查询接口。下图是用Dependency Walker对其分析的结果:

你可以看到,这里边有两个导出函数:LookupAddress和_GetAddress,那么我们可以按照返回值、调用约定、函数名、参数列表的顺序将它们声明如下:

? ? LookupAddress( ? );
? ? _GetAddress( ? );

是的,有太多的未知,下面李马将要逐一地破解这些问号。

调试器不可能孤立地对DLL进行调试,我们所需要的应该是一个合适的EXE,这样有助于我们的探究工作。在这里我选择的EXE是我编写的ipsearcher.exe,当然这可能会让你认为我这篇文章的组织顺序有问题——毕竟是我已经知道了这两个导出函数之后(编写了ipsearcher.exe)还要假装成不知道的样子来对ipsearcher.dll来进行探究,所以我决定在下文中不对ipsearcher.exe的代码进行任何关注,而是直接进入到ipsearcher.dll的领空。

打开调试器,载入ipsearcher.exe。当ipsearcher.dll被装载后,会引发一个访问异常,可以忽略这个异常继续调试。根据Dependency Walker的分析结果,在ipsearcher.dll的0x00001BB0和0x00001C40处各下一个断点。现在在“IP地址”中输入一个IP地址(这里以寒泉BBS的IP为例),点击“查询”,会发现指令跳入0x00001C40中(也就是_GetAddress),它的代码如下:

10001C40 MOV EAX,DWORD PTR SS:[ESP+4] ; 一个参数
10001C44 PUSH ipsear_1.10009BE8
10001C49 PUSH EAX
10001C4A CALL ipsear_1.LookupAddress ; 两个参数
10001C4F ADD ESP,8 ; LookupAddress是cdecl调用约定
10001C52 MOV EAX,ipsear_1.10009BE8
10001C57 RETN ; _GetAddress这厮也是cdecl调用约定

很短的几行代码,不过它已经可以提供这些信息了:

  • 从SS的使用来看,_GetAddress只带有一个参数。
  • _GetAddress中调用了LookupAddress,后者带有两个参数。
  • 调用LookupAddress之后进行了堆栈修正,所以LookupAddress是cdecl调用约定。
  • _GetAddress返回时并未进行堆栈修正,所以_GetAddress也是cdecl调用约定。

于是,我们可以替换一下刚才的问号了:

? CDECL LookupAddress( ?, ? );
? CDECL _GetAddress( ? );

下面可以进行单步调试了,当代码步至10001C44时,你会发现寄存器窗口发生了如下的变化:

“202.207.177.9”终于出现了,这样一来我们可以继续对问号进行替换了:

? CDECL LookupAddress( PCSTR, ? );
? CDECL _GetAddress( PCSTR );

现在继续对代码进行跟踪,是进入LookupAddress的时候了。我们可以从先前_GetAddress的代码中可以发现,这两个导出函数一直在围绕10009BE8这个地址做文章,那么我们就要在单步调试LookupAddress的同时关注这个地址的数据改变。几步跟踪之后,你会发现10009BE8开头的8字节(两个DWORD)数据发生了改变,变成了10009AB4和10009B1C。那么我们再转向这两个地址,会发现:

这样一来就很清楚了,10009BE8是一个字符串指针的数组,它有两个元素。也就是说,我们的函数声明可以换成这样:

? CDECL LookupAddress( PCSTR, PSTR* );
PSTR* CDECL _GetAddress( PCSTR );

接下来需要确定的就是LookupAddress的返回值了。纵观LookupAddress的返回代码,你会发现这样的片断:

; 片断1
10001C0B XOR EAX,EAX
10001C0D POP ESI
10001C0E RETN
; 片断2
10001C2B MOV EAX,1
10001C30 POP ESI
10001C31 RETN

也就是说,这个函数有两个返回值:0或1。那么最后的真相终于大白于天下——

BOOL CDECL LookupAddress( PCSTR, PSTR* );
PSTR* CDECL _GetAddress( PCSTR );

GetProcAddress?

到此为止,这两个函数的声明终于让我们找出来了。也许你会觉得这就够了——接下来就是用typedef定义函数指针,然后使用LoadLibrary、GetProcAddress调用这些函数的事情了。

如果你真的这么认为的话,那我认为我有必要向你介绍这另外的一种方式。

首先请你建立一个名为ipsearcher.def的文件,然后在其中写入如下内容:

LIBRARY "ipsearcher"
EXPORTS
LookupAddress @1
_GetAddress   @2

将文件保存后,进入到命令行模式下,输入以下命令(前提是你拥有Visual Studio的附带工具lib.exe并有正确的路径指向。以Visual Studio 6.0为例,这个工具通常位于Microsoft Visual Studio\VC98\Bin下):

lib /def:ipsearcher.def

执行的结果有一个警告,不必理会。这时候我们会发现,lib为我们生成了一个ipsearcher.lib。

然后,我们继续编写ipsearcher.h文件,如下:

#ifndef IPSEARCHER_H
#define IPSEARCHER_H
#include <windows.h>
#pragma comment( lib, "ipsearcher.lib" )
extern "C"
{
BOOL CDECL LookupAddress( PCSTR, PSTR* );
PSTR* CDECL _GetAddress( PCSTR );
};
#endif // IPSEARCHER_H

大功告成!这样我们就为这个光秃秃的ipsearcher.dll做了一份SDK开发包,而不必再使用动态加载的方法了。

总结一下再

其实,探究一个DLL并非像我这里所讲述的这么简单。这项工作很可能需要阅读大量的汇编代码,了解DLL函数体的流程才能使真相大白于天下。另外,还不能排除有的DLL被加密、加壳、反跟踪……也就是说对于ipsearcher.dll,那简直就是我捡了个便宜来借花献佛了。

点这里下载ipsearcher SDK

DLL的远程注入技术是目前Win32病毒广泛使用的一种技术。使用这种技术的病毒体通常位于一个DLL中,在系统启动的时候,一个EXE程序会将这个DLL加载至某些系统进程(如Explorer.exe)中运行。这样一来,普通的进程管理器就很难发现这种病毒了,而且即使发现了也很难清除,因为只要病毒寄生的进程不终止运行,那么这个DLL就不会在内存中卸载,用户也就无法在资源管理器中删除这个DLL文件,真可谓一箭双雕哉。

记得2003年QQ尾巴病毒肆虐的时候,就已经有些尾巴病毒的变种在使用这种技术了。到了2004年初,我曾经尝试着仿真了一个QQ尾巴病毒,但独是跳过了DLL的远程加载技术。直到最近在学校论坛上看到了几位朋友在探讨这一技术,便忍不住将这一尘封已久的技术从我的记忆中拣了出来,以满足广大的技术爱好者们。

必备知识

在阅读本文之前,你需要了解以下几个API函数:

OpenProcess - 用于打开要寄生的目标进程。
VirtualAllocEx/VirtualFreeEx - 用于在目标进程中分配/释放内存空间。
WriteProcessMemory - 用于在目标进程中写入要加载的DLL名称。
CreateRemoteThread - 远程加载DLL的核心内容,用于控制目标进程调用API函数。
LoadLibrary - 目标进程通过调用此函数来加载病毒DLL。

在此我只给出了简要的函数说明,关于函数的详细功能和介绍请参阅MSDN。

示例程序

我将在以下的篇幅中用一个简单的示例Virus.exe来实现这一技术。这个示例的界面如下图:

首先运行Target.exe,这个文件是一个用Win32 Application向导生成的“Hello, World”程序,用来作为寄生的目标进程。

然后在界面的编辑控件中输入进程的名称“Target.exe”,单击“注入DLL”按钮,这时候Virus.exe就会将当前目录下的DLL.dll注入至Target.exe进程中。

在注入DLL.dll之后,你也可以单击“卸载DLL”来将已经注入的DLL卸载。

点这里下载示例程序

模拟的病毒体DLL.dll

这是一个简单的Win32 DLL程序,它仅由一个入口函数DllMain组成:

BOOL WINAPI DllMain( HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved )
{
switch ( fdwReason )
    {
case DLL_PROCESS_ATTACH:
        {
            MessageBox( NULL, _T("DLL已进入目标进程。"), _T("信息"), MB_ICONINFORMATION );
        }
break;
case DLL_PROCESS_DETACH:
        {
            MessageBox( NULL, _T("DLL已从目标进程卸载。"), _T("信息"), MB_ICONINFORMATION );
        }
break;
    }
return TRUE;
}

如你所见,这里我在DLL被加载和卸载的时候调用了MessageBox,这是用来显示我的远程注入/卸载工作是否成功完成。而对于一个真正的病毒体来说,它往往就是处理DLL_PROCESS_ATTACH事件,在其中加入了启动病毒代码的部分:

case DLL_PROCESS_ATTACH:
    {
        StartVirus();
    }
break;

注入!

现在要开始我们的注入工作了。首先,我们需要找到目标进程:

DWORD FindTarget( LPCTSTR lpszProcess )
{
    DWORD dwRet = 0;
    HANDLE hSnapshot = CreateToolhelp32Snapshot( TH32CS_SNAPPROCESS, 0 );
    PROCESSENTRY32 pe32;
    pe32.dwSize = sizeof( PROCESSENTRY32 );
    Process32First( hSnapshot, &pe32 );
do
    {
if ( lstrcmpi( pe32.szExeFile, lpszProcess ) == 0 )
        {
            dwRet = pe32.th32ProcessID;
break;
        }
    } while ( Process32Next( hSnapshot, &pe32 ) );
    CloseHandle( hSnapshot );
return dwRet;
}

这里我使用了Tool Help函数库,当然如果你是NT系统的话,也可以选择PSAPI函数库。这段代码的目的就是通过给定的进程名称来在当前系统中查找相应的进程,并返回该进程的ID。得到进程ID后,就可以调用OpenProcess来打开目标进程了:

// 打开目标进程
HANDLE hProcess = OpenProcess( PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, dwProcessID );

现在有必要说一下OpenProcess第一个参数所指定的三种权限。在Win32系统下,每个进程都拥有自己的4G虚拟地址空间,各个进程之间都相互独立。如果一个进程需要完成跨进程的工作的话,那么它必须拥有目标进程的相应操作权限。在这里,PROCESS_CREATE_THREAD表示我可以通过返回的进程句柄在该进程中创建新的线程,也就是调用CreateRemoteThread的权限;同理,PROCESS_VM_OPERATION则表示在该进程中分配/释放内存的权限,也就是调用VirtualAllocEx/VirtualFreeEx的权限;PROCESS_VM_WRITE表示可以向该进程的地址空间写入数据,也就是调用WriteProcessMemory的权限。

至此目标进程已经打开,那么我们该如何来将DLL注入其中呢?在这之前,我请你看一行代码,是如何在本进程内显式加载DLL的:

HMODULE hDll = LoadLibrary( "DLL.dll" );

那么,如果能控制目标进程调用LoadLibrary,不就可以完成DLL的远程注入了么?的确是这样,我们可以通过CreateRemoteThread将LoadLibrary作为目标进程的一个线程来启动,这样就可以完成“控制目标进程调用LoadLibrary”的工作了。到这里,也许你会想当然地写下类似这样的代码:

DWORD dwID;
LPVOID pFunc = LoadLibraryA;
HANDLE hThread = CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, (LPVOID)"DLL.dll", 0, &dwID );

不过结果肯定会让你大失所望——注入DLL失败!

嗯嗯,那么现在让我们来分析一下失败的原因吧。我是前说过,在Win32系统下,每个进程都拥有自己的4G虚拟地址空间,各个进程之间都是相互独立的。在这里,我们当作参数传入的字符串"DLL.dll"其实是一个数值,它表示这个字符串位于Virus.exe地址空间之中的地址,而这个地址在传给Target.exe之后,它指向的东西就失去了有效性。举个例子来说,譬如A、B两栋大楼,我住在A楼的401;那么B楼的401住的是谁我当然不能确定——也就是401这个门牌号在B楼失去了有效性,而且如果我想要入住B楼的话,我就必须请B楼的楼长为我在B楼中安排新的住处(当然这个新的住处是否401也就不一定了)。

由此看来,我就需要做这么一系列略显繁杂的手续——首先在Target.exe目标进程中分配一段内存空间,然后向这段空间写入我要加载的DLL名称,最后再调用CreateRemoteThread。这段代码就成了这样:

// 向目标进程地址空间写入DLL名称
DWORD dwSize, dwWritten;
dwSize = lstrlenA( lpszDll ) + 1;
LPVOID lpBuf = VirtualAllocEx( hProcess, NULL, dwSize, MEM_COMMIT, PAGE_READWRITE );
if ( NULL == lpBuf )
{
    CloseHandle( hProcess );
// 失败处理
}
if ( WriteProcessMemory( hProcess, lpBuf, (LPVOID)lpszDll, dwSize, &dwWritten ) )
{
// 要写入字节数与实际写入字节数不相等,仍属失败
if ( dwWritten != dwSize )
    {
        VirtualFreeEx( hProcess, lpBuf, dwSize, MEM_DECOMMIT );
        CloseHandle( hProcess );
// 失败处理
    }
}
else
{
    CloseHandle( hProcess );
// 失败处理
}
// 使目标进程调用LoadLibrary,加载DLL
DWORD dwID;
LPVOID pFunc = LoadLibraryA;
HANDLE hThread = CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, lpBuf, 0, &dwID );

需要说的有两点,一是由于我要在目标进程中为ANSI字符串来分配内存空间,所以这里凡是和目标进程相关的部分,都明确使用了后缀为“A”的API函数——当然,如果要使用Unicode字符串的话,可以换作后缀是“W”的API;第二,在这里LoadLibrary的指针我是取的本进程的LoadLibraryA的地址,这是因为LoadLibraryA/LoadLibraryW位于kernel32.dll之中,而Win32下每个应用程序都会把kernel32.dll加载到进程地址空间中一个固定的地址,所以这里的函数地址在Target.exe中也是有效的。

在调用LoadLibrary完毕之后,我们就可以做收尾工作了:

// 等待LoadLibrary加载完毕
WaitForSingleObject( hThread, INFINITE );
// 释放目标进程中申请的空间
VirtualFreeEx( hProcess, lpBuf, dwSize, MEM_DECOMMIT );
CloseHandle( hThread );
CloseHandle( hProcess );

在此解释一下WaitForSingleObject一句。由于我们是通过CreateRemoteThread在目标进程中另外开辟了一个LoadLibrary的线程,所以我们必须等待这个线程运行完毕才能够释放那段先前申请的内存。

好了,现在你可以尝试着整理这些代码并编译运行。运行Target.exe,然后开启一个有模块查看功能的进程查看工具(在这里我使用我的July)来查看Target.exe的模块,你会发现在注入DLL之前,Target.exe中并没有DLL.dll的存在:

在调用了注入代码之后,DLL.dll就位于Target.exe的模块列表之中了:

矛盾相生

记得2004年初我将QQ尾巴病毒成功仿真后,有很多网友询问我如何才能杀毒,不过我都没有回答——因为当时我研究的重点并非病毒的寄生特性。这一寄生特性直到今天可以说我才仿真完毕,那么,我就将解毒的方法也一并公开吧。

和DLL的注入过程类似,只不过在这里使用了两个API:GetModuleHandle和FreeLibrary。出于篇幅考虑,我略去了与注入部分相似或相同的代码:

// 使目标进程调用GetModuleHandle,获得DLL在目标进程中的句柄
DWORD dwHandle, dwID;
LPVOID pFunc = GetModuleHandleA;
HANDLE hThread = CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, lpBuf, 0, &dwID );
// 等待GetModuleHandle运行完毕
WaitForSingleObject( hThread, INFINITE );
// 获得GetModuleHandle的返回值
GetExitCodeThread( hThread, &dwHandle );
// 释放目标进程中申请的空间
VirtualFreeEx( hProcess, lpBuf, dwSize, MEM_DECOMMIT );
CloseHandle( hThread );
// 使目标进程调用FreeLibrary,卸载DLL
pFunc = FreeLibrary;
hThread = CreateRemoteThread( hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pFunc, (LPVOID)dwHandle, 0, &dwID );
// 等待FreeLibrary卸载完毕
WaitForSingleObject( hThread, INFINITE );
CloseHandle( hThread );
CloseHandle( hProcess );

用这个方法可以卸载一个进程中的DLL模块,当然包括那些非病毒体的DLL。所以,这段代码还是谨慎使用为好。

在完成卸载之后,如果没有别的程序加载这个DLL,你就可以将它删除了。

到此为止,整个的技术细节我就讲完了。

Windows SDK开发包中并未提供所有的API函数,在本文中我将讨论如何调用这种未公开的API函数。

事实上所有未公开的API函数都和其它的API函数一样包含在系统的动态链接库中,调用这些函数的方法是取得它们的指针,然后通过指针来进行操作。而取得函数地址,是通过GetProcAddress这个API函数实现的:

FARPROC WINAPI GetProcAddress(
    HMODULE hModule, // DLL模块句柄
    LPCSTR lpProcName // 函数名称
);

当然,在取得地址之前,需要用LoadLibrary获得模块的句柄。还有,为了书写方便,最好用typedef将函数指针定义为一种类型。

下面我将通过两个例子来演示如何调用这些未公开的API函数。

一、有名称的函数

这种函数在DLL中拥有自己的函数名称,但是在SDK包中并没有提供声明,其中最有代表性的是RegisterServiceProcess函数:

DWORD WINAPI RegisterServiceProcess(
    DWORD dwProcessId, // 进程ID
    DWORD dwType // 注册种类,1表示注册
);

这个函数的功能是在Win98下将进程注册为系统服务进程,很多木马程序的隐藏就是用这个函数实现的。调用它的示例代码如下:

typedef DWORD (WINAPI * REGISTER)( DWORD, DWORD );
HMODULE hModule;
REGISTER RegisterServiceProcess;
hModule = LoadLibrary( "kernel32.dll" );
if ( hModule != NULL )
{
    RegisterServiceProcess = (REGISTER)GetProcAddress( hModule, "RegisterServiceProcess" );
    RegisterServiceProcess( GetCurrentProcessId(), 1 );
    FreeLibrary( hModule );
}

二、无名称的函数

有的函数在DLL中并没有函数名称,这又如何调用呢?事实上所有的API函数无论有无名称,都会有一个ID,来在DLL中标识自己。比如函数RunFileDlg,它的ID是61,功能是显示系统“运行”对话框。下图所列的是我开发的进程管理软件July中所调用的“运行”对话框:

事实上调用这种函数的方法和前一种非常相似,唯一不同的只是把GetProcAddress的lpProcName参数使用MAKEINTRESOURCE宏将函数的ID转换一下即可。示例代码如下:

typedef void (WINAPI* RUN)( HWND, HICON, LPCSTR, LPCSTR, LPCSTR, UINT );
HMODULE hShell32;
RUN RunFileDlg;
hShell32 = LoadLibrary( "shell32.dll" );
RunFileDlg = (RUN)GetProcAddress( hShell32, MAKEINTRESOURCE( 61 ) );
RunFileDlg( hParent, hIcon, NULL, NULL, NULL, 0 );
FreeLibrary( hShell32 );

未公开的API函数的调用方法就介绍到这里了。事实上还有很多这样的函数,关于这些函数的介绍及使用方法,请下载我的“未公开的Windows API函数”文档

首先请大家看这么一个简单的小程序:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
void main()
{
    int i, b[10];
    for ( i = 0; i <= 10; i++ )
    {
        b[i] = 0;
    }
}

请问这个程序是否有错?A.正常 B.越界 C.死循环

正确答案是C,相信选A或选B的朋友一定会很纳闷。事实上我也是如此,单单从程序的表面上看,按定义这应该是个越界,因为当循环进行到i == 10的时候,程序将试图将b[10]赋值为0,而C语言中,b[10]的声明就是指定b[0]~b[9]可用。

题目:

设有如下C++类

class A
{
int value;
public:
    A( int n = 0 ) : value( n ) {}
int GetValue()
    {
return value;
    }
};

请使用某种方式来在类的外部改变私有成员A::value的值。

程序员的可能做法:

class A
{
int value;
public:
    A( int n = 0 ) : value( n ) {}
int GetValue()
    {
return value;
    }
void SetValue( int n )
    {
        value = n;
    }
};
void f()
{
    A a;
    a.SetValue( 5 );
}

黑客的可能做法:

void f()
{
    A a;
    *( (int *)&a ) = 5;
}

结论:

程序员习惯于遵循既有的限制来增加既有的东西。

黑客习惯于利用既有的东西来打破既有的限制。

第一阶段

此阶段主要是能熟练地使用某种语言。这就相当于练武中的套路和架式这些表面的东西。

第二阶段

此阶段能精通基于某种平台的接口(例如我们现在常用的Win 32的API函数)以及所对应语言的自身的库函数。到达这个阶段后,也就相当于可以进行真

朋友帖了如下一段代码:

  #pragma pack(4)

  class TestB

  {

  public:

    int aa;

    char a;

    short b;

    char c;

  };

  int nSize = sizeof(TestB);

  这里nSize结果为12,在预料之中。

  现在去掉第一个成员变量为如下代码:

  #pragma pack(4)