本文将描述如何通过钩住本机API的方式来实现监控一个进程的创建并在系统级上对之进行控制。
本文大胆假设,目标进程是以一种用户模式(外壳函数,CreateProcess(),用一系列的本机API调用的手工的进程创建,等等)创建的。尽管从理论上,一个进程能够以内核方式启动;不过从实际来看,如此的可能性是可以忽略不计的,因此我们不必为此担心。为什么?请逻辑地思考一下-为了以内核方式启动一个进程,用户必须装载一个驱动程序,该驱动程序反过来首先要暗示某种用户模式代码的执行。因此,为了防止未被授权程序的执行,我们可以安全地在系统级上以用户模式限制我们自己控制的进程的创建。
首先让我们明确,之所以这样做的目的是为了在系统级上监视和控制进程创建。
进程创建是一件相当复杂的事情-它包含相当多的工作(如果你不相信我,可以反汇编CreateProcess(),这样你就会亲眼看到这点)。为了启动一个进程,可以使用下列步骤:
1.可执行文件必须被以FILE_EXECUTE存取方式打开。
2.可执行映像必须被装载进RAM。
3.必须建立进程执行对象(EPROCESS,KPROCESS和PEB结构)。
4.必须为新建进程分配地址空间。
5.必须建立进程的主线程的线程执行对象(ETHREAD,KTHREAD和TEBstructures)。
6.必须为主线程分配堆栈。
7.必须建立进程的主线程的执行上下文。
8.必须通知Win32子系统有关该新进程的创建情况。
为确保这些步骤中的任何一步的成功,所有其前面的步骤必须是成功执行的(你不能够在没有一个可执行区句柄的情况下建立一个可执行进程对象;没有文件句柄的情况下你无法映射一个可执行区,等等)。因此,如果我们决定退出任何这些步骤,所有后面的步骤也会失败,以至于整个进程创建会失败。上面所有的步骤都可以通过调用某些本机API函数的方式来实现,这是可以理解的。因此,为了监视和控制进程创建,我们所有要做的就是钩住这些API函数-它们无法旁路掉要创建一新进程所要执行的代码。
我们应该钩住哪些本机API函数呢?尽管NtCreateProcess()似乎是问题的最显然的答案,但是,这个答案是错误的-有可能不需要调用这个函数也可以创建一个新的进程。例如,CreateProcess()可以创建与进程相关的内核模式结构而不是调用NtCreateProcess()。因此,这样以来钩住NtCreateProcess()对我们毫无帮助。
为了监视进程的创建,我们必须或者钩住NtCreateFile()和NtOpenFile(),或者钩住NtCreateSection()-不经调用这些API是绝对无法运行任何可执行文件的。如果我们决定监视对NtCreateFile()和NtOpenFile()的调用,那么我们必须区别开进程创建和常规的文件IO操作。这项任务并不总是那么容易。例如,如果一些可执行文件正在被以FILE_ALL_ACCESS存取方式打开,我们该怎么办?这仅是一个IO操作还是一个进程创建的一部分?在这点上,是很难判断的-我们需要了解调用线程下一步要干什么。因此,钩住NtCreateFile()和NtOpenFile()不是最好的可能性选择。
钩住NtCreateSection()是更为合理的-如果我们想拦截对NtCreateSection()的调用,发出的请求是作为一个映像(SEC_IMAGE属性)映射可执行文件(SEC_IMAGE属性),同时请求允许执行的页面保护;那么,我们可以确信该进程将要被启动。在这一点上,我们是能够作出决定的,并且在我们不想要创建该进程的情况下,让NtCreateSection()返回STATUS_ACCESS_DENIED。因此,为了完全控制目标机器上的进程创建,所有我们要做的是在系统级上钩住NtCreateSection()。
象来自于ntdll.dll中的任何其它代理一样,NtCreateSection()用服务索引加载EAX,使EDX指向函数参数,并且把执行权传递到KiDispatchService()内核模式例程(这是通过Windows NT/2000中的INT 0x2E指令或者Windows XP下的SYSENTER指令实现的)。在校验完函数参数之后,KiDispatchService()把执行权传递到服务的实际实现部分-它的地址可用于服务描述表(指向这个表的指针由ntoskrnl.exe作为KeServiceDescriptorTable变量所输出,所以它对于内核模式驱动程序是可用的)中。服务描述表通过下列结构所描述:
此代码是显然的-唯一需要注意的是我们得到服务索引的方式。所有来自于ntdll.dll的代理都从一行代码MOV EAX,ServiceIndex开始-它可以适用于任何版本和风味的Windows NT。这是一条5字节长的指令,以MOV EAX操作码作第一字节,服务索引作为留下的4字节。因此,为了得到相应于一些特别的本机API函数的服务索引,所有你要做的是从该地址读取4个字节,-位于从这个代理开始1字节距离的地方。
现在让我们看一下我们的驱动程序做什么,当它收到来自我们的应用程序的IOCTL时:
正如你所见,这里没有什么特别的-我们只是通过MmMapIoSpace()来把交换缓冲区映射到内核中,另外把我们的代理函数的地址写到服务表(当然,我们这是在把实际的服务执行的地址保存到全局变量RealCallee以后这样做的)。为了改写服务表的适当入口,我们通过MmMapIoSpace()来映射目标地址。为什么我们要这样做?不管怎么说,我们已经可以存取服务表了,不是吗?问题是,服务表可能驻留在一段只读内存中。因此,我们必须检查一下是否我们有对目标空间写的权限,而如果我们没有这个权限,那么在改写服务表之前,我们必须改变页面保护。你不认为这样以来工作太多了吗?因此,我们仅用MmMapIoSpace()来映射我们的目标地址,这样以来,我们就不必担心任何的页面保护问题了-从现在开始,我们假定已有到目标页面写的权限了。现在让我们看一下我们的代理函数:
Proxy()保存寄存器和标志,把一个指向服务参数的指针压入栈中并调用check()。其它的依赖于check()所返回的值。如果check()返回TRUE(也就是,我们想要继续请求),那么,Proxy()将恢复寄存器和标志,并且把控制权交给服务实现部分。否则,Proxy()将把STATUS_ACCESS_DENIED写入EAX,恢复ESP并返回-从调用者的观点来看,这就象对NtCreateSection()的调用失败一样-以错误状态STATUS_ACCESS_DENIED返回。
check()函数是怎样做出决定的?一旦它收到一个指向服务参数的指针参数,它就可以检查这些参数。首先,它检查标志和属性-如果有一部分没有被要求作为一个可执行映像映射,或如果要求的页面保护不允许执行,那么我们可以确定NtCreateSection()调用与进程创建毫无关系。在这种情况下,check()直接返回TRUE。否则,它将检查该潜在文件的扩展-毕竟,SEC_IMAGE属性和允许执行的页面保护可能被要求来映射某个DLL文件。如果该潜在文件不是一个.exe文件,那么,check()将返回TRUE。否则,它给用户模式代码一个作出决定的机会。因此,它仅把文件名和路径写到交换缓冲区,并且对它循环查询,直到它得到响应为止。
在打开我们的驱动程序前,我们的应用程序创建一个运行下面函数的线程:
这段代码是显然的-我们的线程每10毫秒查询交换缓冲区。如果它发现我们的驱动程序已经把它的请求寄到了该缓冲区中,它就检查被允许在本机上运行的程序列表中的文件的文件名和路径。如果发现匹配,它直接给出一个OK响应。否则,它显示一个消息窗口,询问用户是否允许有问题的程序执行。如果响应是积极的,我们就把有问题的程序添加到允许在本机上运行的软件列表中。最后,我们把用户响应写入缓冲区,也就是说,把它传递到我们的驱动程序。因此,该用户就能完全控制它的PC上的进程的创建-只要我们的程序运行,在没有用户所给予权限的情况下,绝对没有办法来启动该PC上的任何进程。
正如你所见,我们让内核方式代码等待用户反应。这是否是一种聪明的举措呢?为了回答这个问题,你必须问你自己你是否正在堵住任何关键的系统资源-一切都依赖于具体的情况。在我们的情况下,一切发生在IRQLPASSIVE_LEVEL级上,并没有包含对IRPs的处理,并且必须等待用户响应的线程并不十分重要。因此,在我们的情况下,一切工作正常。然而,本例仅为演示之目的而编写。为了实际地使用它,以一个自动启动的服务的方式来重写我们的应用程序是很重要的。在这种情况下,我建议我们解除LocalSystem帐户,并且,在NtCreateSection()被用LocalSystem帐户特权在一个线程的上下文中调用的情况下,可以继续实际的服务实现而不施行任何检查-不管怎么说,LocalSystem帐户仅运行那些在注册表中指定的可执行程序。因此,这样的一种解除不会是与我们的安全相妥协的。