作者:Anton Bassov
原文地址:http://www.codeproject.com/KB/system/soviet_protector.aspx
该文同时发表于看雪论坛:http://bbs.pediy.com/showthread.php?t=126574
简介
最近我偶然看到一款叫作Sanctuary的安全产品的介绍,这个产品非常有趣.它可以阻止任何程序的的运行,只要在特定机器的"允许运行软件列表"中没有这个软件.因此,PC用户就可以对抗间谍插件,蠕虫和木马--即使一些恶意软件可以存在于电脑中,但是它却没有机会来运行,所以也没有机会来对机器造成破坏.当然,我发现这个功能非常有趣,一点思考之后自己把它做了出来.因此,该文介绍如何通过挂钩native API来在系统范围内用程序监控进程的创建.
该文做了一个大胆的假设,假设目标进程在用户模式下被创建(shell函数,CreateProcess(),手动进程创建作为native API调用的一个顺序等等).尽管理论上一个进程可以在内核模式下被创建,这种可能是有特定目的的,可以忽略不计,不用去担心它.为什么???试着想一下-要在内核模式下创建一个进程,一个程序必须要加载一个驱动,这样的话就意味着首先要执行一些用户模式的代码.因此,要阻止未授权的程序运行,我们可以安全地缩小范围到在系统内控制用户模式的创建就可以了.
制定我们的策略
首先,我们要决定欲在系统内监控一个进程创建我们必须做什么.
进程创建是一件相当复杂的事情,它牵扯到大量的工作(如果你不信我,可以反汇编CreateProcess(),你可以亲眼看到).为了启动一个进程,要做以下的步骤:
1.可执行文件被打开(FILE_EXECUTE访问权限)
2.可执行映像(Executable image)被加载到内存中
3.建立进程执行对象(Process Executive Object,EPROCESS,KPROCESS和PEB结构)
4.为新创建的进程分配地址空间
5.建立进程的主线程的线程执行对象(Thread Executive Object,ETHREAD,KTHREAD和TEB结构)
6.建立主线程的堆栈
7.建立主线程的执行上下文(Execution context)
8.通知Win32子系统新进程信息
对于这些步骤中任何一步的成功执行,所有之前的步骤都必须被成功完成(你不可以还没有一个 executable section的句柄就建立Executive Process Object;你也不可以还没有一个文件句柄就要映射executable section等等).因此,如果我们决定终止这些步骤中的任何一步,接下来的操作也同样会失败,这样进程创建就会被终止.所有这些步骤都要调用对应的native API函数,这是很容易理解的.所以,为了监控进程创建,我们需要做的就是挂钩这些API函数不让启动一个新进程的代码通过.
我们需要挂钩哪个native API函数?尽管NtCreateProcess()似乎是最明显的答案,但是它确实是错的--不通过这个函数创建一个进程是有可能的.比如说,CreateProcess()建立一个进程相关的内核模式结构而不通过NtCreateProcess().所以挂钩NtCreateProcess()对我们来说没用.
为了监视进程创建,我们可以挂钩NtCreateFile()和NtOpenFile(),也可以挂钩NtCreateSection()--一个可执行文件运行而不调用这些函数是绝不可能的.如果我们决定监视NtCreateFile()和NtOpenFile(),我们不得不区分进程创建和正常的文件IO操作.这个任务不怎么容易.例如,如果一个可执行文件用FILE_ALL_ACCESS属性来打开,我们应该怎么办???它是一个IO操作呢还是它是进程创建的一部分???在这点上面很难做判断--我们需要看看调用线程下一步将要做什么.因此,挂钩NtCreateFile()和NtOpenFile()不是最好的选择.
挂钩NtCreateSection()是更合理的--如果有一个映射可执行文件作为映像(SEC_IMAGE属性)的请求,我们拦截了这个请求对NtCreateSection()调用,再结合具有可执行属性的页的请求,我们可以确定进程是将要被启动.在这个时候我们可以做决定,如果我们不希望进程被创建,那就让NtCreateSection()返回STATUS_ACCESS_DENIED.因此,为了获得在机器上面全面监控进程创建的权力,我们要做的就是在系统上挂钩NtCreateSection().
和其他的函数(stub这里翻译成了函数)一样,NtCreateSection()把函数的服务索引号放进EAX中,让EDX指向函数的参数,然后执行转到内核函数KiDispatchService()(这一步在windows NT/2000下通过INT 0x2E指令,在windows XP下是SYSENTER实现).在检查过参数的有效性之后,KiDispatchService()把执行权转到实际的服务实现处,在SSDT(System Service Descriptor Table)中这个服务的地址是有效的(ntoskrnl.exe导出的KeServiceDescriptorTable变量指向这个表,因此对于内核驱动它可以使用).SSDT描述见下面的结构:
struct SYS_SERVICE_TABLE { void **ServiceTable; unsigned long CounterTable; unsigned long ServiceLimit; void **ArgumentsTable; };
这个结构的ServiceTable成员指向存放实际系统服务地址的数组.因此,要在系统中挂钩所有native API函数我们要做的就是把我们代理函数的地址写进数组的第i项(i是服务的索引),这个数组被KeServiceDescriptorTable中的ServiceTable指向(老外不知道咋想的,这句话刚说过又来重复,是不是觉得除了自己别人IQ都很低啊---冏).
看起来我们已经了解了要在系统中监控进程创建所有我们需要知道的东西了.开始进行实际工作吧!
控制进程创建
我们的解决方案由一个内核模式的驱动和一个用户模式的应用程序组成.为了开始监视进程创建,我们的应用程序根据NtCreateSection()传递服务索引值和通信缓冲区的地址给我们的驱动.以下的代码做了这个工作:
//open device device=CreateFile("////.//PROTECTOR",GENERIC_READ|GENERIC_WRITE, 0,0,OPEN_EXISTING, FILE_ATTRIBUTE_SYSTEM,0); // get index of NtCreateSection, and pass it to the driver, along with the //address of output buffer DWORD * addr=(DWORD *) (1+(DWORD)GetProcAddress(GetModuleHandle("ntdll.dll"), "NtCreateSection")); ZeroMemory(outputbuff,256); controlbuff[0]=addr[0]; controlbuff[1]=(DWORD)&outputbuff[0]; DeviceIoControl(device,1000,controlbuff,256,controlbuff,256,&dw,0);
这些代码都是可以自己解释自己的--需要我们一点注意的是我们获取服务索引值的方法.所有从ntdll.dll导出的函数(stub)都是以"MOV EAX, ServiceIndex"开始的,这个适用于所有windows NT的版本.这是一个5字节的指令,MOV EAX的机器码(opcode)作为第一个字节,索引值作为剩下的4个字节.因此,要获取特定native API所对应的服务索引值,你需要做的就是从这个(函数)地址中读取这个4个字节,在函数(stub)开始的1个字节的偏移处.
现在我们看一下当驱动接收到我们应用程序的IOCTL时它会做些什么:
NTSTATUS DrvDispatch(IN PDEVICE_OBJECT device,IN PIRP Irp) { UCHAR*buff=0; ULONG a,base; PIO_STACK_LOCATION loc=IoGetCurrentIrpStackLocation(Irp); if(loc->Parameters.DeviceIoControl.IoControlCode==1000) { buff=(UCHAR*)Irp->AssociatedIrp.SystemBuffer; // hook service dispatch table memmove(&Index,buff,4); a=4*Index+(ULONG)KeServiceDescriptorTable->ServiceTable; base=(ULONG)MmMapIoSpace(MmGetPhysicalAddress((void*)a),4,0); a=(ULONG)&Proxy; _asm { mov eax,base mov ebx,dword ptr[eax] mov RealCallee,ebx mov ebx,a mov dword ptr[eax],ebx } MmUnmapIoSpace(base,4); memmove(&a,&buff[4],4); output=(char*)MmMapIoSpace(MmGetPhysicalAddress((void*)a),256,0); } Irp->IoStatus.Status=0; IoCompleteRequest(Irp,IO_NO_INCREMENT); return 0; }
你可以看到,这里没什么特别的--我们仅仅通过MmMapIoSpace()映射通信缓冲区到内核地址空间,另外把我们的代理函数的地址写进SSDT中(当然了,我们是在保存了实际服务地址到全局变量RealCallee中之后才做的这个).为了覆盖SSDT中的合适的项(entry),我们通过MmMapIoSpace()映射目标地址.为什么要这样做呢?毕竟,我们已经对SSDT做了一次访问了(access),不是吗?问题是SSDT可能驻留在只读内存中.因此,我们不得不检查一下我们是否对目标页面有写权限,如果没有,我们需要在改写SSDT之前改变页面保护属性.太多工作了,难道你不这么认为吗?因此,我们简单地用MmMapIoSpace()映射我们的目标地址,这样我们就不用再担心页面保护属性了--从现在开始我们就被授权可以访问目标页面了.现在让我们看一眼我们的代理函数吧:
//this function decides whether we should //allow NtCreateSection() call to be successfull ULONG __stdcall check(PULONG arg) { HANDLE hand=0;PFILE_OBJECT file=0; POBJECT_HANDLE_INFORMATION info;ULONG a;char*buff; ANSI_STRING str; LARGE_INTEGER li;li.QuadPart=-10000; //check the flags. If PAGE_EXECUTE access to the section is not requested, //it does not make sense to be bothered about it if((arg[4]&0xf0)==0)return 1; if((arg[5]&0x01000000)==0)return 1; //get the file name via the file handle hand=(HANDLE)arg[6]; ObReferenceObjectByHandle(hand,0,0,KernelMode,&file,&info); if(!file)return 1; RtlUnicodeStringToAnsiString(&str,&file->FileName,1); a=str.Length;buff=str.Buffer; while(1) { if(buff[a]=='.'){a++;break;} a--; } ObDereferenceObject(file); //if it is not executable, it does not make sense to be bothered about it //return 1 if(_stricmp(&buff[a],"exe")){RtlFreeAnsiString(&str);return 1;} //now we are going to ask user's opinion. //Write file name to the buffer, and wait until //the user indicates the response //(1 as a first DWORD means we can proceed) //synchronize access to the buffer KeWaitForSingleObject(&event,Executive,KernelMode,0,0); // set first 2 DWORD of a buffer to zero, // copy the string into the buffer, and loop // until the user sets first DWORD to 1. // The value of the second DWORD indicates user's //response strcpy(&output[8],buff); RtlFreeAnsiString(&str); a=1; memmove(&output[0],&a,4); while(1) { KeDelayExecutionThread(KernelMode,0,&li); memmove(&a,&output[0],4); if(!a)break; } memmove(&a,&output[4],4); KeSetEvent(&event,0,0); return a; } //just saves execution contect and calls check() _declspec(naked) Proxy() { _asm{ //save execution contect and calls check() //-the rest depends upon the value check() returns // if it is 1, proceed to the actual callee. //Otherwise,return STATUS_ACCESS_DENIED pushfd pushad mov ebx,esp add ebx,40 push ebx call check cmp eax,1 jne block //proceed to the actual callee popad popfd jmp RealCallee //return STATUS_ACCESS_DENIED block:popad mov ebx, dword ptr[esp+8] mov dword ptr[ebx],0 mov eax,0xC0000022L popfd ret 32 } }
Proxy()保存了通用寄存器和标志寄存器,把该服务的参数指针压栈,然后调用check().剩下就要靠check()的返回值了.如果check()返回TRUE(比如我们希望处理这个请求),Proxy()恢复通用寄存器和标志寄存器,然后将控制权交给否服务实现处.否则,Proxy()将STATUS_ACCESS_DENIED传递给EAX,恢复ESP然后返回--调用者看来就像NtCreateSection()返回一个STATUS_ACCESS_DENIED错误状态而调用失败.
check()怎么做决定?一旦它接收到一个指向服务参数的指针作为实参,它能检查这些参数.首先,它检查标志和属性--如果一个段(section)没有请求被映射为一个可执行映像(image),或者如果请求的页面保护属性没有允许可执行,我们可以确定这次NtCreateSection()调用没有与进程创建相关.这种情况check()直接返回TRUE.否则,它检查文件的扩展名--毕竟,SEC_IMAGE属性和页面保护属性允许可执行也可能是映射一些DLL文件.如果这个文件不是.exe文件,check()返回TRUE.否则,它给用户模式的代码一个机会来做决定.因此,它只是把文件名称和路径写进通信缓冲区里面,然后不断测试它直到得到回应.
再打开我们的驱动之前,我们的应用程序创建了一个线程来运行下面这个函数:
void thread() { DWORD a,x; char msgbuff[512]; while(1) { memmove(&a,&outputbuff[0],4); //if nothing is there, Sleep() 10 ms and check again if(!a){Sleep(10);continue;} // looks like our permission is asked. If the file // in question is already in the white list, // give a positive response char*name=(char*)&outputbuff[8]; for(x=0;x<stringcount;x++) { if(!stricmp(name,strings[x])){a=1;goto skip;} } // ask user's permission to run the program strcpy(msgbuff, "Do you want to run "); strcat(msgbuff,&outputbuff[8]); // if user's reply is positive, add the program to the white list if(IDYES==MessageBox(0, msgbuff,"WARNING", MB_YESNO|MB_ICONQUESTION|0x00200000L)) {a=1; strings[stringcount]=_strdup(name);stringcount++;} else a=0; // write response to the buffer, and driver will get it skip:memmove(&outputbuff[4],&a,4); //tell the driver to go ahead a=0; memmove(&outputbuff[0],&a,4); } }
这个代码看到就知道什么意思了--我们的线程每隔10毫秒测试一下通信缓冲区.如果发现我们的驱动已经发送请求到缓冲区里,它就检查这个文件的名字和路径是否存在于机器上的"允许运行程序列表"中.如果查找到了,它直接给一个OK的回应.否则,它弹出一个消息框来询问用户是否允许运行这个可疑程序.如果得到的答复是肯定的,那么我们添加这个可疑程序到"允许运行软件列表"中.最后,我们把用户的选择写进(通信)缓冲区里,即传给我们的驱动程序.因此,用户得到了在自己机器上面进程创建的全部控制权--只要我们的程序运行了,绝对不可能发生在机器上没有经过用户同意就有进程启动了的现象.
你可以看到,我们让内核模式的代码等待用户的回答.这是一个明智的做法吗?为了回答这个问题,你必须要问问自己是否你正在阻止系统临界资源--所有的都要看情况(而定).我们这里的情况是所有的操作都发生在IRQL PASSIVE_LEVEL级别,没有涉及到处理IRPs,等待用户回应的线程也不是非常重要.所以,我们这种情况一切运行正常.然而,这个例子只是为了演示目的而写的.为了让它有实际的用处,重写我们的应用程序作为一个自动启动的服务是非常有意义的.这种情况下,我觉得我们应该对LocalSystem账户做一个解除,如果NtCreateSection()是在LocalSystem账户(这个账户可以在MSDN上查到说明:http://msdn.microsoft.com/en-us/libr...90(VS.85).aspx)的一个线程上下文中被调用的,在这种情况下,不需要做任何检查(直接)转到真正的服务执行处--毕竟,LocalSystem账户仅仅加载注册表指定的可执行文件.所以,这个的解除操作不会威胁到我们的安全.
结论
在结论中我必须要说挂钩native API是现存的最强的编程技术之一.这篇文章仅仅给了你一个例子来演示通过挂钩native API可以达到什么目的--你可以看到,我们通过挂钩了一个(!!!)native API函数就做到了阻止未授权程序的运行.你可以进一步的扩展这个方法来获取硬件设备的全部控制权,文件IO操作,网络流量等等.然而,我们目前的解决方案不适合内核API来调用(被挂钩的函数)--一旦内核模式的代码被允许直接调用ntoskrnl.exe的导出(函数),这些调用不需要经过SSDT.因此,在我的下一篇文章里面我们要挂钩ntoskrnl.exe本身.
这个例子已经在windows XP SP2的一些机器上测试成功.尽管我还没有在其他环境下做测试,我相信它在哪里都会运行正常的(这句话有点吹牛了,我直接下载的代码,直接运行就有问题)--毕竟,它没有用到任何可能和系统相关的结构.要运行这个例子,你需要做的就是把protector.exe和protector.sys放到同一个目录下面,然后运行protector.exe.在protector.exe的应用程序窗口关闭前,你每次试图运行任何的可执行文件时都会被提示(已经被允许运行的从第二次开始就不会再被提示,这点老外没表达清楚).
如果你给我发邮件告诉我你的评论和建议,我将十分感谢.
---------------------------------------------END--------------------------------------
译者的话:请在虚拟机下运行本程序!!!XP SP2下没有进行测试,XP SP3下有问题,需要在驱动protector.c的76行的while循环内首先对li.QuadPart进行重新赋值(加上li.QuadPart=-10000;这句代码即可),否则会死机.